From 215ddf872e55b10b15b1ce180cffbd0eee555c00 Mon Sep 17 00:00:00 2001
From: TM Hospitality Strategies
Date: Wed, 21 Oct 2026 23:19:31 -0400
Subject: [PATCH 1/8] Type-check list and boolean fields, strip trailing
frontmatter comments, one link-scheme rule, build-time duplicate id check
- frontmatter: a trailing ' # comment' is dropped outside quotes (#79); a
comment or blank line between a key and its block list no longer breaks
the list (#84); TEMPLATE.md moves its inline guidance to comment lines
- validate: new V24 checkFieldTypes rejects tags/subcategories/formats that
parsed as text and quoted booleans, with the fix in the message (#79)
- shared: EXTERNAL_SCHEME_RE and ANY_SCHEME_RE used by validate, the link
resolver and the page checks; an ftp:/data:/javascript: link is rejected
everywhere with the scheme named; a line-start autolink is not raw HTML (#83)
- build: a duplicate id fails the build instead of overwriting a page (#81)
Closes #79, #81, #83, #84
Co-Authored-By: Claude Fable 5.1
---
catalog/TEMPLATE.md | 9 +-
site/build.mjs | 8 +
site/checks.mjs | 32 +++
site/checks.test.mjs | 11 +
site/lib/frontmatter.mjs | 291 ++++++++++++++------------
site/lib/lib.test.mjs | 38 ++++
site/lib/links.mjs | 5 +-
site/lib/markdown.mjs | 429 ++++++++++++++++++++-------------------
site/lib/page-checks.mjs | 8 +-
site/lib/shared.mjs | 10 +
site/validate.mjs | 29 +--
11 files changed, 500 insertions(+), 370 deletions(-)
diff --git a/catalog/TEMPLATE.md b/catalog/TEMPLATE.md
index 311624b..3a124ff 100644
--- a/catalog/TEMPLATE.md
+++ b/catalog/TEMPLATE.md
@@ -11,9 +11,12 @@ commercial: true
attribution_required: false
formats: [glTF, FBX, PNG]
# subcategories and formats spelling: see CONTRIBUTING.md (formats must be in site/format-vocabulary.json)
-tags: [low-poly, modular] # describe the content; never the licence or the publisher (V19, V21)
-verified: 2026-07-19 # the day you read the licence at the source
-status: needs-review # set active once the licence is read and quoted in ## Evidence
+# tags describe the content; never the licence or the publisher (V19, V21)
+tags: [low-poly, modular]
+# verified: the day you read the licence at the source
+verified: 2026-07-19
+# status: set active once the licence is read and quoted in ## Evidence
+status: needs-review
# Optional (omit if N/A):
# publisher: Kenney # rights holder when that publisher has more than one entry; not a generic host
# attribution_string: "Credit line" # required when attribution_required is true
diff --git a/site/build.mjs b/site/build.mjs
index cc5fd38..bc5a67c 100644
--- a/site/build.mjs
+++ b/site/build.mjs
@@ -51,6 +51,9 @@ function loadEntries(vocab) {
const entries = [];
const errors = [];
const bodies = new Map();
+ // validate.mjs catches a duplicate id too, but a local `npm run build` must
+ // not quietly write one entry's page over another's.
+ const fileOfId = new Map();
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
@@ -72,6 +75,11 @@ function loadEntries(vocab) {
errors.push(`${rel} missing: ${missing.join(", ")}`);
continue;
}
+ if (fileOfId.has(String(meta.id))) {
+ errors.push(`duplicate id "${meta.id}": ${fileOfId.get(String(meta.id))} and ${rel}`);
+ continue;
+ }
+ fileOfId.set(String(meta.id), rel);
bodies.set(String(meta.id), body);
entries.push({
diff --git a/site/checks.mjs b/site/checks.mjs
index e5dc20f..b0ca821 100644
--- a/site/checks.mjs
+++ b/site/checks.mjs
@@ -236,6 +236,38 @@ export function checkPublisherConsistency(entries) {
return errors;
}
+/* ----------------------------------------------------------------- V24 */
+/** Fields that must parse as YAML lists. `hardware_tags` is checked in V12. */
+const LIST_FIELDS = ["subcategories", "formats", "tags"];
+/** Fields that must parse as booleans, plus the literal words each one also allows. */
+const BOOLEAN_FIELDS = { commercial: ["varies", "unknown"], attribution_required: ["unknown"] };
+
+/**
+ * The list fields were checked for emptiness but never for being lists, and
+ * the two boolean fields accepted the quoted strings "true"/"false". A
+ * trailing comment or a pair of quotes then produced an entry that passed
+ * validation with no tags, or with "commercial ?" on its card.
+ */
+export function checkFieldTypes(rel, meta) {
+ const errors = [];
+ for (const field of LIST_FIELDS) {
+ const v = meta[field];
+ if (empty(v) || Array.isArray(v)) continue;
+ errors.push(`${rel} ${field} must be a list like [a, b] or one "- item" per line; got the text "${String(v)}"`);
+ }
+ for (const [field, words] of Object.entries(BOOLEAN_FIELDS)) {
+ const v = meta[field];
+ if (empty(v) || typeof v === "boolean" || words.includes(v)) continue;
+ const s = String(v);
+ if (s === "true" || s === "false") {
+ errors.push(`${rel} ${field} "${s}" is quoted text, not a boolean; write ${field}: ${s} without quotes`);
+ } else {
+ errors.push(`${rel} ${field} "${s}" is not true|false|${words.join("|")}`);
+ }
+ }
+ return errors;
+}
+
/* ----------------------------------------------------------------- V12 */
/** Documented values for the optional 2D/UI taxonomy. See catalog/TEMPLATE.md. */
export const CAMERA_PERSPECTIVES = new Set([
diff --git a/site/checks.test.mjs b/site/checks.test.mjs
index 6a56df0..e88dc53 100644
--- a/site/checks.test.mjs
+++ b/site/checks.test.mjs
@@ -19,6 +19,7 @@ import {
checkEngineFields,
checkEntryUrl,
checkEvidenceDates,
+ checkFieldTypes,
checkFormatVocabulary,
evidenceDates,
markdownAnchors,
@@ -748,6 +749,16 @@ accepts(
formatVocab.formats.filter((f) => Object.prototype.hasOwnProperty.call(aliases.formats, f))
);
+/* V24: field types (#79) ------------------------------------------------ */
+rejects("V24 rejects tags that parsed as text", checkFieldTypes("bad.md", { tags: "[a, b] # note" }), "tags must be a list");
+rejects("V24 rejects a subcategories string", checkFieldTypes("bad.md", { subcategories: "models" }), "subcategories must be a list");
+rejects("V24 rejects a quoted boolean", checkFieldTypes("bad.md", { commercial: "true" }), "write commercial: true without quotes");
+rejects("V24 rejects a quoted attribution_required", checkFieldTypes("bad.md", { attribution_required: "false" }), "write attribution_required: false without quotes");
+rejects("V24 rejects an unknown commercial word", checkFieldTypes("bad.md", { commercial: "maybe" }), 'commercial "maybe" is not true|false|varies|unknown');
+accepts("V24 accepts lists and booleans", checkFieldTypes("ok.md", { tags: ["a"], subcategories: ["b"], formats: ["PNG"], commercial: true, attribution_required: false }));
+accepts("V24 accepts the literal words", checkFieldTypes("ok.md", { commercial: "varies", attribution_required: "unknown" }));
+accepts("V24 leaves missing fields to the required check", checkFieldTypes("ok.md", {}));
+
/* ----------------------------------------------------------------------- */
if (failures.length) {
console.error(`checks.test failed (${failures.length}):`);
diff --git a/site/lib/frontmatter.mjs b/site/lib/frontmatter.mjs
index 37af725..00f35fc 100644
--- a/site/lib/frontmatter.mjs
+++ b/site/lib/frontmatter.mjs
@@ -1,133 +1,158 @@
-/**
- * Entry frontmatter and body helpers, shared by build.mjs and validate.mjs so
- * the two can never read the same file differently. (They used to carry a
- * copy each, and the copies had drifted: one undid quote escapes and the
- * other did not.)
- *
- * The parser reads exactly the YAML subset entries use: `key: value`, quoted
- * scalars, flow lists (`[a, "b, c"]`), block lists (`key:` then ` - item`)
- * and `#` comment lines. Anything else, and a key given twice, is reported
- * with its line number rather than guessed at.
- */
-import { renderInline } from "./markdown.mjs";
-import { unquoteScalar } from "./shared.mjs";
-
-/** Splits the inside of a flow list on commas that are not inside quotes. */
-function splitFlowList(inner) {
- const parts = [];
- let cur = "";
- let quote = null;
- for (let i = 0; i < inner.length; i += 1) {
- const ch = inner[i];
- if (quote) {
- cur += ch;
- if (quote === '"' && ch === "\\" && i + 1 < inner.length) cur += inner[++i];
- else if (ch === quote) quote = null;
- } else if (ch === '"' || ch === "'") {
- quote = ch;
- cur += ch;
- } else if (ch === ",") {
- parts.push(cur);
- cur = "";
- } else cur += ch;
- }
- parts.push(cur);
- return parts.map((p) => unquoteScalar(p.trim()));
-}
-
-export function parseScalar(raw) {
- const v = raw.trim();
- if (v === "true") return true;
- if (v === "false") return false;
- if (v === "null" || v === "~" || v === "") return null;
- if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
- return unquoteScalar(v);
- }
- if (v.startsWith("[") && v.endsWith("]")) {
- const inner = v.slice(1, -1).trim();
- return inner ? splitFlowList(inner) : [];
- }
- return v;
-}
-
-/**
- * `{ meta, body, errors }`, or null when the text has no frontmatter block.
- * `errors` are "line N: ..." strings; the caller prefixes the file.
- */
-export function parseFrontmatter(text) {
- // A byte-order mark is invisible in an editor; ignore it.
- const match = String(text)
- .replace(/^/, "")
- .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
- if (!match) return null;
- const meta = {};
- const errors = [];
- const lines = match[1].split(/\r?\n/);
- let listKey = null;
- lines.forEach((line, i) => {
- const lineNo = i + 2; // line 1 is the opening ---
- if (!line.trim() || line.trim().startsWith("#")) return;
- const item = line.match(/^\s+-\s+(.*)$/);
- if (item) {
- if (listKey === null) errors.push(`line ${lineNo}: list item without a key above it`);
- else meta[listKey].push(parseScalar(item[1]));
- return;
- }
- listKey = null;
- const idx = line.indexOf(":");
- if (idx === -1 || /^\s/.test(line)) {
- errors.push(`line ${lineNo}: "${line.trim()}" is not a key: value line`);
- return;
- }
- const key = line.slice(0, idx).trim();
- if (Object.prototype.hasOwnProperty.call(meta, key)) {
- errors.push(`line ${lineNo}: key "${key}" is given twice`);
- return;
- }
- const value = parseScalar(line.slice(idx + 1));
- const next = lines[i + 1];
- if (value === null && next !== undefined && /^\s+-\s/.test(next)) {
- meta[key] = [];
- listKey = key;
- return;
- }
- meta[key] = value;
- });
- return { meta, body: match[2] || "", errors };
-}
-
-/** The `## Evidence` section of an entry body, up to the next `## ` heading, or null. */
-export function evidenceSection(body) {
- const idx = body.search(/^## Evidence\s*$/m);
- if (idx === -1) return null;
- const rest = body.slice(idx);
- const next = rest.search(/\n## (?!Evidence\s*$)/m);
- return next === -1 ? rest : rest.slice(0, next);
-}
-
-const ENTITIES = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" };
-
-/**
- * Plain text of a line of entry markdown. It goes through the same inline
- * renderer as the entry page and drops the tags, so it strips exactly the
- * markup the page renders: underscores and `#` stay literal (Rig_Medium, C#).
- */
-export function plainText(markdown) {
- const html = renderInline(markdown, (href) => ({ href, external: true }));
- return html
- .replace(/<[^>]+>/g, "")
- .replace(/&(amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m])
- .replace(/\s+/g, " ")
- .trim();
-}
-
-/** The first paragraph of an entry body as plain text, at most 220 characters. */
-export function summaryFromBody(body) {
- const first =
- String(body)
- .split(/\n\s*\n/)
- .map((p) => p.trim())
- .find((p) => p && !p.startsWith("#")) || "";
- const clean = plainText(first);
- return clean.length > 220 ? `${clean.slice(0, 217)}…` : clean;
-}
+/**
+ * Entry frontmatter and body helpers, shared by build.mjs and validate.mjs so
+ * the two can never read the same file differently. (They used to carry a
+ * copy each, and the copies had drifted: one undid quote escapes and the
+ * other did not.)
+ *
+ * The parser reads exactly the YAML subset entries use: `key: value`, quoted
+ * scalars, flow lists (`[a, "b, c"]`), block lists (`key:` then ` - item`)
+ * and `#` comment lines. Anything else, and a key given twice, is reported
+ * with its line number rather than guessed at.
+ */
+import { renderInline } from "./markdown.mjs";
+import { unquoteScalar } from "./shared.mjs";
+
+/** Splits the inside of a flow list on commas that are not inside quotes. */
+function splitFlowList(inner) {
+ const parts = [];
+ let cur = "";
+ let quote = null;
+ for (let i = 0; i < inner.length; i += 1) {
+ const ch = inner[i];
+ if (quote) {
+ cur += ch;
+ if (quote === '"' && ch === "\\" && i + 1 < inner.length) cur += inner[++i];
+ else if (ch === quote) quote = null;
+ } else if (ch === '"' || ch === "'") {
+ quote = ch;
+ cur += ch;
+ } else if (ch === ",") {
+ parts.push(cur);
+ cur = "";
+ } else cur += ch;
+ }
+ parts.push(cur);
+ return parts.map((p) => unquoteScalar(p.trim()));
+}
+
+/**
+ * Drops a trailing ` # comment`. As in YAML, a `#` starts a comment only after
+ * whitespace (or at the start) and outside quotes, so `C#` and `#fff` are
+ * content. A quote opens only where a scalar can start (the start, or after
+ * `[`, `,` or whitespace), so an apostrophe in `Kay's` is content too.
+ */
+function stripComment(raw) {
+ let quote = null;
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i];
+ if (quote) {
+ if (quote === '"' && ch === "\\") i += 1;
+ else if (ch === quote) quote = null;
+ } else if ((ch === '"' || ch === "'") && (i === 0 || /[\s[,]/.test(raw[i - 1]))) {
+ quote = ch;
+ } else if (ch === "#" && (i === 0 || /\s/.test(raw[i - 1]))) {
+ return raw.slice(0, i);
+ }
+ }
+ return raw;
+}
+
+export function parseScalar(raw) {
+ const v = stripComment(raw).trim();
+ if (v === "true") return true;
+ if (v === "false") return false;
+ if (v === "null" || v === "~" || v === "") return null;
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
+ return unquoteScalar(v);
+ }
+ if (v.startsWith("[") && v.endsWith("]")) {
+ const inner = v.slice(1, -1).trim();
+ return inner ? splitFlowList(inner) : [];
+ }
+ return v;
+}
+
+/**
+ * `{ meta, body, errors }`, or null when the text has no frontmatter block.
+ * `errors` are "line N: ..." strings; the caller prefixes the file.
+ */
+export function parseFrontmatter(text) {
+ // A byte-order mark is invisible in an editor; ignore it.
+ const match = String(text)
+ .replace(/^/, "")
+ .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
+ if (!match) return null;
+ const meta = {};
+ const errors = [];
+ const lines = match[1].split(/\r?\n/);
+ let listKey = null;
+ lines.forEach((line, i) => {
+ const lineNo = i + 2; // line 1 is the opening ---
+ if (!line.trim() || line.trim().startsWith("#")) return;
+ const item = line.match(/^\s+-\s+(.*)$/);
+ if (item) {
+ if (listKey === null) errors.push(`line ${lineNo}: list item without a key above it`);
+ else meta[listKey].push(parseScalar(item[1]));
+ return;
+ }
+ listKey = null;
+ const idx = line.indexOf(":");
+ if (idx === -1 || /^\s/.test(line)) {
+ errors.push(`line ${lineNo}: "${line.trim()}" is not a key: value line`);
+ return;
+ }
+ const key = line.slice(0, idx).trim();
+ if (Object.prototype.hasOwnProperty.call(meta, key)) {
+ errors.push(`line ${lineNo}: key "${key}" is given twice`);
+ return;
+ }
+ const value = parseScalar(line.slice(idx + 1));
+ // A block list may start after blank or comment lines under its key.
+ let j = i + 1;
+ while (j < lines.length && (!lines[j].trim() || lines[j].trim().startsWith("#"))) j += 1;
+ const next = lines[j];
+ if (value === null && next !== undefined && /^\s+-\s/.test(next)) {
+ meta[key] = [];
+ listKey = key;
+ return;
+ }
+ meta[key] = value;
+ });
+ return { meta, body: match[2] || "", errors };
+}
+
+/** The `## Evidence` section of an entry body, up to the next `## ` heading, or null. */
+export function evidenceSection(body) {
+ const idx = body.search(/^## Evidence\s*$/m);
+ if (idx === -1) return null;
+ const rest = body.slice(idx);
+ const next = rest.search(/\n## (?!Evidence\s*$)/m);
+ return next === -1 ? rest : rest.slice(0, next);
+}
+
+const ENTITIES = { "&": "&", "<": "<", ">": ">", """: '"', "'": "'" };
+
+/**
+ * Plain text of a line of entry markdown. It goes through the same inline
+ * renderer as the entry page and drops the tags, so it strips exactly the
+ * markup the page renders: underscores and `#` stay literal (Rig_Medium, C#).
+ */
+export function plainText(markdown) {
+ const html = renderInline(markdown, (href) => ({ href, external: true }));
+ return html
+ .replace(/<[^>]+>/g, "")
+ .replace(/&(amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m])
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/** The first paragraph of an entry body as plain text, at most 220 characters. */
+export function summaryFromBody(body) {
+ const first =
+ String(body)
+ .split(/\n\s*\n/)
+ .map((p) => p.trim())
+ .find((p) => p && !p.startsWith("#")) || "";
+ const clean = plainText(first);
+ return clean.length > 220 ? `${clean.slice(0, 217)}…` : clean;
+}
diff --git a/site/lib/lib.test.mjs b/site/lib/lib.test.mjs
index f6b3cbc..e3d0c18 100644
--- a/site/lib/lib.test.mjs
+++ b/site/lib/lib.test.mjs
@@ -118,6 +118,11 @@ eq("external untouched", resolve("https://a.test/x").href, "https://a.test/x");
eq("external flagged", resolve("https://a.test/x").external, true);
throws("missing target fails", () => resolve("nope.md"), 'link target "nope.md" does not exist');
throws("escaping the repo fails", () => resolve("../../../outside.md"), "does not exist");
+// #83: one scheme rule everywhere, and the unsupported scheme is named.
+throws("ftp link names its scheme", () => resolve("ftp://a.test/x"), 'unsupported scheme "ftp:"');
+throws("data link names its scheme", () => resolve("data:text/plain,hi"), 'unsupported scheme "data:"');
+throws("javascript link names its scheme", () => resolve("javascript:alert(1)"), 'unsupported scheme "javascript:"');
+eq("mailto is external", resolve("mailto:a@b.test").external, true);
/* entry page --------------------------------------------------------------- */
import { entryPageHtml, jsonLd, scriptJson } from "./entry-page.mjs";
@@ -161,6 +166,9 @@ has("empty title", checkPage(okPage.replace("T", "
has("dangling link", checkPage(okPage.replace("../e2/", "../e3/"), opts).join(), "../e3/ does not resolve");
has("link leaving the site", checkPage(okPage.replace("../e2/", "../../../x/"), opts).join(), "leaves the site");
eq("query and hash are ignored", checkPage(okPage.replace("../e2/", "../../?cat=2d#catalog"), opts).length, 0);
+has("page check rejects a data: href", checkPage(okPage.replace("../e2/", "data:text/html,x"), opts).join(), 'unsupported scheme "data:"');
+has("page check rejects a javascript: href", checkPage(okPage.replace("../e2/", "javascript:void(0)"), opts).join(), 'unsupported scheme "javascript:"');
+eq("page check accepts mailto", checkPage(okPage.replace("../e2/", "mailto:a@b.test"), opts).length, 0);
/* llms ------------------------------------------------------------------- */
import { llmsFullTxt, llmsTxt } from "./llms.mjs";
@@ -492,6 +500,34 @@ eq("frontmatter with CRLF line endings", parseFrontmatter("---\r\nid: a\r\nname:
has("frontmatter rejects a duplicate key", fm(["id: a", "id: b"]).errors.join(), 'line 3: key "id" is given twice');
has("frontmatter rejects a line that is not key: value", fm(["id: a", "just words"]).errors.join(), 'line 3: "just words" is not a key: value line');
eq("no frontmatter is null", parseFrontmatter("# Title\n"), null);
+// #79: trailing comments. #84: a comment between a key and its block list.
+const f2 = fm([
+ "id: a",
+ "tags: [low-poly, modular] # describe the content",
+ "name: Kay's pack # note",
+ 'attribution_string: "Art # by X" # keep the hash inside the quotes',
+ "langs: [c#, f#]",
+ 'colour: "#fff"',
+ "ref: a#b",
+ "verified: 2026-07-19 # the day you read the licence",
+ "formats:",
+ " # content words only",
+ "",
+ " - PNG # a comment on an item",
+ " - SVG",
+ "commercial: true # yes",
+]);
+eq("frontmatter strips a comment after a flow list", JSON.stringify(f2.meta.tags), JSON.stringify(["low-poly", "modular"]));
+eq("frontmatter strips a comment after a bare scalar with an apostrophe", f2.meta.name, "Kay's pack");
+eq("frontmatter keeps a # inside quotes", f2.meta.attribution_string, "Art # by X");
+eq("frontmatter keeps C# and F#", JSON.stringify(f2.meta.langs), JSON.stringify(["c#", "f#"]));
+eq("frontmatter keeps a quoted # value", f2.meta.colour, "#fff");
+eq("frontmatter keeps a # with no space before it", f2.meta.ref, "a#b");
+eq("frontmatter strips a comment after a date", f2.meta.verified, "2026-07-19");
+eq("frontmatter block list after a comment and a blank line", JSON.stringify(f2.meta.formats), JSON.stringify(["PNG", "SVG"]));
+eq("frontmatter boolean before a comment", f2.meta.commercial, true);
+eq("frontmatter has no errors with comments everywhere", f2.errors.join(), "");
+eq("frontmatter keeps a quoted boolean as text", fm(["id: a", 'commercial: "true"']).meta.commercial, "true");
eq("evidence stops at the next heading, whatever its letter", evidenceSection("## Evidence\n\n- e (2026-01-01)\n\n## Extras\n\n- x"), "## Evidence\n\n- e (2026-01-01)\n");
eq("summary keeps underscores", summaryFromBody("Split by Rig_Medium and **KHR_draco**.\n\n## Notes"), "Split by Rig_Medium and KHR_draco.");
eq("summary keeps C# and link text", summaryFromBody("# T\n\nA [C#](x.md) library, `code`, *em*."), "A C# library, code, em.");
@@ -502,6 +538,8 @@ eq("summary is capped at 220", summaryFromBody("word ".repeat(80)).length, 218);
eq("link href keeps balanced parens", renderInline("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)) next", passLink), 'Foo next');
eq("misnested emphasis stays literal", renderInline("**a *b** c*", passLink), "a *b c*");
eq("italic around bold still works", renderInline("*a **b** c*", passLink), "a b c");
+has("a line starting with an autolink is not raw HTML", rb(" is the page."), ' rb("bold"), "raw HTML");
const dupIds = rb("## Notes\n\nx\n\n## Notes\n\ny\n\n## Content\n\nz");
has("first heading keeps its id", dupIds, '
");
- }
- item = li[2];
- blank = false;
- return;
- }
- if (stack.length && !blank && /^\s/.test(line)) {
- item += ` ${line.trim()}`;
- return;
- }
- closeLists();
- blank = false;
- const h = line.match(/^(#{1,2})\s+(.*)$/);
- if (h) {
- flushPara();
- if (h[1] === "#") return; // the page shows the entry name as its h1
- // Unique on the page: a second "Notes" becomes notes-2, and a heading
- // never takes an id the page template uses.
- const base = h[2].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
- let id = base;
- for (let n = 2; ids.has(id); n += 1) id = `${base}-${n}`;
- ids.add(id);
- html.push(`
${inline(h[2])}
`);
- return;
- }
- para.push(line.trim());
- });
- flushPara();
- closeLists();
- // Items and their markup were pushed as separate strings; join tight.
- return html.join("").replace(/<\/p><(h2|p|ul)/g, "
\n<$1").replace(/<\/ul><(h2|p)/g, "\n<$1").replace(/<\/h2><(p|ul)/g, "\n<$1");
-}
-
-/** Lead (before the first `## `) and the rest, with the rest's first line number. */
-export function splitEntryBody(body) {
- const ls = lines(body);
- const i = ls.findIndex((l) => /^##\s/.test(l));
- if (i === -1) return { lead: ls.join("\n"), rest: "", restFirstLine: ls.length + 1 };
- return { lead: ls.slice(0, i).join("\n"), rest: ls.slice(i).join("\n"), restFirstLine: i + 1 };
-}
-
-/** Raw markdown under `## `, up to the next `## `, or null. */
-export function sectionMarkdown(body, heading) {
- const ls = lines(body);
- const start = ls.findIndex((l) => l.trim() === `## ${heading}`);
- if (start === -1) return null;
- let end = ls.findIndex((l, j) => j > start && /^##\s/.test(l));
- if (end === -1) end = ls.length;
- return ls.slice(start + 1, end).join("\n").trim();
-}
-
-/** The text after the `- Deprecated:` marker line, or null. */
-export function deprecationReason(body) {
- const m = String(body).match(/^- Deprecated:\s*(.+)$/m);
- if (!m) return null;
- // The banner reads "Deprecated. ", so the reason starts a sentence.
- const reason = m[1].trim();
- return reason.charAt(0).toUpperCase() + reason.slice(1);
-}
+/**
+ * Renders catalog entry bodies to HTML. Deliberately small: it supports the
+ * markdown the catalog uses (measured 2026-09-24) and throws on anything
+ * else, so a new construct fails the build instead of rendering wrong.
+ * Underscores are literal: bodies contain identifiers like Rig_Medium.
+ */
+import { esc } from "./shared.mjs";
+
+export class MarkdownError extends Error {}
+
+const slot = (i) => `\u0000${i}\u0000`;
+const SLOT_RE = /\u0000(\d+)\u0000/g;
+
+const count = (str, sub) => str.split(sub).length - 1;
+
+/**
+ * A markdown link. The href may hold one level of balanced parens
+ * (Foo_(bar)). Shared with llms.mjs so both read the same href.
+ */
+export const LINK_RE = /\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g;
+
+/**
+ * Bold and italic on already-escaped text. Italic that would cross a bold
+ * boundary (`**a *b** c*`) stays literal: wrapping it would nest the tags
+ * wrongly.
+ */
+function marks(escaped) {
+ return escaped
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*/g, "$1")
+ .replace(/(^|[^*\w])\*(?=\S)([^*]+?)(?<=\S)\*(?![*\w])/g, (whole, pre, inner) =>
+ count(inner, "") === count(inner, "") ? `${pre}${inner}` : whole
+ );
+}
+
+/** Ids the page templates use themselves; a heading must not take one. */
+const RESERVED_IDS = new Set([
+ "attribution-string",
+ "content",
+ "copy-attribution",
+ "copy-status",
+ "credits-all",
+ "credits-list",
+ "every-check",
+ "facts-title",
+ "owes-title",
+ "recent",
+ "sec-gaps",
+]);
+
+export function renderInline(text, resolveLink) {
+ const slots = [];
+ const keep = (html) => {
+ slots.push(html);
+ return slot(slots.length - 1);
+ };
+ // NUL marks a slot below; one in the input could make a slot hold its own
+ // token and the restore loop never settle.
+ let s = String(text).replaceAll("\u0000", "");
+ // Code spans first: their contents are literal.
+ s = s.replace(/`([^`]+)`/g, (_, code) => keep(`${esc(code)}`));
+ // Backslash escapes: any ASCII punctuation, as in CommonMark.
+ s = s.replace(/\\([!-/:-@[-`{-~])/g, (_, ch) => keep(esc(ch)));
+ // Links. The label keeps bold, italic and code; the href goes through the
+ // resolver.
+ s = s.replace(LINK_RE, (_, label, href) => {
+ const { href: out, external } = resolveLink(href);
+ const rel = external ? ' rel="noopener noreferrer"' : "";
+ return keep(`${marks(esc(label))}`);
+ });
+ // Autolinks in angle brackets.
+ s = s.replace(/<(https?:\/\/[^\s<>]+)>/g, (_, url) => keep(`${esc(url)}`));
+ // Bare URLs, leaving trailing sentence punctuation outside the link. A URL
+ // stops at `*` (bold around it) and at a slot (a code span right after it),
+ // and keeps a closing paren that balances one inside it (Foo_(bar)).
+ s = s.replace(/https?:\/\/[^\s<>"*\u0000]+/g, (url) => {
+ let [, core, tail] = url.match(/^(.*?)([.,;:!?)\]]*)$/);
+ while (tail.startsWith(")") && count(core, "(") > count(core, ")")) {
+ core += ")";
+ tail = tail.slice(1);
+ }
+ return keep(`${esc(core)}`) + tail;
+ });
+ s = marks(esc(s));
+ // Link labels can hold code slots, so restore until stable.
+ let before;
+ do {
+ before = s;
+ s = s.replace(SLOT_RE, (_, i) => slots[Number(i)]);
+ } while (s !== before);
+ return s;
+}
+
+const UNSUPPORTED = [
+ [/^\s*\|/, "a table row"],
+ [/^\s*```/, "a code fence"],
+ [/^\s*>/, "a blockquote"],
+ [/^\s*!\[/, "an image"],
+ // An autolink `` at the start of a line is inline markdown, not HTML.
+ [/^\s*<(?!https?:\/\/)[A-Za-z!/]/, "raw HTML"],
+ [/^#{3,}\s/, "a heading below level 2"],
+ [/^\s*\d+[.)]\s/, "an ordered list"],
+];
+
+const lines = (md) => String(md).replace(/\r\n?/g, "\n").split("\n");
+
+export function renderBlocks(md, { file, resolveLink, firstLine = 1 }) {
+ const html = [];
+ const stack = [];
+ const ids = new Set(RESERVED_IDS);
+ let para = [];
+ let item = null;
+ let blank = false;
+ const inline = (t) => renderInline(t, resolveLink);
+ const flushPara = () => {
+ if (para.length) html.push(`
${inline(para.join(" "))}
`);
+ para = [];
+ };
+ const flushItem = () => {
+ if (item !== null) html.push(inline(item));
+ item = null;
+ };
+ const closeLists = () => {
+ flushItem();
+ while (stack.length) {
+ html.push("");
+ stack.pop();
+ }
+ };
+ lines(md).forEach((line, i) => {
+ for (const [re, what] of UNSUPPORTED) {
+ if (re.test(line)) {
+ throw new MarkdownError(`${file}:${firstLine + i}: ${what} is not supported by the site renderer`);
+ }
+ }
+ if (!line.trim()) {
+ flushPara();
+ blank = true;
+ return;
+ }
+ const li = line.match(/^(\s*)[-*]\s+(.*)$/);
+ if (li) {
+ flushPara();
+ const indent = li[1].replace(/\t/g, " ").length;
+ if (!stack.length) {
+ html.push("
");
+ }
+ item = li[2];
+ blank = false;
+ return;
+ }
+ if (stack.length && !blank && /^\s/.test(line)) {
+ item += ` ${line.trim()}`;
+ return;
+ }
+ closeLists();
+ blank = false;
+ const h = line.match(/^(#{1,2})\s+(.*)$/);
+ if (h) {
+ flushPara();
+ if (h[1] === "#") return; // the page shows the entry name as its h1
+ // Unique on the page: a second "Notes" becomes notes-2, and a heading
+ // never takes an id the page template uses.
+ const base = h[2].toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
+ let id = base;
+ for (let n = 2; ids.has(id); n += 1) id = `${base}-${n}`;
+ ids.add(id);
+ html.push(`
${inline(h[2])}
`);
+ return;
+ }
+ para.push(line.trim());
+ });
+ flushPara();
+ closeLists();
+ // Items and their markup were pushed as separate strings; join tight.
+ return html.join("").replace(/<\/p><(h2|p|ul)/g, "\n<$1").replace(/<\/ul><(h2|p)/g, "