The finding
Discovered by the #162 behavioural-equivalence table, which fed pathological frontmatter through every reader. This is pre-existing — it reproduces identically at main (8a36292) and at the #162 head, so #162 neither introduced nor fixed it. #162 was a pure consolidation and was not allowed to change behaviour, so it is filed here instead.
A post whose frontmatter is valid YAML but not a mapping — a bare scalar or a list — is treated differently by the two readers, and the disagreement is after the fence split, so consolidating the fence did not touch it.
Reproduction
content/blog/<slug>/index.mdx:
---
just a bare scalar
---
Body.
At 8a36292, with that one file added:
$ pnpm content:lint --published
✓ content lint passed (335 files, 44 glossary terms checked) # exit 0
$ node -e "import('./astro.config.mjs')"
Error: <slug>/index.mdx has empty or non-object frontmatter # exit 1
A YAML list (- one\n- two) behaves the same way.
Why the two disagree
scripts/content-lint.mjs does data = yaml.load(raw) ?? {}. A bare string is truthy, so data becomes the string — every data[key] lookup is then undefined. In default mode that surfaces as six Missing required frontmatter field errors (so the file is caught). In --published mode the loop hits if (onlyPublished && data.status !== 'published') continue; first, undefined !== 'published', and the file is skipped entirely — the gate never looks at it.
scripts/lib/post-dates.mjs rejects it outright: if (!data || typeof data !== 'object') throw. astro.config.mjs calls readPostLastmods() at module scope to build the sitemap lastmod map, so the throw lands at config load.
Why it is worth a card
Nothing ships broken — pnpm build runs content:lint --published and then astro build, so the bad file does stop the build. But it stops it in the wrong place and with the wrong message:
- the gate whose job is to catch bad frontmatter prints
✓ content lint passed, which is a false statement about that file;
- the actual failure arrives from
astro.config.mjs during config load, before Astro reports any file context of its own, so an author sees a config-time stack rather than a lint error against their post;
- and the gate's own contract is "a run that measured nothing is a failure, not a pass" (the
--dist mode says so in a comment). --published silently measuring nothing for this file is the same shape.
Options
- Reject a non-mapping frontmatter in
content-lint.mjs, matching post-dates.mjs — one added check before the --published skip, wording this script's own. Smallest fix; makes the gate the thing that reports it.
- Move the mapping check into
scripts/lib/frontmatter.mjs as a third failure reason, so both readers get it from one place. Larger: the helper currently does no YAML at all by design, and content-lint.mjs would need its own wording for the new reason.
- Leave it — the build does fail, just later and less legibly.
Option 1 looks right, and it needs its own equivalence run (the #162 harness covers exactly these cases).
Found while implementing #162.
Generated by Claude Code
The finding
Discovered by the #162 behavioural-equivalence table, which fed pathological frontmatter through every reader. This is pre-existing — it reproduces identically at
main(8a36292) and at the #162 head, so #162 neither introduced nor fixed it. #162 was a pure consolidation and was not allowed to change behaviour, so it is filed here instead.A post whose frontmatter is valid YAML but not a mapping — a bare scalar or a list — is treated differently by the two readers, and the disagreement is after the fence split, so consolidating the fence did not touch it.
Reproduction
content/blog/<slug>/index.mdx:At
8a36292, with that one file added:A YAML list (
- one\n- two) behaves the same way.Why the two disagree
scripts/content-lint.mjsdoesdata = yaml.load(raw) ?? {}. A bare string is truthy, sodatabecomes the string — everydata[key]lookup is thenundefined. In default mode that surfaces as sixMissing required frontmatter fielderrors (so the file is caught). In--publishedmode the loop hitsif (onlyPublished && data.status !== 'published') continue;first,undefined !== 'published', and the file is skipped entirely — the gate never looks at it.scripts/lib/post-dates.mjsrejects it outright:if (!data || typeof data !== 'object') throw.astro.config.mjscallsreadPostLastmods()at module scope to build the sitemaplastmodmap, so the throw lands at config load.Why it is worth a card
Nothing ships broken —
pnpm buildrunscontent:lint --publishedand thenastro build, so the bad file does stop the build. But it stops it in the wrong place and with the wrong message:✓ content lint passed, which is a false statement about that file;astro.config.mjsduring config load, before Astro reports any file context of its own, so an author sees a config-time stack rather than a lint error against their post;--distmode says so in a comment).--publishedsilently measuring nothing for this file is the same shape.Options
content-lint.mjs, matchingpost-dates.mjs— one added check before the--publishedskip, wording this script's own. Smallest fix; makes the gate the thing that reports it.scripts/lib/frontmatter.mjsas a third failure reason, so both readers get it from one place. Larger: the helper currently does no YAML at all by design, andcontent-lint.mjswould need its own wording for the new reason.Option 1 looks right, and it needs its own equivalence run (the #162 harness covers exactly these cases).
Found while implementing #162.
Generated by Claude Code