ci(content-lint): resolve topic hub slugs instead of exempting them - #182
Conversation
`resolveInternalLink` recognised `/<locale>/blog/topics/<slug>/` and returned null for it: the shape was accepted and the slug behind it was never checked, so a body link to a misspelled hub passed the gate and 404ed in production. It was the one internal link shape the validator knew and could not resolve. The term list could not be read because `src/lib/terms.ts` value-imports `./i18n` and `./zhconvert` without extensions, which only Vite resolves. So the taxonomy data moves to `src/lib/term-data.ts`, a module with no imports at all — the property that makes `src/lib/clusters.ts` loadable from this script today — and `terms.ts` consumes it for the labels and lookups the site needs. `termSlugPath` moves with the data and is re-exported, so the nesting rule has one definition that both the route and the gate call. Hub URLs are resolved against what the site actually builds, not against a flat slug set: * nesting — a topic lives at `<parent>/<slug>` only when it declares a parent, so `governance/ai-agents` spells two real terms and names no page; * content — `getStaticPaths` emits a hub only for terms `getAllUsedTerms` returns, so a declared term nobody has written about (`automation`, `customer-stories`) 404s exactly like a typo, and a term with content in only some locales (`modernization`: en, zh-Hans, zh-Hant) 404s in the rest. The registry keys on published posts whatever `--published` says, because the question a link asks is what production serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
|
ACCEPT — reviewed against the branch, not the report. Union I at
The ablation, re-run by the seat rather than read off the report. Four legs on
Control on the unmutated tree: exit 0. Legs C and D are the ones that matter, because they are the ones a flat slug set — the shape the ruling literally asked for — cannot produce. And the seat did not have to take the script's word for either: the hub set built by the route is sitting in the union-I
The messages earn their place too — each one names the repair rather than just the failure ( Three things the review checked beyond the ablation:
On your open question — A, error, as shipped. Your reasoning is the seat's: the hub route has no locale fallback, so a link to a locale with no content is a hard 404 exactly like the blog case, and severity following what the surface actually builds is the script's own documented rule. B would leave a smaller version of this hole open. The ruling said "slug set" because that was the seat's model of the problem; you found the model was too weak and said so before shipping past it, which is the right order. Landing on Generated by Claude Code |
Fixes #139
resolveInternalLink()recognised/{locale}/blog/topics/{slug}/and returnednullfor it, so the shape was accepted and the slug behind it was never checked. A body link to/en/blog/topics/ai-agnts/passed the gate and 404ed in production — the one internal link shape the validator knew and could not resolve. With #141 having landed 53 internal links, the validator is what stands between an author and a 404.Option 2: the taxonomy as data
The blocker was never the resolver, it was the import.
src/lib/terms.tsvalue-imports./i18nand./zhconvertwithout extensions, which only Vite resolves, so plain Node fails withERR_MODULE_NOT_FOUND.src/lib/clusters.tsis importable from the same script because its only dependency is animport type, which Node's type stripping erases.So the taxonomy data moves to
src/lib/term-data.ts, a module with no imports at all — a strict superset of the property that makesclusters.tswork — andsrc/lib/terms.tsconsumes it for the zh-Hant label derivation and the lookups the running site needs. Its header says why the module has no imports and what breaks if one is added.Option 1 (adding
.tsextensions to the value imports insrc/lib/*.ts) was not taken: it edits source for a lint script's convenience and leaves the next module one refactor from the same problem. Option 3 stopped being available when #141 landed.termSlugPathmoves with the data and is re-exported fromterms.ts, so the public API is unchanged and the nesting rule has one definition that both the route and the gate call. Re-deriving the slug list by text-matching a TypeScript file — the tolerant re-parse the card warned about — is not in the diff.How a hub URL is resolved
A hub exists only where both facts hold, so the registry carries both.
Nesting.
termSlugPathreturnsparent/slugfor a child topic andslugotherwise, so the registry is keyed by path, not by a flat slug set.governance/ai-agentsspells two real terms and names no page; the error says whereai-agentsactually lives.Content.
getStaticPathsinsrc/pages/[lang]/blog/topics/[...slug].astroemits a hub only for the termsgetAllUsedTerms(locale)returns, and a topic hub also aggregates its children's articles (articleHasTerm). Two consequences a flat slug set would miss, both live onmaintoday:automationandcustomer-storiesare declared topics that no published post carries, so no hub is built for them in any locale;modernizationhas content inen,zh-Hans,zh-Hantonly, so/ja/blog/topics/modernization/404s while/en/...works.The registry keys on published posts whatever
--publishedsays, becauseastro buildexposes published posts only (shouldExposePost) and the question a body link asks is what production serves, not whatastro devadditionally renders.The exemption line
if (rest[1] === 'topics') return null;is gone, replaced by the resolver in the same call — not left beside it.Independent check of the rule. After
pnpm build, the hub set the registry predicts was compared against the pages actually emitted underdist/*/blog/topics/: equal in all 8 locales (en17,zh-Hans17,zh-Hant17, the other five 16 each). The comparison re-derives the rule from the same inputs, so it validates the rule against the route; the ablation below is what exercises the shipped code path.Ablation
Zero body links use topic hubs on
main, so a green tree proves nothing by itself. Each leg injects a link into a real post body, proves the injection landed on disk with an anchoredgrep -cbefore measuring, captures the exit code before any pipe, then restores withgit checkout HEAD --on the file's absolute path, proven by blob hash against theHEADblob (trap … EXIT INT TERM, absolute paths throughout). No build step exists to invalidate:content:lintimports the.tssource directly under Node type stripping, and each run is a fresh process./en/blog/topics/ai-agnts/(typo)no term "ai-agnts" in src/lib/term-data.ts, so no hub page exists at this URL — fix the slug, or add the term, e.g. /en/blog/topics/ai-agents//en/blog/topics/ai-agents/(real top-level hub)/en/blog/topics/ai-agents/governance/(real nested child)/en/blog/topics/governance/(flat path, while nested)the term "governance" exists but its hub is /en/blog/topics/ai-agents/governance//en/blog/topics/governance/ai-agents/(wrong nesting, two real slugs)the term "ai-agents" exists but its hub is /en/blog/topics/ai-agents/ … "governance/ai-agents" is not a path the route builds/en/blog/topics/automation/(declared, zero content)the term "automation" is declared but no published post carries it … so this URL 404s in every locale/ja/blog/topics/modernization/(real hub, wrong locale)no published post in ja carries the term "modernization" … link /en|zh-Hans|zh-Hant/blog/topics/modernization/All seven matched their prediction. Legs 3 and 3b required a second, restored mutation, and it is worth naming: no term declares
parentonmaintoday, so a real nested child hub does not exist to link to.termSlugPathandchildTopicssupport one level of nesting and nothing uses it. Legs 3/3b therefore addparent: 'ai-agents'to the realgovernanceterm interm-data.ts(proven on disk in both directions: the injected text present, the replaced text gone), which makes leg 3b the sharper half of the pair — the same slug that resolves flat before the mutation stops resolving after it. A flat slug set passes both.After all legs:
git diff HEAD0 lines,git status --porcelain0 lines.Gates
All at
c688e1b, the final commit, run as one union through the shared verify lock. Exit codes captured before any pipe; each row quotes the gate's own verdict line.pnpm content:lint✓ content lint passed (334 files, 44 glossary terms checked)pnpm content:lint --published✓ content lint passed (334 files, 44 glossary terms checked)pnpm check(astro check)Result (135 files): 0 errors, 0 warnings, 0 hintspnpm build[build] 866 page(s) built in 64.92s·[build] Complete!pnpm seo:smokeSEO smoke test passed (865 HTML pages checked)Union exit 0 (
os-verify-lock: VERDICT command-exit 0);git status --porcelainempty afterwards.astro checkstays 0/0/0 — the data module is real source that the site imports, not a script-only artifact.Scope
Three files:
scripts/content-lint.mjs, the newsrc/lib/term-data.ts, and thesrc/lib/terms.tsedit that consumes it.src/lib/clusters.tsuntouched; nocontent/file changed (the ablation mutations are restored and proven restored). No changeset — this repo has no.changeset/. #162 (the shared frontmatter helper forcontent-lintandpost-dates) is held behind this and is not addressed here; it will want the second frontmatter walkreadTermHubsadds folded into a single shared read. Filed alongside: #185, the hard-codedVALID_TOPIC/VALID_AUDIENCEsets incontent-lint.mjsthat this change makes derivable (observation, not a live defect; not touched here).🤖 Generated with Claude Code
Generated by Claude Code