← back to Open Seo
badseo/src/lib.ts
206 lines
// Rendering primitives for badseo.dev.
//
// Everything the shared chrome emits (nav, footer, and the "what this page
// tests" panel) is deliberately SEO-NEUTRAL: no <h1>–<h6> and no
// <img>. 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";
import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "./plausible";
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.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 <title> 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[] = [
"<!-- Privacy-friendly analytics by Plausible -->",
`<script async src="${PLAUSIBLE_SCRIPT_SRC}"></script>`,
`<script>${PLAUSIBLE_INIT_SCRIPT}</script>`,
'<script defer src="/analytics.js"></script>',
'<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)}</title>`);
if (opts.metaDescription !== undefined)
head.push(
`<meta name="description" content="${escapeHtml(opts.metaDescription)}">`,
);
if (opts.canonical)
head.push(`<link rel="canonical" href="${escapeHtml(opts.canonical)}">`);
if (opts.robotsMeta)
head.push(`<meta name="robots" content="${escapeHtml(opts.robotsMeta)}">`);
head.push(
'<link rel="preconnect" href="https://fonts.googleapis.com">',
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo+Narrow:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap">',
);
head.push('<link rel="stylesheet" href="/styles.css">');
if (opts.headExtra) head.push(opts.headExtra);
return `<!doctype html>
<html lang="${opts.lang ?? "en"}">
<head>
${head.join("\n")}
</head>
<body>
${opts.bodyHtml}
</body>
</html>`;
}
function navHtml(): string {
return `<nav class="nav">
<a class="brand" href="/">BADSEO</a>
<span class="nav-links"><a href="https://openseo.so">OpenSEO</a><a href="https://github.com/every-app/open-seo">GitHub</a></span>
</nav>`;
}
function footerHtml(): string {
return `<footer class="foot"><div class="foot-inner">
<a class="foot-brand" href="/">BADSEO</a>
<span class="foot-links">
<a href="/#issues">All issues</a>
<a href="https://github.com/every-app/open-seo">GitHub</a>
<a href="https://openseo.so">OpenSEO</a>
<a href="/privacy">Privacy</a>
<button class="footer-button" type="button" data-cookie-settings>Cookie settings</button>
</span>
</div></footer>`;
}
/** Render the expected-issue chips, using the real audit-registry titles. */
function issueChips(issues: IssueId[]): string {
if (issues.length === 0) {
return `<span class="chip chip-clean">Should pass clean</span>`;
}
return issues
.map((id) => {
const d = AUDIT_ISSUE_TYPES[id];
return `<span class="chip chip-${d.severity}" title="${escapeHtml(
d.explanation,
)}">${escapeHtml(d.title)}</span>`;
})
.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
? `<p class="panel-lesson">${escapeHtml(fixture.lesson)}</p>`
: "";
return `<aside class="panel" aria-label="What this page tests">
<div class="panel-head">
<span class="panel-kicker">What this page tests</span>
<span class="panel-cat">${escapeHtml(fixture.category)}</span>
</div>
<p class="panel-summary">${escapeHtml(fixture.summary)}</p>
${lesson}
<div class="panel-chips">
<span class="chips-label">Audit should flag:</span>
${issueChips(fixture.expectedIssues)}
</div>
</aside>`;
}
function withChrome(inner: string): string {
return `${navHtml()}
${inner}
${footerHtml()}`;
}
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}
<main class="main">
${doc.bodyHtml}
</main>`;
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 = `<main class="main">
${opts.bodyHtml}
</main>`;
return renderDocument({ ...opts, bodyHtml: withChrome(inner) });
}
interface HtmlResponseOptions {
status?: number;
headers?: Record<string, string>;
/** Artificial delay before responding — tests slow-response. */
delayMs?: number;
}
export async function htmlResponse(
html: string,
opts: HtmlResponseOptions = {},
): Promise<Response> {
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" },
});
}