// Rendering primitives for badseo.dev. // // Everything the shared chrome emits (nav, footer, the "what this page tests" // panel, the OpenSEO badge) is deliberately SEO-NEUTRAL: no

and no // . That way each fixture's headings and images are fully under the // fixture's own control, and the audit measures exactly the defect we injected // — not accidental noise from the layout. import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues"; import type { Fixture, IssueId } from "./fixtures/types"; export function escapeHtml(input: string): string { return input .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** Deterministic filler text so pages can clear the thin-content threshold. */ export function lorem(words: number): string { const bank = "the quick brown fox jumps over a lazy search engine while crawling deep into a sprawling website looking for signals headings titles descriptions and links that help people find genuinely useful content on the open web".split( " ", ); const out: string[] = []; for (let i = 0; i < words; i++) out.push(bank[i % bank.length]); return out.join(" "); } interface DocumentOptions { /** Omit entirely to render NO element (tests missing-title). */ title?: string; /** Omit entirely to render NO meta description (tests missing-meta). */ metaDescription?: string; /** <link rel="canonical"> href. */ canonical?: string; /** <meta name="robots"> content. */ robotsMeta?: string; /** Raw HTML injected at the end of <head> (extra tags, JSON-LD, etc.). */ headExtra?: string; lang?: string; bodyHtml: string; } /** Build a complete HTML document string with exact <head> control. */ export function renderDocument(opts: DocumentOptions): string { const head: string[] = ['<meta charset="utf-8">']; head.push( '<meta name="viewport" content="width=device-width, initial-scale=1">', ); if (opts.title !== undefined) head.push(`<title>${escapeHtml(opts.title)}`); if (opts.metaDescription !== undefined) head.push( ``, ); if (opts.canonical) head.push(``); if (opts.robotsMeta) head.push(``); head.push( '', '', '', ); head.push(''); if (opts.headExtra) head.push(opts.headExtra); return ` ${head.join("\n")} ${opts.bodyHtml} `; } function navHtml(): string { return ``; } /** * The sitewide backlink to openseo.so, pinned bottom-right on every page. The * logo is a CSS background-image (not an ) so the shared chrome stays * image-free and never affects the images-missing-alt check. */ function openseoBadge(): string { return ` Maintained by OpenSEO `; } function footerHtml(): string { return ``; } /** Render the expected-issue chips, using the real audit-registry titles. */ function issueChips(issues: IssueId[]): string { if (issues.length === 0) { return `Should pass clean`; } return issues .map((id) => { const d = AUDIT_ISSUE_TYPES[id]; return `${escapeHtml(d.title)}`; }) .join(""); } /** * The on-page "what this page tests" panel. Heading-free and image-free so it * never pollutes the signals under test. Skip it (`showPanel: false`) on the * few pages whose word count is itself the thing being tested. */ function testPanel(fixture: Fixture): string { const lesson = fixture.lesson ? `

${escapeHtml(fixture.lesson)}

` : ""; return ``; } function withChrome(inner: string): string { return `${navHtml()} ${inner} ${footerHtml()} ${openseoBadge()}`; } interface PageOptions extends DocumentOptions { fixture: Fixture; showPanel?: boolean; } /** Compose chrome + optional test panel + fixture body into a full document. */ export function renderPage(opts: PageOptions): string { const { fixture, showPanel = true, ...doc } = opts; const panel = showPanel ? testPanel(fixture) : ""; const inner = `${panel}
${doc.bodyHtml}
`; return renderDocument({ ...doc, bodyHtml: withChrome(inner) }); } /** Chrome-wrapped page with NO test panel — for the home and catalog pages. */ export function renderShell(opts: DocumentOptions): string { const inner = `
${opts.bodyHtml}
`; return renderDocument({ ...opts, bodyHtml: withChrome(inner) }); } interface HtmlResponseOptions { status?: number; headers?: Record; /** Artificial delay before responding — tests slow-response. */ delayMs?: number; } export async function htmlResponse( html: string, opts: HtmlResponseOptions = {}, ): Promise { if (opts.delayMs && opts.delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, opts.delayMs)); } return new Response(html, { status: opts.status ?? 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...opts.headers, }, }); } /** A manual 3xx redirect (crawler records each hop as its own page row). */ export function redirect(location: string, status = 301): Response { return new Response(null, { status, headers: { location, "cache-control": "no-store" }, }); }