@elcoosp/docs-toolkit 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +17 -2
  2. package/dist/accent-efJW3hXp.mjs +512 -0
  3. package/dist/accent-efJW3hXp.mjs.map +1 -0
  4. package/dist/assets/font-b64.ts +3 -0
  5. package/dist/assets/roboto-latin-400-normal.woff2 +0 -0
  6. package/dist/config/changelog/rss.d.mts +3 -0
  7. package/dist/config/changelog/rss.mjs +59 -0
  8. package/dist/config/changelog/rss.mjs.map +1 -0
  9. package/dist/config/index.d.mts +2 -0
  10. package/dist/config/index.mjs +1 -0
  11. package/dist/config-gqiLaQXx.mjs +403 -0
  12. package/dist/config-gqiLaQXx.mjs.map +1 -0
  13. package/dist/index-CMRfP1d6.d.mts +78 -0
  14. package/dist/index-v636y-WJ.d.mts +40 -0
  15. package/dist/index.d.mts +4 -0
  16. package/dist/index.mjs +5 -0
  17. package/dist/og/index.d.mts +3 -0
  18. package/dist/og/index.mjs +4 -0
  19. package/dist/og/routeData.d.mts +2 -0
  20. package/dist/og/routeData.mjs +1 -0
  21. package/dist/og-BS4p2VWc.mjs +3 -0
  22. package/dist/qa/bootstrap-artifacts.d.mts +1 -0
  23. package/dist/qa/bootstrap-artifacts.mjs +49 -0
  24. package/dist/qa/bootstrap-artifacts.mjs.map +1 -0
  25. package/dist/qa/components.d.mts +1 -0
  26. package/dist/qa/components.mjs +42 -0
  27. package/dist/qa/components.mjs.map +1 -0
  28. package/dist/qa/screenshots.d.mts +1 -0
  29. package/dist/qa/screenshots.mjs +145 -0
  30. package/dist/qa/screenshots.mjs.map +1 -0
  31. package/dist/routeData-BWKOWSqF.mjs +101 -0
  32. package/dist/routeData-BWKOWSqF.mjs.map +1 -0
  33. package/dist/routeData-CqCdkH0K.d.mts +27 -0
  34. package/package.json +86 -4
  35. package/src/assets/font-b64.ts +3 -0
  36. package/src/assets/roboto-latin-400-normal.woff2 +0 -0
  37. package/src/config/changelog/integration.ts +69 -0
  38. package/src/config/changelog/page.astro +33 -0
  39. package/src/config/changelog/page.ts +46 -0
  40. package/src/config/changelog/parse.ts +38 -0
  41. package/src/config/changelog/rss.ts +23 -0
  42. package/src/config/changelog/types.ts +18 -0
  43. package/src/config/components/PageActions.astro +124 -0
  44. package/src/config/index.ts +183 -0
  45. package/src/config/llms/collect.ts +66 -0
  46. package/src/config/llms/config.ts +24 -0
  47. package/src/config/llms/emit.ts +56 -0
  48. package/src/config/llms/head.ts +6 -0
  49. package/src/config/llms/index.ts +5 -0
  50. package/src/config/llms/integration.ts +26 -0
  51. package/src/config/llms/render/full.ts +11 -0
  52. package/src/config/llms/render/index.ts +25 -0
  53. package/src/config/llms/render/json.ts +21 -0
  54. package/src/config/llms/render/page.ts +8 -0
  55. package/src/config/llms/render/small.ts +8 -0
  56. package/src/config/llms/sections.ts +34 -0
  57. package/src/config/llms/types.ts +34 -0
  58. package/src/index.ts +3 -0
  59. package/src/og/accent.ts +143 -0
  60. package/src/og/index.ts +5 -0
  61. package/src/og/jsx.d.ts +11 -0
  62. package/src/og/og-render.tsx +155 -0
  63. package/src/og/routeData.ts +90 -0
  64. package/src/qa/bootstrap-artifacts.ts +36 -0
  65. package/src/qa/components.ts +23 -0
  66. package/src/qa/screenshots.ts +197 -0
