Skip to content

Commit dbb0634

Browse files
TMHSDigitalclaude
andcommitted
Frontmatter quote errors, case-sensitive link check, one id rule
- An unterminated quoted scalar or list item is reported with its line instead of being accepted with the opening quote kept (Closes #138) - Relative links whose case differs from the file on disk are reported by validate and refused by the build's link resolver, so a link that works on Windows or macOS cannot pass locally and fail in CI (Closes #139) - ID_RE lives in site/lib/entry-files.mjs with idMatchesFile; validate, scaffold and the thumbnail manifest check import it, and build.mjs and thumbs.mjs refuse to build a page or file path from an id that does not match its file name (Closes #135) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent fab5285 commit dbb0634

9 files changed

Lines changed: 85 additions & 13 deletions

File tree

‎site/build.mjs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import { filmstripHtml, groupedEntriesHtml } from "./lib/cards.mjs";
1919
import { categoryPageHtml } from "./lib/category-page.mjs";
2020
import { filmstripPick, isAttributionLicence, previewCredit, thumbRelPath } from "./lib/thumbs.mjs";
2121
import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs";
22-
import { listEntryFiles } from "./lib/entry-files.mjs";
22+
import { listEntryFiles, idMatchesFile } from "./lib/entry-files.mjs";
2323
import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs";
24-
import { LinkError, makeLinkResolver } from "./lib/links.mjs";
24+
import { LinkError, makeLinkResolver, caseMismatch } from "./lib/links.mjs";
2525
import { atomFeed, catalogCsv, homeJsonLd, sitemapXml } from "./lib/exports.mjs";
2626
import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs";
2727
import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs";
@@ -97,6 +97,11 @@ function loadEntries(vocab) {
9797
errors.push(`duplicate id "${meta.id}": ${fileOfId.get(String(meta.id))} and ${rel}`);
9898
continue;
9999
}
100+
// Page and thumbnail paths are built from the id; never from one that
101+
// could leave dist (validate reports the same mismatch with a line).
102+
if (!idMatchesFile(meta.id, rel)) {
103+
throw new Error(`${rel}: id "${meta.id}" must be short kebab-case and match the file name (node site/validate.mjs explains)`);
104+
}
100105
fileOfId.set(String(meta.id), rel);
101106
bodies.set(String(meta.id), body);
102107

@@ -340,9 +345,12 @@ function pruneDist(dir = DIST, prefix = "") {
340345
/** Returns file => link resolver for links in that file's body (`file.path` is repo-relative). */
341346
function linkResolvers(entries, config, entryBase = "../") {
342347
const idByPath = new Map(entries.map((e) => [e.path, e.id]));
348+
const realRoot = fs.realpathSync.native(ROOT);
343349
const kindOf = (repoPath) => {
344350
const full = path.join(ROOT, repoPath);
345351
if (!fs.existsSync(full)) return null;
352+
// A wrong-case link resolves here on Windows and macOS but not on Linux.
353+
if (caseMismatch(repoPath, path.relative(realRoot, fs.realpathSync.native(full)))) return null;
346354
return fs.statSync(full).isDirectory() ? "dir" : "file";
347355
};
348356
return (file) => makeLinkResolver({ entryPath: file.path, idByPath, repo: config.site.repo, kindOf, entryBase });

‎site/checks.mjs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import path from "node:path";
10+
import { ID_RE } from "./lib/entry-files.mjs";
1011
import { evidenceSection } from "./lib/frontmatter.mjs";
1112
import { isRealDate } from "./lib/shared.mjs";
1213
import { previewEligibility } from "./lib/thumbs.mjs";
@@ -803,13 +804,13 @@ export function checkThumbManifest(manifest, entriesById, files) {
803804
const thumbs = (manifest && manifest.thumbs) || {};
804805
const fileIds = new Set();
805806
for (const name of files) {
806-
const m = name.match(/^([a-z0-9]+(?:-[a-z0-9]+)*)\.webp$/);
807-
if (!m) {
807+
const id = name.endsWith(".webp") ? name.slice(0, -".webp".length) : "";
808+
if (!ID_RE.test(id)) {
808809
errors.push(`site/public/thumbs/${name} is not a <id>.webp thumbnail`);
809810
continue;
810811
}
811-
fileIds.add(m[1]);
812-
if (!thumbs[m[1]]) errors.push(`site/public/thumbs/${name} has no record in site/thumbs.json`);
812+
fileIds.add(id);
813+
if (!thumbs[id]) errors.push(`site/public/thumbs/${name} has no record in site/thumbs.json`);
813814
}
814815
for (const [id, rec] of Object.entries(thumbs)) {
815816
if (!fileIds.has(id)) errors.push(`site/thumbs.json has "${id}" but site/public/thumbs/${id}.webp is missing`);

‎site/lib/entry-files.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
import fs from "node:fs";
66
import path from "node:path";
77

8+
/** A short kebab-case id: the only thing an entry's file name, page path and thumbnail name are built from. */
9+
export const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
10+
11+
/**
12+
* True when `id` may be used as a path segment for `file`: it matches ID_RE
13+
* and equals the file's base name. validate reports the mismatch; the other
14+
* scripts refuse to build a path from anything else.
15+
*/
16+
export function idMatchesFile(id, file) {
17+
return ID_RE.test(String(id)) && String(id) === path.basename(String(file), ".md");
18+
}
19+
820
/** Not entries: the category index and the copy-me template. */
921
export const NON_ENTRY_MD = new Set(["README.md", "TEMPLATE.md"]);
1022
/** Never walked: hidden folders (any name starting ".") and local scratch. */

‎site/lib/frontmatter.mjs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ function cutAtHash(raw, value) {
6666
return /\S\s+#\S/.test(raw) && typeof value === "string" && value !== "" && !/^["'[]/.test(raw.trim());
6767
}
6868

69+
/** "double" or "single" when a scalar opens a quote it never closes, else null. */
70+
export function unclosedQuote(raw) {
71+
const v = stripComment(raw).trim();
72+
const q = v[0];
73+
if (q !== '"' && q !== "'") return null;
74+
if (v.length >= 2 && v.endsWith(q)) return null;
75+
return q === '"' ? "double" : "single";
76+
}
77+
6978
export function parseScalar(raw) {
7079
const v = stripComment(raw).trim();
7180
if (v === "true") return true;
@@ -103,7 +112,9 @@ export function parseFrontmatter(text) {
103112
if (listKey === null) errors.push(`line ${lineNo}: list item without a key above it`);
104113
else {
105114
const value = parseScalar(item[1]);
106-
if (value === null) errors.push(`line ${lineNo}: list item is empty (a value starting with # is a comment; quote it)`);
115+
const open = unclosedQuote(item[1]);
116+
if (open) errors.push(`line ${lineNo}: list item opens a ${open} quote that is never closed`);
117+
else if (value === null) errors.push(`line ${lineNo}: list item is empty (a value starting with # is a comment; quote it)`);
107118
else if (cutAtHash(item[1], value)) errors.push(`line ${lineNo}: list item is cut at " #" (read as a comment); quote the value if the # is part of it`);
108119
else meta[listKey].push(value);
109120
}
@@ -122,6 +133,8 @@ export function parseFrontmatter(text) {
122133
}
123134
const rawValue = line.slice(idx + 1);
124135
const value = parseScalar(rawValue);
136+
const open = unclosedQuote(rawValue);
137+
if (open) errors.push(`line ${lineNo}: "${key}" opens a ${open} quote that is never closed`);
125138
if (cutAtHash(rawValue, value)) {
126139
errors.push(`line ${lineNo}: "${key}" is cut at " #" (read as a comment); quote the value if the # is part of it`);
127140
}

‎site/lib/lib.test.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,15 @@ eq("sectionMarkdown missing", sectionMarkdown(body, "Nope"), null);
103103
eq("deprecationReason starts a sentence", deprecationReason(body), "Gone");
104104

105105
/* links -------------------------------------------------------------------- */
106-
import { makeLinkResolver } from "./links.mjs";
106+
import { makeLinkResolver, caseMismatch } from "./links.mjs";
107107
const idByPath = new Map([["catalog/2d/kenney-ui-pack.md", "kenney-ui-pack"], ["catalog/audio/kenney-ui-audio.md", "kenney-ui-audio"]]);
108108
const files = new Map([["docs/provenance.md", "file"], ["catalog/2d/README.md", "file"], ["catalog/video", "dir"]]);
109109
const resolve = makeLinkResolver({ entryPath: "catalog/2d/kenney-ui-pack.md", idByPath, repo: "https://github.com/o/r", kindOf: (p) => files.get(p) || null });
110+
eq("links: case mismatch names the component", caseMismatch("docs/Licenses.md", "docs/licenses.md"), "Licenses.md vs licenses.md");
111+
eq("links: directory case counts", caseMismatch("Docs/a.md", "docs/a.md"), "Docs vs docs");
112+
eq("links: same path is not a mismatch", caseMismatch("docs/licenses.md", "docs\\licenses.md"), null);
113+
eq("links: a different file is not a case mismatch", caseMismatch("docs/a.md", "docs/b.md"), null);
114+
eq("links: a drive letter ends the comparison", caseMismatch("e:/r/docs/a.md", "E:/r/docs/a.md"), null);
110115
eq("sibling entry", resolve("kenney-ui-pack.md").href, "../kenney-ui-pack/");
111116
eq("entry in another category", resolve("../audio/kenney-ui-audio.md").href, "../kenney-ui-audio/");
112117
eq("entry link is internal", resolve("../audio/kenney-ui-audio.md").external, false);
@@ -557,6 +562,10 @@ has("empty recent window wording counts the build day", freshnessPageHtml({ stat
557562
import { evidenceSection, parseFrontmatter, plainText, summaryFromBody } from "./frontmatter.mjs";
558563
import { latestAllowedDate } from "./shared.mjs";
559564
const fm = (lines, body = "Body.\n") => parseFrontmatter(`---\n${lines.join("\n")}\n---\n${body}`);
565+
eq("frontmatter: unterminated double quote is an error", fm(['name: "Foo']).errors.some((e) => e === 'line 2: "name" opens a double quote that is never closed'), true);
566+
eq("frontmatter: unterminated single quote in a list item", fm(["tags:", " - 'a"]).errors.some((e) => e === "line 3: list item opens a single quote that is never closed"), true);
567+
eq("frontmatter: closed quotes stay clean", fm(['name: "Foo"', "tags:", " - 'a'"]).errors.length, 0);
568+
eq("frontmatter: a lone quote character is unterminated", fm(['name: "']).errors.length, 1);
560569
const f1 = fm([
561570
"id: a",
562571
'attribution_string: "Music \\"{title}\\" by {artist}"',

‎site/lib/links.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ import { ANY_SCHEME_RE, EXTERNAL_SCHEME_RE } from "./shared.mjs";
44

55
export class LinkError extends Error {}
66

7+
/**
8+
* The path component whose case differs between a link target and the file
9+
* on disk, as "linked vs on disk", or null. Windows and macOS resolve
10+
* `Docs/Foo.md` to `docs/foo.md`; Linux, where CI runs, does not. Compared
11+
* from the file name backwards; a component that differs in more than case
12+
* (a symlink, a drive letter) ends the comparison.
13+
*/
14+
export function caseMismatch(linked, onDisk) {
15+
const a = String(linked).split(/[\\/]+/).filter(Boolean);
16+
const b = String(onDisk).split(/[\\/]+/).filter(Boolean);
17+
const n = Math.min(a.length, b.length);
18+
for (let i = 1; i <= n; i += 1) {
19+
const x = a[a.length - i];
20+
const y = b[b.length - i];
21+
if (x === y) continue;
22+
if (x.endsWith(":") || y.endsWith(":")) return null;
23+
return x.toLowerCase() === y.toLowerCase() ? `${x} vs ${y}` : null;
24+
}
25+
return null;
26+
}
27+
728
export function makeLinkResolver({ entryPath, idByPath, repo, kindOf, entryBase = "../" }) {
829
const dir = path.posix.dirname(entryPath);
930
return (raw) => {

‎site/lib/scaffold.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export const syncCatalogReadme = (text, counts) => syncTable(text, counts, "`CAT
2727

2828
export const syncConfig = (text, total) => text.replace(/("expectedEntryCount":\s*)\d+/, `$1${total}`);
2929

30-
export const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
30+
import { ID_RE } from "./entry-files.mjs";
31+
export { ID_RE };
3132

3233
/** Why new-entry must refuse these arguments, or null. `taken` is the category already holding the id, if any. */
3334
export function newEntryProblem({ category, id, categories, taken }) {

‎site/thumbs.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import fs from "node:fs";
1717
import os from "node:os";
1818
import path from "node:path";
1919
import { fileURLToPath } from "node:url";
20-
import { listEntryFiles } from "./lib/entry-files.mjs";
20+
import { listEntryFiles, idMatchesFile } from "./lib/entry-files.mjs";
2121
import { parseFrontmatter } from "./lib/frontmatter.mjs";
2222
import { imageUrlFromHtml, needsFetch, previewEligibility, sharedImageIds, webpSize } from "./lib/thumbs.mjs";
2323

@@ -49,7 +49,7 @@ function loadEntries() {
4949
const out = [];
5050
for (const file of listEntryFiles(CATALOG)) {
5151
const parsed = parseFrontmatter(fs.readFileSync(file, "utf8"));
52-
if (!parsed || parsed.errors.length || !parsed.meta.id) continue;
52+
if (!parsed || parsed.errors.length || !idMatchesFile(parsed.meta.id, file)) continue;
5353
const m = parsed.meta;
5454
out.push({
5555
id: String(m.id),

‎site/validate.mjs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Catalog integrity checks. Run before every commit: node site/validate.mjs
44
*/
55
import fs from "node:fs";
6+
import { caseMismatch } from "./lib/links.mjs";
67
import path from "node:path";
78
import { fileURLToPath } from "node:url";
89
import {
@@ -37,11 +38,12 @@ import {
3738
} from "./checks.mjs";
3839
import { evidenceSection, parseFrontmatter } from "./lib/frontmatter.mjs";
3940
import { ANY_SCHEME_RE, EXTERNAL_SCHEME_RE, isRealDate, latestAllowedDate } from "./lib/shared.mjs";
40-
import { listEntryFiles } from "./lib/entry-files.mjs";
41+
import { listEntryFiles, ID_RE } from "./lib/entry-files.mjs";
4142
import { licenceTerms, listStackFiles } from "./lib/stacks.mjs";
4243

4344
const __dirname = path.dirname(fileURLToPath(import.meta.url));
4445
const ROOT = path.resolve(__dirname, "..");
46+
const REAL_ROOT = fs.realpathSync.native(ROOT);
4547
const CATALOG = path.join(ROOT, "catalog");
4648
const CONFIG_PATH = path.join(__dirname, "config.json");
4749
const SPDX_ALLOWED_PATH = path.join(__dirname, "spdx-allowed.json");
@@ -64,7 +66,6 @@ const REQUIRED = [
6466
"verified",
6567
"status",
6668
];
67-
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
6869
// C0 controls other than tab, LF and CR: invalid in XML, and NUL is the
6970
// markdown renderer's slot marker.
7071
const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
@@ -189,6 +190,12 @@ function checkRelativeLinks(file, errors) {
189190
errors.push(`${relFromRoot(file)} broken link: ${href}`);
190191
continue;
191192
}
193+
// Case-insensitive filesystems accept a wrong-case link that CI (Linux) will not.
194+
const mismatch = caseMismatch(path.relative(ROOT, resolved), path.relative(REAL_ROOT, fs.realpathSync.native(resolved)));
195+
if (mismatch) {
196+
errors.push(`${relFromRoot(file)} broken link: ${href} (case differs on disk: ${mismatch}; CI is case-sensitive)`);
197+
continue;
198+
}
192199
// A fragment into a Markdown file must name one of its headings.
193200
if (fragment && resolved.endsWith(".md") && fs.statSync(resolved).isFile()) {
194201
let id = fragment;

0 commit comments

Comments
 (0)