Skip to content

scripts: split the MDX frontmatter fence in one place - #207

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-162-shared-frontmatter-helper
Sep 3, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-162-shared-frontmatter-helper

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #162

scripts/content-lint.mjs and scripts/lib/post-dates.mjs each carried their own copy of the MDX frontmatter fence split — startsWith('---\n'), indexOf('\n---', 4), slice. The copies were byte-identical, which is exactly 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.

$ grep -ro "indexOf('\n---'" scripts/ | wc -l
1
$ grep -rn "indexOf('\n---'" scripts/
scripts/lib/frontmatter.mjs:67:  const end = source.indexOf('\n---', 4);

The shape: the helper reports, the caller says

The helper never throws. It returns { ok: true, raw, body } or { ok: false, reason: 'no-opening-fence' | 'no-closing-fence' }, and each caller maps the reason to its own wording, unchanged:

  • content-lint.mjs collects a message per file into a report four gates and a --dist mode read, so its strings are part of its output contract. Its local splitFrontmatter(source, file) keeps the same signature and the same two throw new Error(...) texts, so both call sites are untouched.
  • post-dates.mjs throws where content-lint collects, because bad frontmatter there fails a build that cannot produce a correct sitemap. Its third message — has empty or non-object frontmatter — stays in post-dates.mjs, where it belongs: it is a YAML policy, not a fence fact.

No YAML parsing, trimming or normalization moved into the helper. content-lint.mjs still trims the body it counts words from; post-dates.mjs still rejects a non-mapping YAML result. Folding either in would have changed the other caller.

Acceptance: behavioural equivalence, measured

A green tree proves nothing here — the tree was green before. The acceptance is byte-identical output from every caller on pathological input, at main and at head.

The main column was produced by running the main scripts, not reconstructed: a second worktree detached at origin/main (8a36292), git status --porcelain empty. The head column ran at 4e50717. Same fixtures, same tree, same machine, same minute.

Callers driven, all three: content-lint.mjs, content-lint.mjs --published, and readPostLastmods() from post-dates.mjs (the sitemap lastmod map, read by astro.config.mjs and scripts/seo-smoke.mjs). --dist is not in the table because it exits at content-lint.mjs:230, before any frontmatter is parsed — it does not exercise this parser at all. It is green as a gate below.

# pathological input content-lint --published post-dates main vs head
01 no opening fence 1 · does not start with YAML frontmatter 1 · same 1 · throws same identical
02 opening fence, no closing 1 · has no closing frontmatter fence 1 · same 1 · throws same identical
03 empty frontmatter (---\n---\n) 1 · has no closing frontmatter fence 1 · same 1 · throws same identical
04 --- inside the YAML body 1 · unexpected end of the stream within a double quoted scalar (2:1) 1 · same 1 · throws identical
05 --- inside a body code fence 1 · fixture's own content issues only — fence split correct 1 · same 0 · post parsed, 336 entries identical
06 CRLF line endings 1 · does not start with YAML frontmatter 1 · same 1 · throws same identical
07 UTF-8 BOM before the fence 1 · does not start with YAML frontmatter 1 · same 1 · throws same identical
08 valid YAML, bare string 1 · 6× Missing required frontmatter field 0 · ✓ content lint passed 1 · has empty or non-object frontmatter identical
09 valid YAML, a list 1 · 6× Missing required frontmatter field 0 · ✓ content lint passed 1 · has empty or non-object frontmatter identical

diff -r over the two columns — 9 cases × 3 legs, full transcripts including the entire 336-entry lastmod map — exits 0. The only normalization is replacing each worktree's own absolute path with <ROOT>; the two roots genuinely differ on disk and nothing else was touched.

Proving the harness is not blind

Nine identical rows mean nothing if the harness always prints the same thing. Two mutations of the one shared split, each confirmed on disk by anchored grep on both the removed and the injected text before any measurement:

leg mutation anchored counts (from → to) mutated blob rows that went red
A startsWith('---\n') → startsWith('---') 1→0 / 0→1 b2d682c 1 — case 06 (CRLF) starts parsing: lint goes 1→ different errors, post-dates 1→0 and the sitemap map grows to 336 entries
B indexOf('\n---', 4) → lastIndexOf('\n---') 1→0 / 0→1 ccbb33c all 9

Both restored with git checkout HEAD -- <path> from a trap ... EXIT INT TERM on absolute paths, restore proven by blob hash: 211d1e4… == HEAD:scripts/lib/frontmatter.mjs, git diff HEAD empty, git status --porcelain empty.

One leg is worth recording: leg B's first attempt reported from=0 to=0 and the harness refused to measure. Bash had interpolated the replacement into perl source, where \n was parsed as a newline escape, so the mutation landed as something else entirely. Without the anchored check on the injected text that run would have produced a table and looked like a measurement. Both sides now go through the environment.

Gates

All at 4e50717, through the shared verify lock, each exit captured before any pipe, verdicts as the gates printed them:

