Skip to content

Commit d0af474

Browse files
TMHSDigitalclaude
andcommitted
build: check #fragment links against the ids of the page they point at
Page checks skipped every same-page anchor and dropped the fragment from cross-page links, so renaming a heading could break a link on ~150 entry pages without failing the build. checkPage now checks same-page fragments against the page's own ids and cross-page ones against the target page's (build.mjs reads them from dist, once per page). Closes #166 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent da7bc72 commit d0af474

3 files changed

Lines changed: 70 additions & 5 deletions

File tree

‎site/build.mjs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { atomFeed, catalogCsv, commitDatesFromLog, DATED_PATHS, homeJsonLd, site
2828
import { schemaErrors } from "./lib/schema-check.mjs";
2929
import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs";
3030
import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs";
31-
import { checkPage } from "./lib/page-checks.mjs";
31+
import { checkPage, pageIds } from "./lib/page-checks.mjs";
3232
import { checkStacks } from "./checks.mjs";
3333
import { stackPageHtml } from "./lib/stack-page.mjs";
3434
import { freshnessLineHtml, freshnessPageHtml, freshnessStats, newEntriesLineHtml } from "./lib/freshness.mjs";
@@ -525,7 +525,21 @@ function checkPages(files) {
525525
if (produced.has(rel ? `${rel}/index.html` : "index.html")) return "dir";
526526
return null;
527527
};
528-
return files.flatMap((f) => checkPage(fs.readFileSync(path.join(DIST, f), "utf8"), { file: f, kindOf }));
528+
// A #fragment is checked against the ids of the page it points at.
529+
const ids = new Map();
530+
const idsOf = (rel) => {
531+
if (!ids.has(rel)) {
532+
let found = null;
533+
try {
534+
found = pageIds(fs.readFileSync(path.join(DIST, rel), "utf8"));
535+
} catch {
536+
// Not a readable page: kindOf has already vouched for it existing.
537+
}
538+
ids.set(rel, found);
539+
}
540+
return ids.get(rel);
541+
};
542+
return files.flatMap((f) => checkPage(fs.readFileSync(path.join(DIST, f), "utf8"), { file: f, kindOf, idsOf }));
529543
}
530544

531545
function main() {

‎site/lib/lib.test.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ const okPage = `<html><head><title>T</title><link rel="canonical" href="https://
179179
const kinds = new Map([["entry/e2", "dir"], ["", "dir"]]);
180180
const opts = { file: "entry/e1/index.html", kindOf: (p) => kinds.get(p) || null };
181181
eq("clean page passes", checkPage(okPage, opts).length, 0);
182+
{
183+
const withLinks = (links) => okPage.replace("<h1>A</h1>", `<h1 id="top">A</h1>${links}`);
184+
const fopts = { ...opts, idsOf: (p) => (p === "entry/e2/index.html" ? new Set(["preview-images"]) : null) };
185+
eq("fragment: a same-page id passes", checkPage(withLinks('<a href="#top">t</a><a href="#">x</a>'), fopts).length, 0);
186+
has("fragment: a missing same-page id fails", checkPage(withLinks('<a href="#nowhere">t</a>'), fopts).join(), "link #nowhere names no id on this page");
187+
eq("fragment: a cross-page id passes", checkPage(withLinks('<a href="../e2/#preview-images">p</a>'), fopts).length, 0);
188+
has("fragment: a missing cross-page id fails", checkPage(withLinks('<a href="../e2/#no-such-anchor">p</a>'), fopts).join(), "names no id on entry/e2/index.html");
189+
eq("fragment: an encoded id is decoded", checkPage(withLinks('<a href="#t%6Fp">t</a>'), fopts).length, 0);
190+
eq("fragment: unknown target ids are not guessed", checkPage(withLinks('<a href="../../#search">s</a>'), fopts).length, 0);
191+
}
182192
has("leaked bold", checkPage(okPage.replace("<h1>A</h1>", "<h1>A</h1>**b**"), opts).join(), "double asterisk");
183193
has("leaked link", checkPage(okPage.replace("<h1>A</h1>", "<h1>A</h1>[a](b)"), opts).join(), "markdown link");
184194
has("leaked backtick", checkPage(okPage.replace("<h1>A</h1>", "<h1>A</h1>`x`"), opts).join(), "backtick");

‎site/lib/page-checks.mjs‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,31 @@ const LEAKS = [
1010
[/`/, "a backtick"],
1111
];
1212

