From 4e5071726de1e9703ab0d87aa12754b59e03c9ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 04:56:43 +0000 Subject: [PATCH] scripts: split the MDX frontmatter fence in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/content-lint.mjs` and `scripts/lib/post-dates.mjs` each carried an identical copy of the fence split — `startsWith('---\n')`, `indexOf('\n---', 4)`, slice. Identical is what made them dangerous: the frontmatter fence is the kind of parsing detail where a divergence is silent, one script accepting a file the other rejects with no gate able to see the disagreement. The split now lives once, in `scripts/lib/frontmatter.mjs`. It reports what went wrong and never throws, so each caller keeps its own wording: content-lint's messages are part of the report four gates read, and post-dates throws where content-lint collects. Neither string moved. No behaviour change: same predicate, same offsets, same slices. content-lint still trims the body it counts words from; post-dates still rejects a non-mapping YAML result. `scripts/gen-zh-hant.mjs` and `scripts/lib/wechat-html.mjs` split a fence with a deliberately weaker, non-failing parser and are left alone — documented in the helper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- scripts/content-lint.mjs | 22 +++++---- scripts/lib/frontmatter.mjs | 90 +++++++++++++++++++++++++++++++++++++ scripts/lib/post-dates.mjs | 23 +++++++--- 3 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 scripts/lib/frontmatter.mjs diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index ffa260e..fd39e68 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -11,6 +11,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { cwd, argv, exit } from 'node:process'; import yaml from 'js-yaml'; +import { splitFrontmatter as splitFence } from './lib/frontmatter.mjs'; const ROOT = cwd(); const BLOG = path.join(ROOT, 'content', 'blog'); @@ -286,17 +287,22 @@ async function walk(dir) { return files; } +// The fence itself is split by `scripts/lib/frontmatter.mjs`, shared with the +// sitemap's `lastmod` reader so the two cannot drift into accepting different +// files. The wording below stays here: this gate's messages are read by its +// callers, so the helper reports what went wrong and this function says it. function splitFrontmatter(source, file) { - if (!source.startsWith('---\n')) { - throw new Error(`${file} does not start with YAML frontmatter`); - } - const end = source.indexOf('\n---', 4); - if (end === -1) { - throw new Error(`${file} has no closing frontmatter fence`); + const split = splitFence(source); + if (!split.ok) { + throw new Error( + split.reason === 'no-opening-fence' + ? `${file} does not start with YAML frontmatter` + : `${file} has no closing frontmatter fence` + ); } return { - raw: source.slice(4, end), - body: source.slice(end + 4).trim(), + raw: split.raw, + body: split.body.trim(), }; } diff --git a/scripts/lib/frontmatter.mjs b/scripts/lib/frontmatter.mjs new file mode 100644 index 0000000..211d1e4 --- /dev/null +++ b/scripts/lib/frontmatter.mjs @@ -0,0 +1,90 @@ +// The one place an MDX file's YAML frontmatter fence is split off its body. +// +// Two readers need this split and each used to carry its own copy of it: +// +// * `scripts/content-lint.mjs` — the publishing gate, which parses every +// `.mdx` under `content/` and reports what it finds against its own file +// list. +// * `scripts/lib/post-dates.mjs` — the blog's URL -> `lastmod` map, read by +// `astro.config.mjs` (before Astro exists, so the content collection API is +// not available) and by `scripts/seo-smoke.mjs`. +// +// The copies were identical, which is exactly why they were dangerous: the +// frontmatter fence is the kind of parsing detail where a divergence is silent. +// One script starts accepting a file the other rejects, and no gate can see the +// disagreement — both are green about different trees. +// +// ─── Why this reports instead of throwing ────────────────────────────────── +// +// The split is shared; the *wording* deliberately is not. `content-lint.mjs` +// collects a message per file into a report whose format four gates and a +// `--dist` mode read, so its strings are part of its output contract. +// `post-dates.mjs` throws instead, because a bad frontmatter there fails a build +// that cannot produce a correct sitemap. Neither wording can move to the other. +// +// So this helper answers *what* went wrong and never throws, and each caller +// keeps saying it its own way. That is the split that can be shared without +// making one caller's error text a hidden dependency of the other's. +// +// ─── What it deliberately does not do ────────────────────────────────────── +// +// No YAML parsing, no trimming, no normalization — it returns the two raw +// slices and nothing else. `content-lint.mjs` trims the body it renders word +// counts from; `post-dates.mjs` hands `raw` to `js-yaml` and rejects a +// non-mapping result. Those are caller policies, and folding either one in here +// would change the other caller's behaviour. +// +// Two other scripts split a frontmatter fence with a *different*, deliberately +// weaker parser and are NOT consolidated here — see the note at the bottom of +// this file. + +/** + * @typedef {object} FrontmatterSplit + * @property {true} ok + * @property {string} raw The YAML between the fences, fences excluded. + * @property {string} body Everything after the closing fence, verbatim. + * + * @typedef {object} FrontmatterFailure + * @property {false} ok + * @property {'no-opening-fence' | 'no-closing-fence'} reason + */ + +/** + * Split an MDX source into its YAML frontmatter block and its body. + * + * A source must open with a `---` fence on its own first line and close with a + * line beginning `---`. Both conditions are strict on purpose: a file with a + * BOM or CRLF line endings does not open with `---\n` and is rejected rather + * than silently half-parsed. + * + * @param {string} source Raw `.mdx` file contents. + * @returns {FrontmatterSplit | FrontmatterFailure} + */ +export function splitFrontmatter(source) { + if (!source.startsWith('---\n')) { + return { ok: false, reason: 'no-opening-fence' }; + } + const end = source.indexOf('\n---', 4); + if (end === -1) { + return { ok: false, reason: 'no-closing-fence' }; + } + return { ok: true, raw: source.slice(4, end), body: source.slice(end + 4) }; +} + +// ─── The two splitters that stayed where they are ────────────────────────── +// +// `scripts/gen-zh-hant.mjs` and `scripts/lib/wechat-html.mjs` also split a +// frontmatter fence, and both do it differently on purpose: +// +// * both match `/^---\n[\s\S]*?\n---\n/` and fall back to "no frontmatter, +// all body" instead of failing, because neither is a gate — the generator +// must still rewrite a body it cannot read a head from, and the WeChat +// exporter must still produce HTML; +// * `wechat-html.mjs` does not use `js-yaml` at all, reading single-line +// scalars with a regex because it only ever wants `title`/`description`/ +// `author`. +// +// Routing either through this helper would change behaviour — a tolerated file +// would start failing — which is a different change with its own acceptance, +// not a consolidation. They are recorded here so the next reader finds the +// difference documented instead of discovering it. diff --git a/scripts/lib/post-dates.mjs b/scripts/lib/post-dates.mjs index b833339..2892afc 100644 --- a/scripts/lib/post-dates.mjs +++ b/scripts/lib/post-dates.mjs @@ -26,6 +26,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; +import { splitFrontmatter } from './frontmatter.mjs'; /** Every `.mdx` under `dir`, recursively. */ function walkMdx(dir) { @@ -38,14 +39,24 @@ function walkMdx(dir) { return files; } -/** Parse the YAML frontmatter block of an MDX source. */ +/** + * Parse the YAML frontmatter block of an MDX source. + * + * The fence split comes from `./frontmatter.mjs`, shared with + * `scripts/content-lint.mjs` so the gate and this map cannot drift into + * accepting different files. The wording stays here: this reader throws where + * the gate collects, and both sets of strings are their own caller's. + */ function frontmatter(source, file) { - if (!source.startsWith('---\n')) { - throw new Error(`${file} does not start with YAML frontmatter`); + const split = splitFrontmatter(source); + if (!split.ok) { + throw new Error( + split.reason === 'no-opening-fence' + ? `${file} does not start with YAML frontmatter` + : `${file} has no closing frontmatter fence` + ); } - const end = source.indexOf('\n---', 4); - if (end === -1) throw new Error(`${file} has no closing frontmatter fence`); - const data = yaml.load(source.slice(4, end)); + const data = yaml.load(split.raw); if (!data || typeof data !== 'object') { throw new Error(`${file} has empty or non-object frontmatter`); }