// 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;
/** href. */
canonical?: string;
/** content. */
robotsMeta?: string;
/** Raw HTML injected at the end of (extra tags, JSON-LD, etc.). */
headExtra?: string;
lang?: string;
bodyHtml: string;
}
/** Build a complete HTML document string with exact control. */
export function renderDocument(opts: DocumentOptions): string {
const head: string[] = [''];
head.push(
'',
);
if (opts.title !== undefined)
head.push(`${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" },
});
}