13-
export function checkPage(html, { file, kindOf }) {
13+
/** Every id="..." in a page, unescaped. */
14+
export function pageIds(html) {
15+
return new Set([...String(html).matchAll(/\sid="([^"]+)"/g)].map((m) => unescape(m[1])));
16+
}
17+
18+
/** The fragment of a link, decoded, or null when it has none worth checking ("#" alone, a text fragment). */
19+
function fragmentOf(href) {
20+
const i = href.indexOf("#");
21+
if (i === -1) return null;
22+
const raw = href.slice(i + 1);
23+
if (!raw || raw.startsWith(":~:")) return null;
24+
try {
25+
return decodeURIComponent(unescape(raw));
26+
} catch {
27+
return unescape(raw);
28+
}
29+
}
30+
31+
/**
32+
* `kindOf(rel)` says whether a dist path is a "file", a "dir" (with an
33+
* index.html) or missing. `idsOf(rel)`, when given, returns the ids of the
34+
* built page at that dist path (or null when it cannot tell), so a link's
35+
* #fragment is checked against the page it points at.
36+
*/
37+
export function checkPage(html, { file, kindOf, idsOf = null }) {
1438
const errors = [];
1539
const text = html
1640
.replace(/<script[\s\S]*?<\/script>/g, " ")
@@ -30,9 +54,16 @@ export function checkPage(html, { file, kindOf }) {
3054
const description = unescape(html.match(/<meta name="description" content="([^"]*)"/)?.[1] || "");
3155
if (description.length > SEO_DESCRIPTION_MAX) errors.push(`${file}: description is ${description.length} characters, over ${SEO_DESCRIPTION_MAX}`);
3256
const dir = path.posix.dirname(file);
57+
let ownIds = null;
3358
for (const m of html.matchAll(/\s(?:href|src)="([^"]+)"/g)) {
3459
const href = m[1];
35-
if (EXTERNAL_SCHEME_RE.test(href) || href.startsWith("#")) continue;
60+
if (EXTERNAL_SCHEME_RE.test(href)) continue;
61+
if (href.startsWith("#")) {
62+
const frag = fragmentOf(href);
63+
ownIds ??= pageIds(html);
64+
if (frag !== null && !ownIds.has(frag)) errors.push(`${file}: link ${href} names no id on this page`);
65+
continue;
66+
}
3667
const scheme = href.match(ANY_SCHEME_RE);
3768
if (scheme) {
3869
errors.push(`${file}: link ${href} uses the unsupported scheme "${scheme[1].toLowerCase()}:"`);
@@ -45,7 +76,17 @@ export function checkPage(html, { file, kindOf }) {
4576
errors.push(`${file}: link ${href} leaves the site`);
4677
continue;
4778
}
48-
if (!kindOf(target)) errors.push(`${file}: link ${href} does not resolve`);
79+
const kind = kindOf(target);
80+
if (!kind) {
81+
errors.push(`${file}: link ${href} does not resolve`);
82+
continue;
83+
}
84+
const frag = fragmentOf(href);
85+
if (frag !== null && idsOf) {
86+
const page = kind === "dir" ? (target ? `${target}/index.html` : "index.html") : target;
87+
const ids = page.endsWith(".html") ? idsOf(page) : null;
88+
if (ids && !ids.has(frag)) errors.push(`${file}: link ${href} names no id on ${page}`);
89+
}
4990
}
5091
return errors;
5192
}

0 commit comments

Comments
 (0)