gate exit verdict line
pnpm content:lint 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm content:lint --published 0 ✓ content lint passed (335 files, 44 glossary terms checked)
pnpm check 0 Result (136 files): - 0 errors - 0 warnings - 0 hints
pnpm build 0 [build] 867 page(s) built in 59.31s → ✓ content lint --dist passed (474 built blog pages checked)
pnpm seo:smoke 0 SEO smoke test passed (866 HTML pages checked)

git status --porcelain empty after the gate run. No changeset: this repo has no .changeset/ and no changesets dependency, and nothing here is user-visible.

The sweep, and what it left alone

scripts/new-post.mjs has no copy — it only writes a frontmatter template. scripts/to-wechat.mjs has no copy — it delegates to scripts/lib/wechat-html.mjs. The sweep did find two more splitters, both deliberately weaker and both left where they are:

Neither is a gate, so both tolerate what the lint copy rejects; routing either through the helper would turn a tolerated file into a failing one, which is a behaviour change with its own acceptance. The difference is documented at the bottom of scripts/lib/frontmatter.mjs and filed as #205 so the decision is on record too.

The equivalence table also surfaced a pre-existing divergence, reproducing identically at 8a36292 and at head, outside the fence and so untouched here: rows 08/09 show content:lint --published printing ✓ content lint passed on a non-mapping frontmatter that then throws at astro.config.mjs load. Filed as #206 rather than fixed — no behaviour change ships under this card.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr


Generated by Claude Code

`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — and this PR taught the seat something about its own review standard.

Union with current main (c6ab426 + this branch), all five gates through the shared lock: content:lint 0, content:lint --published 0, check 0, build 0 (867 page(s) built · ✓ content lint --dist passed (474 built blog pages checked)), seo:smoke 0, tree clean.

The invariant holds exactly. grep -rn "indexOf('\n---'" scripts/ → 1, in scripts/lib/frontmatter.mjs. No copy left behind, no wrapper re-implementing it.

The seat ran its own equivalence check before reading yours — same eight pathological inputs, driven through the real caller with scripts/ swapped between origin/main and the branch head:

DIFF (empty = behaviourally identical)
  IDENTICAL on all 8 cases

Agreeing independently is what a consolidation card needs, and it agrees.

Now the part that is better than what I asked for.

I set the acceptance as "nine identical rows." You pointed out that nine identical rows mean nothing if the harness always prints the same thing, and then proved the harness is not blind: mutate the one shared split and show which rows go red.

leg mutation rows red
A startsWith('---\n') → startsWith('---') 1 — case 06, CRLF, starts parsing
B indexOf('\n---', 4) → lastIndexOf('\n---') all 9

Leg A turning exactly one row red — and the right one — is a sharper result than leg B, because a mutation that reddens everything could still be consistent with a harness that reddens on anything. My ruling asked for equivalence and stopped there; equivalence without a sensitivity check is a green light with no bulb behind it. I will ask for this on every consolidation from now on.

And leg B's first attempt is the most valuable paragraph in the PR. It reported from=0 to=0 and the harness refused to measure, because bash had interpolated the replacement into perl source where \n became a newline escape and the mutation landed as something else. Without the anchored check on the injected text — not just the removed text — that run would have produced a full nine-row table and looked exactly like a successful measurement. That is the precise mechanism by which an ablation lies while appearing rigorous, and catching it in your own harness is worth more than the refactor.

Three other things done right:

  • The main column was produced by running the main scripts, in a second worktree detached at 8a36292, same fixtures and same minute — not reconstructed from memory of what they used to print.
  • --dist is correctly absent from the table, because it exits at content-lint.mjs:230 before any frontmatter is parsed. Saying why a leg is excluded is what stops the exclusion looking like an omission.
  • The helper reports, the caller says. { ok, reason } rather than throwing, with each caller keeping its own wording — content-lint collecting because four gates read its report, post-dates throwing because bad frontmatter there fails a build that cannot produce a correct sitemap. And leaving has empty or non-object frontmatter in post-dates.mjs because it is a YAML policy, not a fence fact, is the right line to draw.

On the two you left alone (#205) and the one you found (#206): both dispositions are correct. gen-zh-hant.mjs and wechat-html.mjs are deliberately weaker — tolerant fallbacks, no js-yaml — and neither is a gate, so routing them through the helper would turn a tolerated file into a failing one. That is a behaviour change with its own acceptance, and this card said none ships. #206 — --published passing on a non-mapping frontmatter that then throws at astro.config.mjs load — reproduces identically at 8a36292, so it is pre-existing and correctly filed rather than fixed.

One small thing for the record, found in the seat's own run and not worth a card: a BOM-prefixed file is rejected as "does not start with YAML frontmatter" on both trees. Latent, inert (no file in the corpus carries a BOM), and unchanged by this PR.

Landing on main. This is the last card of the round.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 05:06
@hotlong
hotlong merged commit 7d41e09 into main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MDX frontmatter is now split and parsed in two scripts with no shared helper

2 participants