Skip to content

ci(content-lint): resolve topic hub slugs instead of exempting them - #182

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-139-topic-hub-slug-resolution
Sep 3, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-139-topic-hub-slug-resolution

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #139

resolveInternalLink() recognised /{locale}/blog/topics/{slug}/ and returned null for 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.ts value-imports ./i18n and ./zhconvert without extensions, which only Vite resolves, so plain Node fails with ERR_MODULE_NOT_FOUND. src/lib/clusters.ts is importable from the same script because its only dependency is an import 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 makes clusters.ts work — and src/lib/terms.ts consumes 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 .ts extensions to the value imports in src/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.

termSlugPath moves with the data and is re-exported from terms.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. termSlugPath returns parent/slug for a child topic and slug otherwise, so the registry is keyed by path, not by a flat slug set. governance/ai-agents spells two real terms and names no page; the error says where ai-agents actually lives.

Content. getStaticPaths in src/pages/[lang]/blog/topics/[...slug].astro emits a hub only for the terms getAllUsedTerms(locale) returns, and a topic hub also aggregates its children's articles (articleHasTerm). Two consequences a flat slug set would miss, both live on main today:

  • automation and customer-stories are declared topics that no published post carries, so no hub is built for them in any locale;
  • modernization has content in en, zh-Hans, zh-Hant only, so /ja/blog/topics/modernization/ 404s while /en/... works.

The registry keys on published posts whatever --published says, because astro build exposes published posts only (shouldExposePost) and the question a body link asks is what production serves, not what astro dev additionally 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 under dist/*/blog/topics/: equal in all 8 locales (en 17, zh-Hans 17, zh-Hant 17, 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 anchored grep -c before measuring, captures the exit code before any pipe, then restores with git checkout HEAD -- on the file's absolute path, proven by blob hash against the HEAD blob (trap … EXIT INT TERM, absolute paths throughout). No build step exists to invalidate: content:lint imports the .ts source directly under Node type stripping, and each run is a fresh process.

# Injected link Predicted Observed
1 /en/blog/topics/ai-agnts/ (typo) fail, names the slug exit 1 — 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/
2 /en/blog/topics/ai-agents/ (real top-level hub) pass exit 0
3 /en/blog/topics/ai-agents/governance/ (real nested child) pass exit 0
3b /en/blog/topics/governance/ (flat path, while nested) fail, points at the nested path exit 1 — the term "governance" exists but its hub is /en/blog/topics/ai-agents/governance/
4 /en/blog/topics/governance/ai-agents/ (wrong nesting, two real slugs) fail exit 1 — the term "ai-agents" exists but its hub is /en/blog/topics/ai-agents/ … "governance/ai-agents" is not a path the route builds
5 /en/blog/topics/automation/ (declared, zero content) fail exit 1 — the term "automation" is declared but no published post carries it … so this URL 404s in every locale
6 /ja/blog/topics/modernization/ (real hub, wrong locale) fail, lists the locales exit 1 — 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 parent on main today, so a real nested child hub does not exist to link to. termSlugPath and childTopics support one level of nesting and nothing uses it. Legs 3/3b therefore add parent: 'ai-agents' to the real governance term in term-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 HEAD 0 lines, git status --porcelain 0 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.

Gate 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 hints
pnpm build [build] 866 page(s) built in 64.92s · [build] Complete!
pnpm seo:smoke SEO smoke test passed (865 HTML pages checked)

Union exit 0 (os-verify-lock: VERDICT command-exit 0); git status --porcelain empty afterwards. astro check stays 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 new src/lib/term-data.ts, and the src/lib/terms.ts edit that consumes it. src/lib/clusters.ts untouched; no content/ file changed (the ablation mutations are restored and proven restored). No changeset — this repo has no .changeset/. #162 (the shared frontmatter helper for content-lint and post-dates) is held behind this and is not addressed here; it will want the second frontmatter walk readTermHubs adds folded into a single shared read. Filed alongside: #185, the hard-coded VALID_TOPIC / VALID_AUDIENCE sets in content-lint.mjs that this change makes derivable (observation, not a live defect; not touched here).

🤖 Generated with Claude Code

Generated by Claude Code

`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

hotlong commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — reviewed against the branch, not the report.

Union I at 43ed2fd (main 3d22291 + #182 + #186 + #187 + #188), all five gates green through the shared lock:

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 - 0 warnings - 0 hints
pnpm build 0 [build] 867 page(s) built in 41.19s · [build] Complete!
pnpm seo:smoke 0 SEO smoke test passed (866 HTML pages checked)

git status --porcelain 0 lines afterwards.

The ablation, re-run by the seat rather than read off the report. Four legs on content/blog/ai-expense-audit/index.mdx, each mutation confirmed on disk by anchored grep -c before the measurement, restore proven by blob hash (78c00b9… == HEAD), trap on absolute paths, tree 0 lines afterwards:

Leg Injected Expected Got
A typo slug /en/blog/topics/ai-agnts/ 1 1
B real hub /en/blog/topics/ai-agents/ 0 0
C declared term, zero posts /en/blog/topics/automation/ 1 1
D real hub, locale that does not build it /de/blog/topics/modernization/ 1 1

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 dist/, and it says exactly what the errors say —

en:      … governance hr integration-data it manufacturing modernization portals …
de:      … governance hr integration-data it manufacturing portals …        ← no modernization
zh-Hant: … governance hr integration-data it manufacturing modernization portals …

automation and customer-stories appear in no locale at all. So the registry is checked against the pages the route emits, from a different direction than the script derives them.

The messages earn their place too — each one names the repair rather than just the failure (fix the slug, or add the term, e.g. /en/blog/topics/ai-agents/; link /en|zh-Hans|zh-Hant/blog/topics/modernization/, or translate a post that carries the term).

Three things the review checked beyond the ablation:

  • The exemption was replaced, not shadowed. if (rest[1] === 'topics') return null; is gone from scripts/content-lint.mjs; resolution happens in the same call. That was the ruling's specific worry and it holds.
  • src/lib/term-data.ts has no imports at all, which is a strict superset of the import-type-only property that makes clusters.ts loadable from plain Node — so the uniform import surface is real, not incidental.
  • src/lib/clusters.ts untouched, as the claim required.

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 main.


Generated by Claude Code

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.

Topic hub slugs are the one internal link shape content-lint recognises but cannot resolve

2 participants