Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/h/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Short house-ad click redirector: https://crawlproof.com/h
//
// The terminal twin of /a/<impression_id>, 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=<surface> 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=<surface> 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 });
}
}
11 changes: 7 additions & 4 deletions lib/ads/house.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<surface>
// 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 {
Expand Down
20 changes: 17 additions & 3 deletions lib/ads/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
158 changes: 158 additions & 0 deletions tests/contract/ads-terminal-width.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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=<surface> 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}`);
});
});
Loading