From 28b5e991421d3f9cd180e7382abff225625a76fd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 12:31:11 +0000 Subject: [PATCH] fix(ads): keep terminal ads inside the box at narrow widths Two separate ways a terminal ad could print outside the frame it was asked to fit in. Both bite hardest at the minimum width, 44 cols, which leaves only 40 usable columns. 1. The call-to-action was the one piece of advertiser copy that was never wrapped. Headline and body both go through wrapText; the CTA row was emitted whole. `row` clamps its padding at Math.max(0, ...), so an over-long CTA didn't error -- it silently produced a row wider than the box. A 63-character CTA at cols=44 rendered a 68-character row, i.e. advertiser-controlled copy could break the frame. It now wraps (and hard-splits) exactly like the headline and body. 2. The house ad's click URL spelled its utm params out inline: https://crawlproof.com/?utm_source=house-ad&utm_medium=motd That is 59 characters, or 66 once a publisher's &s= tag is appended -- far past the 40 columns a 44-col box has, so the renderer pushed it below the frame on every narrow fill. Since house ads are currently 100% of terminal inventory, that was every fill. Added /h, a house-ad click redirector mirroring the existing /a/, which re-applies the utm params server-side where characters are free. The printed URL is now 24 characters, or 31 with a surface tag, and sits inside the box at every supported width. Attribution is unchanged apart from utm_medium moving from "motd" to "terminal", with the surface itself recorded as utm_content -- the same URL is printed into BBS screens and SSH banners, not just MOTDs. Not fixed, and left deliberately: a *paid* click URL is /a/ = 61 characters (68 with a surface tag), which cannot fit 40 columns on this domain. Those still fall outside the frame, as designed and as covered by the existing "moves an over-wide URL below the box" test. Making them fit needs short impression codes -- a schema change on the click-metering path, which does not belong in a layout fix. Co-Authored-By: Claude Opus 5 (1M context) --- app/h/route.ts | 49 +++++++ lib/ads/house.ts | 11 +- lib/ads/terminal.ts | 20 ++- tests/contract/ads-terminal-width.test.ts | 158 ++++++++++++++++++++++ 4 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 app/h/route.ts create mode 100644 tests/contract/ads-terminal-width.test.ts 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}`); + }); +});