carlos / carloku Public

Clone
git clone https://amadan.net/carlos/carloku

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– Fails the build when the built docs site is internally broken.
2//
3// The platform repo gates the corpus as markdown: nav and files agree,
4// links resolve, anchors exist, symbols are covered. This checks what
5// that cannot β€” that the rendering actually produced the pages and ids
6// those links point at. A link can be correct in the source and still
7// 404 in a browser if the renderer disagrees with the gate about how a
8// heading becomes an anchor, which is exactly the failure worth
9// catching here.
10import { readdirSync, readFileSync, statSync } from "node:fs";
11import { join, relative } from "node:path";
12
13const site = "_site";
14const docs = join(site, "docs");
15const nav = JSON.parse(readFileSync("src/_data/docsnav.json", "utf8"));
16
17const problems = [];
18const fail = (m) => problems.push(m);
19
20function walk(dir, match) {
21 const out = [];
22 for (const entry of readdirSync(dir, { withFileTypes: true })) {
23 const p = join(dir, entry.name);
24 if (entry.isDirectory()) out.push(...walk(p, match));
25 else if (match(entry.name)) out.push(p);
26 }
27 return out;
28}
29
30const exists = (p) => {
31 try {
32 statSync(p);
33 return true;
34 } catch {
35 return false;
36 }
37};
38
39// 1. The site as a whole was actually built. Everything below this check
40// is scoped to _site/docs β€” deliberately, that's the docs-specific
41// gate this file owns β€” but nothing else in the pipeline verifies
42// that the hand-written pages and shared assets were emitted at all.
43// Ported from the website's check-docs.mjs, which paid for this the
44// hard way: a passthrough file list going stale there dropped a live
45// page and shipped a 404ing stylesheet, both silently, because
46// npm run check only ever looked under _site/docs and exited 0
47// either time. A build missing its homepage should fail before
48// anyone reads about anchors, so this runs first.
49// When one of these goes missing, it is almost always because an
50// addPassthroughCopy line in eleventy.config.js was removed or
51// renamed β€” check there first.
52const requiredSitePaths = [
53 "index.html",
54 "get-started/index.html",
55 "site.css",
56 "site.js",
57 "favicon.svg",
58 "docs.css",
59 "fonts/Geist-Variable.woff2",
60 "fonts/GeistMono-Variable.woff2",
61 "img/dashboard-light.png",
62 "img/dashboard-dark.png",
63];
64for (const rel of requiredSitePaths) {
65 if (!exists(join(site, rel))) {
66 fail(`_site/${rel} was not built β€” check the addPassthroughCopy lines in eleventy.config.js`);
67 }
68}
69
70// 2. Every nav entry has a built page and a built .md twin.
71//
72// docsnav.json is hand-maintained here β€” there is no generator to blame
73// or re-run, so every failure below is an edit someone made. The floor
74// after the loop is the point of the whole gate: a loop over an empty nav
75// asserts nothing, so a nav trimmed down to {"sections": []} would print
76// "docs ok β€” 0 pages" and exit 0 on a corpus that had vanished. Same shape
77// as the pages.length guard below.
78const slugs = [];
79const sections = Array.isArray(nav.sections) ? nav.sections : [];
80if (!Array.isArray(nav.sections)) fail("docsnav.json has no sections array at all");
81for (const section of sections) {
82 if (!Array.isArray(section.pages)) {
83 fail(`docsnav.json section ${JSON.stringify(section.title ?? "?")} has no "pages" key β€” every section in src/_data/docsnav.json needs one, listing that section's pages`);
84 continue;
85 }
86 for (const entry of section.pages) {
87 slugs.push(entry.slug);
88 if (!exists(join(docs, entry.slug, "index.html"))) fail(`no built page for /docs/${entry.slug}/`);
89 if (!exists(join(docs, `${entry.slug}.md`))) fail(`no markdown twin for /docs/${entry.slug}.md`);
90 if (!entry.blurb) fail(`nav entry ${entry.slug} has no blurb`);
91 if (!entry.label) fail(`nav entry ${entry.slug} has no label`);
92 }
93}
94if (slugs.length === 0) {
95 fail("src/_data/docsnav.json lists no pages β€” every page in src/docs/ needs an entry there; nothing in check 2 can fail against an empty nav");
96}
97if (!exists(join(docs, "index.html"))) fail("no built /docs/ index");
98
99// 3. Every internal href resolves β€” to a built page, and to a real id
100// when it carries a fragment.
101const pages = walk(docs, (n) => n.endsWith(".html"));
102if (pages.length === 0) fail("no built docs pages at all");
103
104const idsFor = new Map();
105for (const p of pages) {
106 const html = readFileSync(p, "utf8");
107 idsFor.set(p, new Set([...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])));
108}
109
110// Docs links are "/docs", "/docs/", "/docs/<slug>/" or "/docs/<slug>.md".
111// Anchored to a slash (or end) on purpose: this site links a stylesheet
112// as /docs.css, and a bare /docs prefix match claimed that as a page and
113// failed the build on it.
114const docsHref = /href="(\/docs(?:\/[^"]*)?)"/g;
115
116const pageFor = (href) => {
117 const clean = href.replace(/[?#].*$/, "");
118 if (clean !== "/docs" && !clean.startsWith("/docs/")) return null;
119 const rel = clean.replace(/^\/docs\/?/, "").replace(/\/$/, "");
120 if (rel === "") return join(docs, "index.html");
121 if (rel.endsWith(".md")) return join(docs, rel);
122 return join(docs, rel, "index.html");
123};
124
125for (const p of pages) {
126 const html = readFileSync(p, "utf8");
127 const where = relative(site, p);
128 for (const m of html.matchAll(docsHref)) {
129 const href = m[1];
130 const target = pageFor(href);
131 if (!target || !exists(target)) {
132 fail(`${where}: href="${href}" does not resolve to a built file`);
133 continue;
134 }
135 const hash = href.includes("#") ? href.split("#")[1] : "";
136 if (hash && !(idsFor.get(target) ?? new Set()).has(hash)) {
137 fail(`${where}: href="${href}" β€” ${relative(site, target)} has no id="${hash}"`);
138 }
139 }
140 // Same-page fragments.
141 const ids = idsFor.get(p);
142 for (const m of html.matchAll(/href="#([^"]+)"/g)) {
143 if (!ids.has(m[1])) fail(`${where}: href="#${m[1]}" has no matching id`);
144 }
145}
146
147// 4. The anchor rule agrees on both sides. The Go gate accepted these
148// fragments; if the renderer slugifies differently they are dead
149// links no earlier check can see. The fixture is the platform repo's
150// docs/site/anchors.json, vendored β€” both languages read this file
151// rather than each other's implementation.
152//
153// The "//" key is not a case: it is generated-by metadata some fixture
154// writers prepend to every JSON file they emit. Skip it by name rather
155// than by loosening the comparison β€” the strictness IS this file's
156// purpose.
157const { slugify } = await import("../eleventy.config.js");
158const fixture = JSON.parse(readFileSync("src/_data/docsanchors.json", "utf8"));
159const cases = Object.entries(fixture).filter(([input]) => input !== "//");
160if (cases.length < 5) {
161 fail("src/_data/docsanchors.json has too few cases to pin anything β€” it has been emptied or truncated; restore it from the platform repo's docs/site/anchors.json");
162}
163for (const [input, want] of cases) {
164 const got = slugify(input);
165 if (got !== want) {
166 fail(`slugify(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)} β€” the renderer and internal/docsite.Anchor disagree`);
167 }
168}
169
170if (problems.length) {
171 console.error(`docs check failed (${problems.length}):`);
172 for (const p of problems) console.error(` ${p}`);
173 process.exit(1);
174}
175console.log(`docs ok β€” ${slugs.length} pages, ${pages.length} built, ${cases.length} anchor cases agree`);
176