From 4c081606a0ee605f436b62a32d9bf71d999aed10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 02:27:20 +0000 Subject: [PATCH 1/2] content-lint: derive the topic and audience slug sets from the taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/content-lint.mjs` restated `src/lib/term-data.ts`'s topic and role slugs as two literal Sets. They agreed, but they were two producers of one fact, and the drift they allowed is one-directional and lands on an author: add a term to the taxonomy, use it in a post, and this script rejects the post with `Invalid topic: …` while `astro check` and `astro build` accept it, because the Zod enums in `src/content.config.ts` derive from the taxonomy and these Sets did not. The message names the post; the file to edit was here. The literals existed because the script is plain Node and could not import `src/lib/terms.ts` (it value-imports extensionless `./i18n` and `./zhconvert`, which only Vite resolves). That constraint is gone: the taxonomy data now lives in `src/lib/term-data.ts`, which imports nothing, and this script already loaded it to resolve topic-hub links. So load it once, at module scope, and derive both Sets from `RAW_TERMS` — deleted, not kept beside the derivation as a cross-check, which would be the same duplication in a different hat. `readTermHubs` now consumes the same read instead of importing the module a second time, and the module-load failure message names both consumers. Error wording is unchanged: `Invalid topic: …` and `Invalid audience: …` are part of this script's output contract. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- scripts/content-lint.mjs | 81 +++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index 33dfb88..41493f7 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -22,16 +22,25 @@ const today = new Date(); today.setHours(23, 59, 59, 999); const VALID_STATUS = new Set(['published', 'archived']); -const VALID_TOPIC = new Set([ - 'ai-agents', - 'app-building', - 'integration-data', - 'automation', - 'modernization', - 'governance', - 'customer-stories', -]); -const VALID_AUDIENCE = new Set(['business', 'it', 'developer', 'general']); + +// The frontmatter taxonomy, derived from the module that declares it instead of +// restated here. `src/lib/term-data.ts` holds the same rows `src/content.config.ts` +// turns into its Zod enums (through `slugsByGroup` in `src/lib/terms.ts`), so one +// edit to the taxonomy moves this gate and the build together. +// +// A literal copy here was never a second opinion, it was a second producer of one +// fact — and it drifted in the direction an author feels: add a term, use it in a +// post, and this script rejected the post that `astro check` and `astro build` +// both accept, naming the post while the file to edit was this one. +// +// The frontmatter field and the taxonomy group differ in name for the audience +// axis: `audience` holds a `role` slug. `src/content.config.ts` maps it the same +// way (`audience: z.enum(ROLE)`). +const { RAW_TERMS, termSlugPath } = await readTermData(); +const slugsInGroup = (group) => + new Set(RAW_TERMS.filter((term) => term.group === group).map((term) => term.slug)); +const VALID_TOPIC = slugsInGroup('topic'); +const VALID_AUDIENCE = slugsInGroup('role'); const PLACEHOLDERS = [ { label: 'Write your article here', pattern: /write your article here/i }, { label: 'your-domain.com', pattern: /your-domain\.com/i }, @@ -202,30 +211,23 @@ async function readClusterSlugs() { } /** - * Topic hubs — `//blog/topics//`, one page per term the site - * has content for. Two separate facts decide whether such a URL exists, so the - * registry carries both: + * The taxonomy itself, read once from the module that declares it. Two things in + * this script need it — the frontmatter term checks above and the topic-hub + * registry below — and reading it once is the point: a second reader is a second + * producer of the same fact, which is the drift this gate exists to catch. * - * * the term list and its nesting, from `src/lib/term-data.ts`. This script - * imports the module and calls its own `termSlugPath`, so the gate and the - * route cannot disagree about where a hub lives — re-deriving the slug list - * by text-matching a TypeScript file is exactly the tolerant re-parse a gate - * must not do. That module deliberately has no value imports; see its header. - * * which terms have content in which locale. `getStaticPaths` emits a hub - * only for the terms `getAllUsedTerms(locale)` returns, and a topic hub also - * aggregates its children's articles (`articleHasTerm`, src/lib/posts.ts). - * - * Keyed on PUBLISHED posts whatever `--published` says: `astro build` exposes - * published posts only (`shouldExposePost`), so a link asks what production - * serves, not what `astro dev` additionally renders. + * The module is imported, never text-matched: re-deriving the slug list by + * scraping a TypeScript file is exactly the tolerant re-parse a gate must not do. + * `src/lib/term-data.ts` deliberately has no value imports so that plain Node can + * load it; see its header before adding one. */ -async function readTermHubs() { - let module; +async function readTermData() { try { - module = await import(pathToFileURL(TERMS_MODULE).href); + return await import(pathToFileURL(TERMS_MODULE).href); } catch (error) { console.error( `✗ content lint could not read the term list from src/lib/term-data.ts, so ` + + `topic / audience cannot be checked and ` + `//blog/topics// links cannot be resolved.\n` + ` This script imports that module directly, which needs Node type ` + `stripping (Node 22.18+) and a module with no value imports — an ` + @@ -234,8 +236,25 @@ async function readTermHubs() { ); exit(1); } - const { RAW_TERMS: terms, termSlugPath } = module; +} +/** + * Topic hubs — `//blog/topics//`, one page per term the site + * has content for. Two separate facts decide whether such a URL exists, so the + * registry carries both: + * + * * the term list and its nesting, from `src/lib/term-data.ts` (`readTermData` + * above). This script calls that module's own `termSlugPath`, so the gate and + * the route cannot disagree about where a hub lives. + * * which terms have content in which locale. `getStaticPaths` emits a hub + * only for the terms `getAllUsedTerms(locale)` returns, and a topic hub also + * aggregates its children's articles (`articleHasTerm`, src/lib/posts.ts). + * + * Keyed on PUBLISHED posts whatever `--published` says: `astro build` exposes + * published posts only (`shouldExposePost`), so a link asks what production + * serves, not what `astro dev` additionally renders. + */ +async function readTermHubs() { const usedByLocale = new Map(); for (const file of await walk(BLOG)) { let data; @@ -259,14 +278,14 @@ async function readTermHubs() { } const childSlugs = new Map(); - for (const term of terms) { + for (const term of RAW_TERMS) { if (term.group !== 'topic' || !term.parent) continue; childSlugs.set(term.parent, [...(childSlugs.get(term.parent) ?? []), term.slug]); } const byPath = new Map(); // hub path -> locales the site builds it in const pathBySlug = new Map(); // term slug -> its one canonical hub path - for (const term of terms) { + for (const term of RAW_TERMS) { const owned = [term.slug, ...(childSlugs.get(term.slug) ?? [])]; const locales = new Set(); for (const [locale, used] of usedByLocale) { From 14a1ed137ecc153cff63255d8c20f6c1623849b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 02:28:10 +0000 Subject: [PATCH 2/2] content-lint: check `solutions` and `industries` against the same taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script validated `topic` and `audience` and ignored the other two term fields entirely, so an author could write any string into `solutions` or `industries` and this gate stayed green — the same defect as checking against a stale list, only further from being noticed, since the first objection came from `astro build`. Now that the taxonomy is read here rather than restated, covering all four groups is the same derivation applied twice more. This is an added check, not part of the de-duplication, which is why it is its own commit. It turns no existing post red: all 335 published files pass, as they must — `src/content.config.ts` has always enforced these two fields via `z.array(z.enum(...))`, just later and less legibly than here. A non-list value is an error too, mirroring the schema: `.default([])` there substitutes for an absent field only, so `solutions:` with nothing after it (null) fails the build. Skipping it here would recreate the disagreement this card exists to remove, in the opposite direction. Wording follows the existing pair: `Invalid solution: …` / `Invalid industry: …`, naming the offending entry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- scripts/content-lint.mjs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index 41493f7..c36c85d 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -41,6 +41,19 @@ const slugsInGroup = (group) => new Set(RAW_TERMS.filter((term) => term.group === group).map((term) => term.slug)); const VALID_TOPIC = slugsInGroup('topic'); const VALID_AUDIENCE = slugsInGroup('role'); +const VALID_SOLUTION = slugsInGroup('solution'); +const VALID_INDUSTRY = slugsInGroup('industry'); + +// `topic` and `audience` carry one slug; `solutions` and `industries` carry a +// list of them (`z.array(z.enum(...)).default([])` in `src/content.config.ts`). +// Only an absent field defaults to the empty list there, so anything else that +// is not a list — `solutions:` with nothing after it parses as null — is an +// error here too, which is the point: this gate and the build should reach the +// same verdict on the same file. +const LIST_TERM_FIELDS = [ + { field: 'solutions', noun: 'solution', valid: VALID_SOLUTION }, + { field: 'industries', noun: 'industry', valid: VALID_INDUSTRY }, +]; const PLACEHOLDERS = [ { label: 'Write your article here', pattern: /write your article here/i }, { label: 'your-domain.com', pattern: /your-domain\.com/i }, @@ -227,7 +240,7 @@ async function readTermData() { } catch (error) { console.error( `✗ content lint could not read the term list from src/lib/term-data.ts, so ` + - `topic / audience cannot be checked and ` + + `topic / audience / solutions / industries cannot be checked and ` + `//blog/topics// links cannot be resolved.\n` + ` This script imports that module directly, which needs Node type ` + `stripping (Node 22.18+) and a module with no value imports — an ` + @@ -592,6 +605,24 @@ for (const file of files) { addIssue(issues, rel, data, 'error', `Invalid audience: ${data.audience}`); } + // A frontmatter group nobody checks is the same defect as one checked against + // a stale list, only further from being noticed: until this ran, an author + // could put any string in `solutions` or `industries` and every gate this + // script owns stayed green, leaving `astro build` to be the first to object. + for (const { field, noun, valid } of LIST_TERM_FIELDS) { + const value = data[field]; + if (value === undefined) continue; + if (!Array.isArray(value)) { + addIssue(issues, rel, data, 'error', `${field} must be a list of terms, got: ${value}`); + continue; + } + for (const slug of value) { + if (!valid.has(slug)) { + addIssue(issues, rel, data, 'error', `Invalid ${noun}: ${slug}`); + } + } + } + if (typeof data.title === 'string') { if (data.title.length > 95) { addIssue(issues, rel, data, 'warn', `Title is long (${data.title.length} chars)`);