diff --git a/app/h/route.ts b/app/h/route.ts new file mode 100644 index 00000000..5aaf2df8 --- /dev/null +++ b/app/h/route.ts @@ -0,0 +1,49 @@ +// Short house-ad click redirector: https://crawlproof.com/h +// +// The terminal twin of /a/, for house ads. House fills are +// unmetered and have no impression row, so there is nothing to look up — this +// exists purely to keep the printed URL short. +// +// Why it has to be short: a terminal ad prints its click URL as literal text +// inside the ASCII box, and the box is only `cols` wide. At the narrowest +// supported width (44 cols) there are 40 columns of usable room, and the old +// house URL — +// +// https://crawlproof.com/?utm_source=house-ad&utm_medium=motd (59 chars, +// plus the publisher's &s= tag, so 66 in practice) +// +// — could not fit, so renderCreativeText was forced to print it below the +// frame, dangling well past the right edge of a box the caller had explicitly +// asked to be 44 wide. `/h` is 24 characters, or 31 with a surface tag, which +// fits inside the box at every supported width. +// +// The utm params the old URL carried inline are re-applied here instead, so +// attribution is unchanged — it just happens server-side, where characters are +// free. + +import { NextRequest, NextResponse } from "next/server"; +import { env } from "@/lib/env"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET(request: NextRequest) { + const base = env.siteUrl || "https://crawlproof.com"; + try { + // ?s= is the publisher's own tag (bbs, ssh-banner, motd, …), + // carried through from /api/ads/motd. Sanitised the same way it is there: + // it arrives from a query string and goes back out in a redirect. + const q = new URL(request.url).searchParams; + const src = (q.get("s") ?? q.get("src") ?? "").trim().replace(/[^\w.-]/g, "").slice(0, 32); + + const dest = new URL(base); + dest.searchParams.set("utm_source", "house-ad"); + // "terminal" rather than the old "motd": the surface is what ?s= records, + // and this same URL is printed into BBS screens and SSH banners too. + dest.searchParams.set("utm_medium", "terminal"); + if (src) dest.searchParams.set("utm_content", src); + return NextResponse.redirect(dest.toString(), { status: 302 }); + } catch { + return NextResponse.redirect(base, { status: 302 }); + } +} diff --git a/lib/ads/house.ts b/lib/ads/house.ts index a4308b74..77a738e0 100644 --- a/lib/ads/house.ts +++ b/lib/ads/house.ts @@ -180,12 +180,15 @@ export function houseFill(format: AdFormatId): Fill { // Drawn once here, then threaded through both the creative and the render so // a single fill can't advertise one pitch in its HTML and another in its text. const copy = pickHouse(); - // Terminals print the raw URL, so the terminal house ad uses a short one that - // fits the ASCII box instead of the full utm_campaign query — no utm_content - // there either, for the same reason. + // Terminals print the raw URL as literal text inside the ASCII box, so the + // terminal house ad goes through the /h redirector, which re-applies the utm + // params server-side. Spelling them out inline made the URL 59 characters — + // wider than the 40 columns a 44-col box has to spend, so it got pushed + // outside the frame. /h is 24, and still fits once a publisher's &s= + // tag is appended. const clickUrl = format === TERMINAL_FORMAT_ID - ? `${env.siteUrl}/?utm_source=house-ad&utm_medium=motd` + ? `${env.siteUrl}/h` : `${env.siteUrl}/?utm_source=house-ad&utm_medium=ad&utm_campaign=crawlproof-ads&utm_content=${copy.slug}`; const creative = houseCreative(format, copy); return { diff --git a/lib/ads/terminal.ts b/lib/ads/terminal.ts index a9d4db8d..b9ec02d6 100644 --- a/lib/ads/terminal.ts +++ b/lib/ads/terminal.ts @@ -204,14 +204,28 @@ export function renderCreativeText( // "Try it free -> https://..." on one line when it fits, else CTA then URL. const oneLine = `${cta} -> ${url}`; const overflow: string[] = []; + + // The CTA is advertiser copy of any length, so it wraps exactly like the + // headline and body. Emitting it as one unwrapped row let a long CTA run past + // the right edge: `row` clamps its padding at zero, so instead of erroring it + // silently produced a line wider than the frame. Narrow boxes hit this first + // — at 44 cols there are only 40 columns to spend. + const ctaRows = () => { + for (const l of wrapText(`${cta}:`, inner)) { + row(color ? `${accent}${l}${off}` : l, l.length); + } + }; + if (oneLine.length <= inner) { row(color ? `${accent}${cta} ->${off} ${body_}${url}${off}` : oneLine, oneLine.length); } else if (url.length <= inner) { - row(color ? `${accent}${cta}:${off}` : `${cta}:`, cta.length + 1); + ctaRows(); row(color ? `${body_}${url}${off}` : url, url.length); } else { - // Pathologically long URL: keep it whole, outside the frame. - row(color ? `${accent}${cta}:${off}` : `${cta}:`, cta.length + 1); + // Pathologically long URL: keep it whole, outside the frame. It stays one + // logical line so it survives copy-paste; hard-wrapping it would split a + // link the reader can no longer select in one go. + ctaRows(); overflow.push(color ? `${body_}${url}${off}` : url); } blank(); diff --git a/tests/contract/ads-terminal-width.test.ts b/tests/contract/ads-terminal-width.test.ts new file mode 100644 index 00000000..f613b85c --- /dev/null +++ b/tests/contract/ads-terminal-width.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import type { NextRequest } from "next/server"; +import type { AdCreative } from "@/lib/ads/formats"; +import { renderCreativeText, TERMINAL_COLS } from "@/lib/ads/terminal"; +import { houseFill } from "@/lib/ads/house"; +import { TERMINAL_FORMAT_ID } from "@/lib/ads/formats"; +import { GET as houseRedirect } from "@/app/h/route"; + +// A terminal ad is printed into a box the caller sized. Anything wider than +// that box is the renderer failing to honour the width it was given — and the +// narrowest supported box, 44 cols, has only 40 usable columns, so it is where +// every width bug shows up first. + +const MIN_COLS = 44; +const WIDTHS = [44, 52, 60, 72, 120]; + +function creative(over: Partial = {}): AdCreative { + return { + format: "terminal_ascii", + headline: "Ship faster with CrawlProof", + body: "AI-readable audits for your site, in one command.", + ctaText: "Try it free", + bgColor: "#0b0d10", + fgColor: "#e7e9ee", + accentColor: "#6ee7b7", + fontFamily: "system-ui", + logoUrl: null, + imageUrl: null, + ...over, + }; +} + +const SHORT_URL = "https://crawlproof.com/h?s=motd"; + +describe("renderCreativeText call-to-action width", () => { + // Regression: the CTA was the one piece of advertiser copy that was never + // wrapped. `row` clamps its padding at zero, so an over-long CTA didn't + // error — it silently emitted a row wider than the frame. + const LONG_CTA = "Click here to start your completely free thirty day trial today"; + + it("wraps a call-to-action that is wider than the box", () => { + for (const cols of WIDTHS) { + const out = renderCreativeText(creative({ ctaText: LONG_CTA }), SHORT_URL, { cols }); + for (const line of out.split("\n")) { + expect(line.length).toBeLessThanOrEqual(cols); + } + } + }); + + it("keeps the box rectangular with a long call-to-action at the minimum width", () => { + const out = renderCreativeText(creative({ ctaText: LONG_CTA }), SHORT_URL, { cols: MIN_COLS }); + const framed = out.split("\n").filter((l) => l.startsWith("|") || l.startsWith("+")); + for (const line of framed) expect(line).toHaveLength(MIN_COLS); + // Wrapped, not truncated — the words still all survive somewhere. + for (const word of LONG_CTA.split(" ")) expect(out).toContain(word); + }); + + it("hard-splits a single unbroken call-to-action word", () => { + const out = renderCreativeText(creative({ ctaText: "x".repeat(200) }), SHORT_URL, { + cols: MIN_COLS, + }); + for (const line of out.split("\n")) expect(line.length).toBeLessThanOrEqual(MIN_COLS); + }); + + it("does not break the coloured render's visible width", () => { + const out = renderCreativeText(creative({ ctaText: LONG_CTA }), SHORT_URL, { + cols: MIN_COLS, + color: true, + }); + for (const line of out.split("\n")) { + const bare = line.replace(/\x1b\[[0-9;]*m/g, ""); + expect(bare.length).toBeLessThanOrEqual(MIN_COLS); + } + }); +}); + +describe("house ad click URL width", () => { + // The house URL used to spell its utm params out inline, which made it 59 + // characters and forced it outside the frame on any narrow box. It now goes + // through /h and has to stay short enough to sit inside the box, including + // the publisher's ?s= tag. + // Measure the URL the code actually builds, not a stand-in — otherwise a + // regression back to the long inline-utm form would sail past. env.siteUrl is + // localhost under test, so re-host it on the real (longer) production origin + // and add the surface tag /api/ads/motd appends, for the true worst case. + function productionClickUrl(): string { + const fill = houseFill(TERMINAL_FORMAT_ID); + const u = new URL(fill.clickUrl); + const prod = new URL(`https://crawlproof.com${u.pathname}${u.search}`); + prod.searchParams.set("s", "motd"); + return prod.toString(); + } + + it("fits inside the frame at every supported width", () => { + for (const cols of WIDTHS) { + const fill = houseFill(TERMINAL_FORMAT_ID); + const clickUrl = productionClickUrl(); + const text = renderCreativeText(fill.creative, clickUrl, { + cols, + label: "CRAWLPROOF ADS", + }); + const lines = text.split("\n"); + // Nothing dangles below or beside the box. + for (const line of lines) { + expect(line).toHaveLength(cols); + expect(line.startsWith("|") || line.startsWith("+")).toBe(true); + } + expect(text).toContain(clickUrl); + } + }); + + it("points the terminal house ad at the short redirector", () => { + expect(houseFill(TERMINAL_FORMAT_ID).clickUrl).toMatch(/\/h$/); + }); + + it("leaves the non-terminal house ad on the long, descriptive URL", () => { + // No width constraint in an iframe, so keep the richer attribution there. + expect(houseFill("banner_300x250").clickUrl).toContain("utm_campaign=crawlproof-ads"); + }); +}); + +describe("/h house-ad redirector", () => { + function get(url: string) { + return houseRedirect(new Request(url) as NextRequest); + } + + it("redirects to the site with house-ad attribution", () => { + const res = get("https://crawlproof.com/h"); + expect(res.status).toBe(302); + const loc = new URL(res.headers.get("location") ?? ""); + expect(loc.searchParams.get("utm_source")).toBe("house-ad"); + expect(loc.searchParams.get("utm_medium")).toBe("terminal"); + }); + + it("carries the publisher surface tag through as utm_content", () => { + const loc = new URL(get("https://crawlproof.com/h?s=bbs").headers.get("location") ?? ""); + expect(loc.searchParams.get("utm_content")).toBe("bbs"); + }); + + it("sanitises a hostile surface tag", () => { + const loc = new URL( + get("https://crawlproof.com/h?s=%22%3E%3Cscript%3E").headers.get("location") ?? "", + ); + expect(loc.searchParams.get("utm_content") ?? "").toMatch(/^[\w.-]*$/); + }); + + it("omits utm_content when no tag was given", () => { + const loc = new URL(get("https://crawlproof.com/h").headers.get("location") ?? ""); + expect(loc.searchParams.has("utm_content")).toBe(false); + }); +}); + +describe("default width still behaves", () => { + it("keeps the standard creative on one CTA+URL line at the default width", () => { + const out = renderCreativeText(creative(), SHORT_URL, { cols: TERMINAL_COLS }); + expect(out).toContain(`Try it free -> ${SHORT_URL}`); + }); +});