Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions scripts/content-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(),
};
}

Expand Down
90 changes: 90 additions & 0 deletions scripts/lib/frontmatter.mjs
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 17 additions & 6 deletions scripts/lib/post-dates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`);
}
Expand Down
Loading