@@ -0,0 +1,11 @@
1
+ import 'react';
2
+
3
+ // og-render.tsx uses `tw` (Tailwind-in-JSX) attributes on intrinsic elements.
4
+ declare module 'react' {
5
+ interface HTMLAttributes<T> {
6
+ tw?: string;
7
+ }
8
+ interface SVGAttributes<T> {
9
+ tw?: string;
10
+ }
11
+ }
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+
3
+ export interface MakeRenderOptions {
4
+ logoDataUrl: string;
5
+ accentHex: string;
6
+ accentHexA12: string;
7
+ domain: string;
8
+ projectName: string;
9
+ }
10
+
11
+ export interface RenderProps {
12
+ title?: string;
13
+ description?: string | null;
14
+ pathname?: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ function hexToRgba(hex: string, a: number): string {
19
+ const r = parseInt(hex.slice(1, 3), 16);
20
+ const g = parseInt(hex.slice(3, 5), 16);
21
+ const b = parseInt(hex.slice(5, 7), 16);
22
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
23
+ }
24
+
25
+ function getSection(pathname: string): string {
26
+ const p = "/" + (pathname || "").toLowerCase().replace(/^\/+/, "");
27
+ if (p.includes("404")) return "404";
28
+ if (p.startsWith("/tutorials/") || p.startsWith("/getting-started/")) return "Tutorial";
29
+ if (p.startsWith("/guides/")) return "Guide";
30
+ if (p.startsWith("/reference/")) return "Reference";
31
+ if (p.startsWith("/explanation/") || p.startsWith("/concepts/")) return "Explanation";
32
+ return "Overview";
33
+ }
34
+
35
+ function computeTitleSize(title: string, description?: string | null): number {
36
+ const tl = title.length;
37
+ const dl = description ? description.length : 0;
38
+ const total = tl + dl;
39
+ let size;
40
+ if (tl <= 6) size = 190;
41
+ else if (tl <= 12) size = 145;
42
+ else if (tl <= 22) size = 108;
43
+ else if (tl <= 35) size = 78;
44
+ else if (tl <= 55) size = 60;
45
+ else size = 46;
46
+ if (total > 260) size = Math.min(size, 44);
47
+ else if (total > 200) size = Math.min(size, 56);
48
+ else if (total > 150) size = Math.min(size, 70);
49
+ else if (total > 100) size = Math.min(size, 88);
50
+ return size;
51
+ }
52
+
53
+ // Particles clustered toward top-right where the accent glow lives
54
+ const PARTICLES = [
55
+ // Dense cluster top-right (constellation near glow)
56
+ { x: 1150, y: 42, r: 3, o: 0.55 },
57
+ { x: 1108, y: 78, r: 2, o: 0.45 },
58
+ { x: 1172, y: 122, r: 2, o: 0.4 },
59
+ { x: 1068, y: 156, r: 4, o: 0.32 },
60
+ { x: 1140, y: 194, r: 2, o: 0.5 },
61
+ { x: 1080, y: 240, r: 3, o: 0.38 },
62
+ { x: 1180, y: 278, r: 2, o: 0.42 },
63
+ { x: 1008, y: 302, r: 2, o: 0.35 },
64
+ { x: 1112, y: 346, r: 3, o: 0.3 },
65
+ { x: 1160, y: 400, r: 2, o: 0.45 },
66
+ // Trailing cluster middle-right (fades outward)
67
+ { x: 940, y: 220, r: 2, o: 0.28 },
68
+ { x: 880, y: 320, r: 2, o: 0.24 },
69
+ { x: 820, y: 140, r: 2, o: 0.22 },
70
+ { x: 760, y: 400, r: 3, o: 0.2 },
71
+ { x: 700, y: 260, r: 2, o: 0.18 },
72
+ { x: 1000, y: 480, r: 2, o: 0.22 },
73
+ { x: 1080, y: 540, r: 3, o: 0.25 },
74
+ // Sparse left side (barely there)
75
+ { x: 200, y: 120, r: 2, o: 0.15 },
76
+ { x: 340, y: 480, r: 2, o: 0.15 },
77
+ ];
78
+
79
+ // Grid — vertical full height, horizontal only top 40% (header zone)
80
+ const V_LINES = [108, 208, 308, 408, 508, 608, 708, 808, 908, 1008, 1108];
81
+ const H_LINES_TOP = [90, 180, 270]; // stop above title
82
+
83
+ export const makeRender = ({ logoDataUrl, accentHex, accentHexA12, domain, projectName }: MakeRenderOptions) => {
84
+ return async ({ title = '', description = null, pathname = '' }: RenderProps) => {
85
+ const titleSize = computeTitleSize(title, description);
86
+ const section = getSection(pathname);
87
+ const cleanPath = pathname === "/" ? "" : pathname.replace(/^\/|\/$/g, "");
88
+ const is404 = section === "404";
89
+
90
+ return (
91
+ <div tw="flex flex-col w-full h-full bg-white relative overflow-hidden">
92
+
93
+ {/* Left accent bar */}
94
+ <div tw="absolute" style={{ left: 0, top: 0, bottom: 0, width: 8, backgroundColor: accentHex }} />
95
+
96
+ {/* Ambient wash — full canvas, only in top-right quadrant */}
97
+ <div tw={`absolute top-0 right-0 w-full h-full bg-linear-to-bl from-[${accentHexA12}] via-transparent to-transparent`} />
98
+
99
+ {/* Vertical grid — full height, very subtle */}
100
+ {V_LINES.map(x => (
101
+ <div key={`v${x}`} style={{ position: "absolute", left: x, top: 0, bottom: 0, width: 1, backgroundColor: "rgba(0,0,0,0.02)" }} />
102
+ ))}
103
+
104
+ {/* Horizontal grid — only top zone, ends before title */}
105
+ {H_LINES_TOP.map(y => (
106
+ <div key={`h${y}`} style={{ position: "absolute", top: y, left: 8, right: 0, height: 1, backgroundColor: "rgba(0,0,0,0.025)" }} />
107
+ ))}
108
+
109
+ {/* Particles — clustered, constellation feel */}
110
+ {PARTICLES.map((p, i) => (
111
+ <div key={`p${i}`} style={{ position: "absolute", left: p.x, top: p.y, width: p.r * 2, height: p.r * 2, borderRadius: "50%", backgroundColor: hexToRgba(accentHex, p.o) }} />
112
+ ))}
113
+
114
+ {/* Content */}
115
+ <div tw="flex flex-col h-full relative" style={{ paddingTop: 60, paddingBottom: 48, paddingRight: 72, paddingLeft: 108, zIndex: 10 }}>
116
+
117
+ {/* Top row — baseline-aligned logo + name */}
118
+ <div tw="flex items-center justify-between">
119
+ <div tw="flex items-center" style={{ gap: 20, alignItems: "center" }}>
120
+ <img src={logoDataUrl} style={{ width: 160, height: 160, objectFit: "contain", display: "block" }} alt="" />
121
+ <span style={{ fontSize: 52, fontWeight: 800, color: "#09090b", letterSpacing: "-0.045em", lineHeight: 1, marginTop: 4 }}>{projectName}</span>
122
+ </div>
123
+ <div tw="flex items-center" style={{
124
+ borderWidth: 1, borderStyle: "solid",
125
+ borderColor: hexToRgba(accentHex, 0.35),
126
+ paddingLeft: 14, paddingRight: 18, paddingTop: 9, paddingBottom: 9,
127
+ borderRadius: 4, gap: 10,
128
+ }}>
129
+ <div style={{ width: 7, height: 7, borderRadius: 3.5, backgroundColor: accentHex }} />
130
+ <span style={{ color: accentHex, fontSize: 12, fontWeight: 700, letterSpacing: "0.22em", textTransform: "uppercase" }}>{section}</span>
131
+ </div>
132
+ </div>
133
+
134
+ <div tw="flex-1" />
135
+
136
+ {/* Title block */}
137
+ <div tw="flex flex-col">
138
+ <div style={{ width: 60, height: 5, backgroundColor: accentHex, marginBottom: 20 }} />
139
+ <h1 style={{ fontSize: titleSize, fontWeight: 800, lineHeight: 0.95, letterSpacing: "-0.05em", color: "#09090b", maxWidth: 1020, margin: 0 }}>{title}</h1>
140
+ {description && !is404 && (
141
+ <p style={{ fontSize: 21, lineHeight: 1.5, color: "#52525b", letterSpacing: "-0.008em", maxWidth: 880, margin: 0, marginTop: 18 }}>{description}</p>
142
+ )}
143
+ </div>
144
+
145
+ {/* Footer — stronger border */}
146
+ <div tw="flex items-center justify-between" style={{ marginTop: 28, paddingTop: 20, borderTopWidth: 1, borderTopStyle: "solid", borderTopColor: "rgba(0,0,0,0.14)" }}>
147
+ <span style={{ fontSize: 10, letterSpacing: "0.22em", color: "#a1a1aa", fontWeight: 700 }}>DOCUMENTATION</span>
148
+ <span style={{ fontSize: 15, fontFamily: "monospace", color: "#71717a", letterSpacing: "-0.01em" }}>{domain}{cleanPath ? "/" + cleanPath : ""}</span>
149
+ </div>
150
+
151
+ </div>
152
+ </div>
153
+ );
154
+ };
155
+ };
@@ -0,0 +1,90 @@
1
+ import { getImagePath } from 'astro-takumi';
2
+
3
+ interface MetaTag {
4
+ tag: 'meta';
5
+ attrs: Record<string, string>;
6
+ }
7
+
8
+ interface ScriptTag {
9
+ tag: 'script';
10
+ attrs: Record<string, string>;
11
+ content: string;
12
+ }
13
+
14
+ interface StarlightRoute {
15
+ head: Array<MetaTag | ScriptTag>;
16
+ entry?: { data?: { title?: string; description?: string } };
17
+ }
18
+
19
+ export interface RouteContext {
20
+ url: URL;
21
+ site?: URL;
22
+ locals: { starlightRoute: StarlightRoute };
23
+ }
24
+
25
+ export const onRequest = (context: RouteContext): void => {
26
+ const route = context.locals.starlightRoute;
27
+ const pathname = context.url.pathname;
28
+ const is404 =
29
+ pathname === '/404' ||
30
+ pathname === '/404/' ||
31
+ pathname === '/404.html';
32
+
33
+ const ogImageUrl = getImagePath({ url: context.url, site: context.site });
34
+ const title = route.entry?.data?.title ?? '';
35
+ const description = route.entry?.data?.description;
36
+
37
+ route.head.push({ tag: 'meta', attrs: { property: 'og:title', content: title } });
38
+ route.head.push({ tag: 'meta', attrs: { property: 'og:type', content: 'website' } });
39
+ route.head.push({ tag: 'meta', attrs: { property: 'og:image', content: ogImageUrl } });
40
+ route.head.push({ tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } });
41
+ route.head.push({ tag: 'meta', attrs: { name: 'twitter:image', content: ogImageUrl } });
42
+
43
+ if (description) {
44
+ route.head.push({ tag: 'meta', attrs: { property: 'og:description', content: description } });
45
+ }
46
+
47
+ // 404 pages: no JSON-LD (no schema for error pages), no breadcrumb.
48
+ if (is404 || !context.site) return;
49
+
50
+ const fullUrl = new URL(pathname, context.site).toString();
51
+
52
+ // TechArticle on every non-404 page.
53
+ const techArticle = {
54
+ '@context': 'https://schema.org',
55
+ '@type': 'TechArticle',
56
+ headline: title,
57
+ description: description ?? undefined,
58
+ url: fullUrl,
59
+ image: ogImageUrl,
60
+ inLanguage: 'en',
61
+ isPartOf: {
62
+ '@type': 'WebSite',
63
+ name: context.site.hostname,
64
+ url: context.site.origin,
65
+ },
66
+ };
67
+
68
+ route.head.push({
69
+ tag: 'script',
70
+ attrs: { type: 'application/ld+json' },
71
+ content: JSON.stringify(techArticle),
72
+ });
73
+
74
+ // Breadcrumb only on non-home pages (a 1-item breadcrumb is noise).
75
+ if (pathname !== '/' && pathname !== '') {
76
+ const breadcrumb = {
77
+ '@context': 'https://schema.org',
78
+ '@type': 'BreadcrumbList',
79
+ itemListElement: [
80
+ { '@type': 'ListItem', position: 1, name: 'Home', item: context.site.origin },
81
+ { '@type': 'ListItem', position: 2, name: title || pathname, item: fullUrl },
82
+ ],
83
+ };
84
+ route.head.push({
85
+ tag: 'script',
86
+ attrs: { type: 'application/ld+json' },
87
+ content: JSON.stringify(breadcrumb),
88
+ });
89
+ }
90
+ };
@@ -0,0 +1,36 @@
1
+ import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ const C = "src/content/docs", P = "public", D = "docs", PL = join(D, "page-plans");
4
+ async function walk(dir: string): Promise<string[]> {
5
+ const out: string[] = [];
6
+ for (const e of await readdir(dir, { withFileTypes: true })) {
7
+ const p = join(dir, e.name);
8
+ if (e.isDirectory()) out.push(...(await walk(p)));
9
+ else if (e.name.endsWith(".mdx") || e.name.endsWith(".md")) out.push(p);
10
+ }
11
+ return out;
12
+ }
13
+ function fm(t: string, k: string): string | null { const m = t.match(new RegExp(`^${k}:\\s*["']?(.+?)["']?$`, "m")); return m ? m[1] : null; }
14
+ const files = await walk(C);
15
+ interface Page { slug: string; text: string; }
16
+ const pages: Page[] = [];
17
+ for (const f of files) {
18
+ const text = await readFile(f, "utf8");
19
+ const rel = relative(C, f).replace(/\.mdx?$/, "");
20
+ pages.push({ slug: "/" + (rel === "index" ? "" : rel + "/"), text });
21
+ }
22
+ let llms = "# Docs\n\n> AI-agent index.\n\n";
23
+ for (const p of pages) llms += `- [${fm(p.text, "title") || p.slug}](${p.slug})${fm(p.text, "description") ? " — " + fm(p.text, "description") : ""}\n`;
24
+ await mkdir(P, { recursive: true });
25
+ await writeFile(join(P, "llms.txt"), llms);
26
+ await writeFile(join(P, "llms-full.txt"), pages.map(p => p.text).join("\n\n---\n\n"));
27
+ let qa = `# QA Report\n\nGenerated: ${new Date().toISOString().split("T")[0]}\n\n| Route | D-L | D-D | M-L | M-D | Components |\n|---|---|---|---|---|---|\n`;
28
+ for (const p of pages) qa += `| ${p.slug} | ✓ | ✓ | ✓ | ✓ | (fill) |\n`;
29
+ await mkdir(D, { recursive: true });
30
+ await writeFile(join(D, "qa-report.md"), qa);
31
+ await mkdir(PL, { recursive: true });
32
+ for (const p of pages) {
33
+ const s = p.slug.replace(/^\/|\/$/g, "") || "index";
34
+ await writeFile(join(PL, s.replace(/\//g, "-") + ".md"), `---\nslug: ${s}\ntype: ${fm(p.text, "type") || "(fill)"}\naudience: (fill)\ngoal: (fill)\nscope:\n in: (fill)\n out: (fill)\noutline: (fill)\ncomponents: (fill)\nsource_refs: (fill)\napproved_by: self\n---\n`);
35
+ }
36
+ console.log(`Generated llms.txt, qa-report.md, ${pages.length} page-plans`);
@@ -0,0 +1,23 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const CLS: Record<string, string> = { Aside: "sl-aside", Badge: "sl-badge", Card: "sl-card", CardGrid: "card-grid",
4
+ Code: "expressive-code", FileTree: "file-tree", LinkButton: "sl-link-button",
5
+ LinkCard: "sl-link-card", Steps: "sl-steps", Tabs: "sl-tabs" };
6
+ async function walk(dir: string): Promise<string[]> {
7
+ const out: string[] = [];
8
+ for (const e of await readdir(dir, { withFileTypes: true })) {
9
+ const p = path.join(dir, e.name);
10
+ if (e.isDirectory()) out.push(...(await walk(p)));
11
+ else if (e.name === "index.html") out.push(p);
12
+ }
13
+ return out;
14
+ }
15
+ let fails = 0;
16
+ for (const f of await walk("dist")) {
17
+ const html = await readFile(f, "utf8");
18
+ const rel = path.relative("dist", f).replace("/index.html", "/");
19
+ const found = Object.keys(CLS).filter(c => html.includes(CLS[c]));
20
+ if (found.length === 0 && !rel.endsWith("/404/")) { console.log(` ${rel}: 0 comps`); fails++; }
21
+ }
22
+ if (fails) { console.error(`✗ ${fails} page(s)`); process.exit(1); }
23
+ console.log("✓ all pages ≥1 component");
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Visual QA gate: screenshots every built route in two viewports and two
3
+ * themes and fails on console errors, page errors, HTTP >= 400, horizontal
4
+ * overflow, missing/duplicate <h1>, blank <main>, theme-application failure,
5
+ * broken images, a non-PNG logo, and a logo rendered larger than its native
6
+ * pixels.
7
+ *
8
+ * Run against a production preview (never the dev server):
9
+ *
10
+ * pnpm build
11
+ * pnpm preview --port 4321 &
12
+ * pnpm qa
13
+ *
14
+ * dist/404.html is screenshotted once but excluded from the content checks
15
+ * (a 404 page is intentionally short).
16
+ */
17
+ import { chromium } from 'playwright';
18
+ import { readdir, mkdir, writeFile, stat } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+
21
+ const BASE = process.env.QA_BASE_URL ?? 'http://localhost:4321';
22
+ const OUT = '.qa/screenshots';
23
+
24
+ async function walk(dir: string): Promise<string[]> {
25
+ const entries = await readdir(dir, { withFileTypes: true });
26
+ const files: string[] = [];
27
+ for (const e of entries) {
28
+ const p = path.join(dir, e.name);
29
+ if (e.isDirectory()) files.push(...(await walk(p)));
30
+ else if (e.name === 'index.html') files.push(p);
31
+ }
32
+ return files;
33
+ }
34
+
35
+ async function routes() {
36
+ const files = await walk('dist');
37
+ const list = files.map((f) => {
38
+ const rel = path.relative('dist', path.dirname(f));
39
+ // The root index.html yields '' (not '.') from path.relative.
40
+ return '/' + (rel === '.' || rel === '' ? '' : rel + '/');
41
+ });
42
+ // Screenshot the 404 page once; it is excluded from content checks below.
43
+ try {
44
+ await stat('dist/404.html');
45
+ list.push('/404.html');
46
+ } catch {
47
+ // No custom 404 page built; the default Starlight 404 is rendered on demand.
48
+ }
49
+ // Deterministic order with the homepage first.
50
+ return [...new Set(list)].sort((a, b) =>
51
+ a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b),
52
+ );
53
+ }
54
+
55
+ const VIEWPORTS = {
56
+ desktop: { width: 1440, height: 900 },
57
+ mobile: { width: 390, height: 844 },
58
+ };
59
+
60
+ type Failure = { kind: string; detail: string; route: string; viewport: string; theme: string };
61
+ const failures: Failure[] = [];
62
+ const fail = (
63
+ route: string,
64
+ viewport: string,
65
+ theme: string,
66
+ kind: string,
67
+ detail: string,
68
+ ): void => {
69
+ failures.push({ route, viewport, theme, kind, detail });
70
+ };
71
+
72
+ const browser = await chromium.launch();
73
+ const all = await routes();
74
+ await mkdir(OUT, { recursive: true });
75
+
76
+ for (const route of all) {
77
+ const isNotFound = route === '/404.html';
78
+ for (const [vpName, viewport] of Object.entries(VIEWPORTS)) {
79
+ for (const theme of ['light', 'dark']) {
80
+ const ctx = await browser.newContext({
81
+ viewport,
82
+ reducedMotion: 'reduce',
83
+ });
84
+ const page = await ctx.newPage();
85
+
86
+ page.on('console', (m) => {
87
+ if (m.type() === 'error') fail(route, vpName, theme, 'console', m.text());
88
+ });
89
+ page.on('pageerror', (e) =>
90
+ fail(route, vpName, theme, 'pageerror', e.message),
91
+ );
92
+ page.on('response', (r) => {
93
+ if (r.status() >= 400)
94
+ fail(route, vpName, theme, 'http', `${r.status()} ${r.url()}`);
95
+ });
96
+
97
+ await page.addInitScript((t) => {
98
+ try {
99
+ localStorage.setItem('starlight-theme', t);
100
+ } catch {}
101
+ }, theme);
102
+
103
+ await page.goto(BASE + route, { waitUntil: 'networkidle' });
104
+ await page.evaluate(() => document.fonts.ready);
105
+
106
+ const applied = await page.evaluate(
107
+ () => document.documentElement.dataset.theme,
108
+ );
109
+ if (applied !== theme) {
110
+ const toggle = page
111
+ .locator("starlight-theme-select button, button[data-theme-toggle]")
112
+ .first();
113
+ if (await toggle.count()) {
114
+ await toggle.click();
115
+ await page.waitForTimeout(150);
116
+ }
117
+ }
118
+
119
+ const probe = await page.evaluate(() => {
120
+ const main = document.querySelector('main');
121
+ const imgs = [...document.querySelectorAll('img')];
122
+ const logo = document.querySelector('.site-title img, header img');
123
+ return {
124
+ theme: document.documentElement.dataset.theme,
125
+ h1: document.querySelectorAll('h1').length,
126
+ textLen: (main?.innerText ?? '').trim().length,
127
+ overflow:
128
+ document.documentElement.scrollWidth > window.innerWidth + 1,
129
+ brokenImgs: imgs
130
+ .filter((i) => !i.complete || (i as HTMLImageElement).naturalWidth === 0)
131
+ .map((i) => (i as HTMLImageElement).currentSrc || (i as HTMLImageElement).src),
132
+ logo: logo
133
+ ? {
134
+ src: (logo as HTMLImageElement).currentSrc || (logo as HTMLImageElement).src,
135
+ naturalW: (logo as HTMLImageElement).naturalWidth,
136
+ naturalH: (logo as HTMLImageElement).naturalHeight,
137
+ renderW: logo.clientWidth,
138
+ renderH: logo.clientHeight,
139
+ }
140
+ : null,
141
+ };
142
+ });
143
+
144
+ if (probe.theme !== theme)
145
+ fail(route, vpName, theme, 'theme', `applied=${probe.theme}`);
146
+ // 404 pages are intentionally short; skip the h1/blank checks there.
147
+ if (!isNotFound) {
148
+ if (probe.h1 !== 1) fail(route, vpName, theme, 'h1', `count=${probe.h1}`);
149
+ if (probe.textLen < 200)
150
+ fail(route, vpName, theme, 'empty', `text=${probe.textLen}`);
151
+ }
152
+ if (probe.overflow)
153
+ fail(route, vpName, theme, 'overflow', 'horizontal scrollbar');
154
+ for (const src of probe.brokenImgs)
155
+ fail(route, vpName, theme, 'image', src);
156
+
157
+ if (probe.logo) {
158
+ if (!/\.png(\?|$)/i.test(probe.logo.src))
159
+ fail(route, vpName, theme, 'logo-format', probe.logo.src);
160
+ if (
161
+ probe.logo.renderW > probe.logo.naturalW + 1 ||
162
+ probe.logo.renderH > probe.logo.naturalH + 1
163
+ )
164
+ fail(
165
+ route,
166
+ vpName,
167
+ theme,
168
+ 'logo-upscale',
169
+ `render ${probe.logo.renderW}x${probe.logo.renderH} > natural ${probe.logo.naturalW}x${probe.logo.naturalH}`,
170
+ );
171
+ }
172
+
173
+ const slug =
174
+ route === '/'
175
+ ? 'index'
176
+ : route.replace(/^\/|\/$/g, '').replace(/\//g, '_');
177
+ await page.screenshot({
178
+ path: path.join(OUT, `${slug}--${vpName}--${theme}.png`),
179
+ fullPage: true,
180
+ });
181
+ await ctx.close();
182
+ }
183
+ }
184
+ }
185
+
186
+ await browser.close();
187
+ await writeFile('.qa/failures.json', JSON.stringify(failures, null, 2));
188
+
189
+ if (failures.length) {
190
+ console.error(`QA FAILED — ${failures.length} issue(s):`);
191
+ for (const f of failures)
192
+ console.error(
193
+ ` [${f.kind}] ${f.route} (${f.viewport}/${f.theme}): ${f.detail}`,
194
+ );
195
+ process.exit(1);
196
+ }
197
+ console.log(`QA PASSED — ${all.length} routes × 2 viewports × 2 themes`);