Ben Senescu d6d0f210ba Redesign the BadSEO home and landing lab experience (#381)
* Redesign BadSEO homepage and add design lab routes

* Migrate badseo.dev to TanStack Start

* Refine BadSEO landing page design

* Migrate badseo.dev to TanStack Start

* Refine BadSEO field guide homepage

* Simplify BadSEO example catalog

* Remove BadSEO issue section label

* Remove All issues from BadSEO header

* Reorder BadSEO examples and simplify footer

* Reduce BadSEO desktop hero type

* Narrow BadSEO homepage content

* Further reduce BadSEO hero type

* Add BadSEO brand to footer

* Tighten Kitchen sink description

* Reorder BadSEO example categories
2026-07-15 12:39:31 -04:00

206 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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" },
});
}