Skip to content

Point generated zh-Hant body links at the Traditional locale - #187

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-142-zh-hant-link-locale
Sep 3, 2026
Merged

hotlong merged 1 commit into
mainfrom
claude/issue-142-zh-hant-link-locale

Conversation

@hotlong

@hotlong hotlong commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #142

OpenCC only rewrites Han characters, so an internal link authored in a Simplified post as [标题](/zh-Hans/blog/some-slug/) survived verbatim into index.zh-Hant.mdx and sent a Traditional reader to the Simplified companion post. The generator now rewrites the locale segment on its way out.

(Angle-bracket placeholders are spelled in caps throughout — GitHub's body sanitizer eats short bracketed fragments, including inside code spans, and ate a whole section of the first draft of this description.)

The rewrite rule

/zh-Hans/PATH becomes /zh-Hant/PATH on link targets in the body, and only where the Traditional target is provably built. A wrong-locale link is a defect; one the generator turned into a 404 would be worse, so an unprovable target is left alone and reported on stdout rather than rewritten.

Where the conditional is load-bearing. Two routes are emitted per-locale with no fallback, so only they can 404 after a rewrite:

Route Emitted for Handling
/[lang]/blog/SLUG/ only locales where the post has a file (src/pages/[lang]/blog/[...slug].astro) rewritten only if the Traditional file exists after this run — i.e. the Simplified source exists (this script writes it) or a hand-maintained copy is already there
/[lang]/blog/topics/SLUG/ only locales where a post actually uses the term never rewritten — resolving it needs the term extractor in src/lib/terms.ts, which this script does not import

Where no check is needed, so none is invented. Everything else under a routed locale builds for zh-Hant exactly when it builds for zh-Hans:

  • the locale home and the per-locale static pages are src/pages/[lang]/*, emitted for every entry of LOCALES in src/lib/i18n.ts — zh-Hant is one of them;
  • glossary terms and marketing pages derive zh-Hant from zh-Hans and fall back to English (src/glossary/registry.ts, src/content-pages/registry.ts), so the two locales resolve together or not at all. The one thing that can still be false — the term existing anywhere — is checked;
  • clusters are emitted for every locale unconditionally, but a bare /zh-Hans/SLUG/ is ambiguous between a cluster and a marketing page without importing src/lib/clusters.ts, so an unmatched single segment is reported rather than rewritten.

Where the rewrite does not apply

  • Fenced code blocks and inline code spans — masked out before any rewriting, using the same two patterns scripts/content-lint.mjs strips before it resolves links, so the generator and the validator agree on what counts as a link.
  • Frontmatter — excluded by construction; only the body is handed to the rewriter.
  • Slugs — never link targets, so a slug in frontmatter, in the URL itself, or in a relatedTerms list is never touched.
  • Images — an image target is skipped, via the same negative-lookbehind-on-bang exclusion the validator uses.
  • Other locales — only the /zh-Hans prefix is ever a candidate. The /en/blog/... links in this repo's translated posts point at English-only posts and are correct as written; they are untouched. A look-alike prefix such as /zh-Hansard/ is not matched either.
  • Hand-maintained Traditional files (those without the @generated marker) — still skipped entirely, unchanged behaviour.

Scoped-rewrite evidence

Two demonstrations on real content. Each mutates a Simplified source, proves the mutation actually reached disk before reading any result, runs the generator, then restores and proves the restore by blob hash — restoring with git checkout HEAD -- ABSOLUTE_PATH, never a bare checkout, and never trusting an exit code for it.

1 — a body link is rewritten, the identical string in code is not. Injected into content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hans.mdx:

body link rewritten to /zh-Hant/      : 1 (want 1)
inline code span left at /zh-Hans/    : 1 (want 1)
fenced code block left at /zh-Hans/   : 1 (want 1)
SCOPED-REWRITE DEMO: PASS

The generated Traditional output, all three cases side by side:

93:DEMO-LINK [示例](/zh-Hant/blog/vibe-coding-technical-debt-2026/)
95:DEMO-INLINE `/zh-Hans/blog/vibe-coding-technical-debt-2026/`
99:(fence opens here)
100:[示例](/zh-Hans/blog/vibe-coding-technical-debt-2026/)

2 — the conditional actually withholds a rewrite. A link to a slug with no post directory, and a topic hub:

⚠ zh-Hant: left 2 /zh-Hans/ link(s) as-is — no provable Traditional build, and a manufactured 404 is worse than a cross-locale link:
    content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx: /zh-Hans/blog/no-such-post-here/
    content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx: /zh-Hans/blog/topics/ai-agent/

93:DEMO-GHOST [幽靈](/zh-Hans/blog/no-such-post-here/)
94:DEMO-TOPIC [主題](/zh-Hans/blog/topics/ai-agent/)
95:DEMO-LIVE [真實](/zh-Hant/blog/vibe-coding-technical-debt-2026/)
CONDITIONAL DEMO: PASS

Both runs restored to byte-identical state — zh-Hans now 25dc4c30… vs HEAD 25dc4c30…, zh-Hant now f493f4b6… vs HEAD f493f4b6…, git status --porcelain: ''.

Pinned, not just demonstrated. This repo has no test runner, and the property worth keeping is a negative one — a link is rewritten, an identical string inside code is not — which a later edit to the patterns would break silently, in generated output nobody reads. So a 16-case fixture table runs on every pnpm dev and pnpm build against a stub registry, printing nothing unless a case fails. It earned its place immediately: it caught a bug in its own stub during development.

Before / after link counts

The issue measured 6 links in 3 files; main has moved since (#130's reverse-linking work), so the live figure at 458bd1b was 8 links across 5 files. All of them had Traditional targets — every one was a wrong-locale link, not a dead one.

$ grep -rn "zh-Hans/blog/" content/blog --include=index.zh-Hant.mdx | wc -l
before: 8      after: 0        (grep exits 1 — no matches)

$ grep -rn "/zh-Hans/" content/blog --include=index.zh-Hant.mdx
after: no matches at all
File links moved
ai-wrote-your-app-dare-to-merge 3
give-your-agent-rules-for-governable-apps 3
automation-cross-system-flows 1
enterprise-ontology-race-open-vs-closed 1
objectos-agent-permission-boundaries 1

The content diff is exactly the locale segment and nothing else — mechanically verified by mapping every removed line through s|/zh-Hans/|/zh-Hant/| and diffing against the added lines: identical, zero remaining difference. The other 41 regenerated Traditional files are byte-identical, which is the second half of the proof that the generator changed rather than the generated text.

Gates

All at head 04e3d56, run as one union through the shared verify lock, exit code 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 check Result (134 files): · 0 errors · 0 warnings · 0 hints
pnpm build ✓ zh-Hant: generated 46, kept 0 hand-maintained, 5 with body links pointed at /zh-Hant/ · [build] 866 page(s) built in 71.04s
pnpm seo:smoke SEO smoke test passed (865 HTML pages checked)
lock os-verify-lock: VERDICT command-exit 0 · held the lock 90s (1m30s) · waited 0s

git status --porcelain is empty after pnpm build — the committed Traditional files are exactly what the generator produces.

#137's link validator is on main, so a mistake here would have surfaced as a content:lint failure rather than silently; it stayed green.

Browser verification

Served the built dist/ and drove Chromium over the three affected Traditional pages at 1440×900 and 390×844. All six page loads returned 200, and every in-article internal link — the anchors inside article .prose with a root-relative href — was checked for both locale and reachability:

=== desktop 1440x900 — /zh-Hant/blog/ai-wrote-your-app-dare-to-merge/ → 200 ===
  OK   200 /zh-Hant/blog/vibe-coding-technical-debt-2026/  [Vibe Coding 技術債]
  OK   200 /zh-Hant/blog/why-ai-agent-pilots-fail-four-layers/  [AI Agent 試點失敗的四層原因]
  OK   200 /zh-Hant/blog/vibe-coding-technical-debt-2026/  [那篇]
…
=== BROWSER PASS PASS ===

14 click-target checks (7 links × 2 viewports), all /zh-Hant/, all 200. Screenshots scrolled to the rewritten links confirm they render as ordinary links in the Traditional paragraph, with the inline code alongside them (GET /api/refunds) still rendering as code, and no layout change at either width.

The language switcher's /zh-Hans/... alternate link is untouched — it lives outside article .prose, and pointing at the Simplified page is its entire job.

Out of scope

Per the seat ruling, the hand-written locales were checked but not edited. The check came back clean: all 20 non-self-locale links in ja/ko/de/es/fr posts are /en/blog/... links to three posts that exist only in English (enterprise-ontology-platform-comparison, ontology-vs-semantic-layer-vs-knowledge-graph, forward-deployed-engineer-trap). Linking to the only locale that is built is correct authoring, not the defect this card anticipated — useful input for #179, which is where mechanical detection belongs.

Filed while verifying: #184 — every zh-Hant page renders the Simplified-text cover and diagrams (46 covers, 21 body assets). Same defect class, different surface: nothing converts .svg text nodes.


Generated by Claude Code

OpenCC only rewrites Han characters, so an internal link authored in a
Simplified post as `[标题](/zh-Hans/blog/some-slug/)` survived verbatim into
index.zh-Hant.mdx and sent a Traditional reader to the Simplified companion
post -- invisible to every gate, since the generated file was byte-for-byte
what the generator produced.

The generator now rewrites `/zh-Hans/...` to `/zh-Hant/...` on link targets in
the body, but only where the Traditional target is provably built: blog posts
are emitted per-locale with no fallback, so the rewrite is conditional on the
Traditional file existing after the run, and topic hubs (whose locale set needs
the term extractor) are never rewritten. A wrong-locale link is a defect; one
the generator turned into a 404 would be worse, so unprovable targets are left
alone and reported.

Scoped, not global: fenced blocks and inline code spans are masked out using
the same two patterns scripts/content-lint.mjs strips before it resolves links,
frontmatter is excluded by construction, and slugs are never link targets. A
fixture table pins both halves -- a body link rewritten, the identical string
inside code left alone -- and runs on every dev/build, this repo having no test
runner.

8 links across 5 generated Traditional posts move to the right locale; the
other 41 regenerated files are byte-identical.

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, with the seat's own scoping probe.

Union I at 43ed2fd (main 3d22291 + #182 + #186 + #187 + #188) green on all five gates through the shared lock — content:lint 0, content:lint --published 0, check 0, build 0 ([build] 867 page(s) built), seo:smoke 0 (SEO smoke test passed (866 HTML pages checked)), git status --porcelain 0 lines after the build. That last one is the load-bearing check for a generator card: it proves the committed Traditional files are exactly what the generator emits, so you changed the generator and not the generated text. The build's own line reads

✓ zh-Hant: generated 46, kept 0 hand-maintained, 5 with body links pointed at /zh-Hant/

Independent target check. All eight rewritten links resolve to a Traditional file that exists — checked by walking the rewritten URLs back to disk rather than by trusting the gate:

objectos-agent-permission-boundaries      → ai-agent-business-data-security-boundaries  ✓
enterprise-ontology-race-open-vs-closed   → ai-ontology-open-protocol                   ✓
automation-cross-system-flows             → automation-trigger-model                    ✓
ai-wrote-your-app-dare-to-merge           → vibe-coding-technical-debt-2026 ✓ · why-ai-agent-pilots-fail-four-layers ✓
give-your-agent-rules-for-governable-apps → enterprise-ontology-race-open-vs-closed ✓ · mcp-governed-tool-layer ✓ · why-ai-agent-pilots-fail-four-layers ✓

grep -rn '/zh-Hans/' content/blog --include=index.zh-Hant.mdx → 0.

The scoping and conditional demos, re-run by the seat on real content. Six /zh-Hans/ occurrences injected into content/blog/ai-expense-audit/index.zh-Hans.mdx — one buildable body link, one inline code span, one fenced block, one link to a slug that does not exist, one topic hub, one image path — mutation confirmed on disk before measuring (3 marker lines, 6 URL lines), trap restore on absolute paths proven by blob hash on both files (2bd1b03…, 8be3836…), tree clean afterwards. Regenerated, the Traditional file reads:

正文連結:[已構建目標](/zh-Hant/blog/mcp-governed-tool-layer/)。      ← rewritten
行內程式碼:`/zh-Hans/blog/mcp-governed-tool-layer/` 不應被改寫。      ← left
不存在的目標:[未構建](/zh-Hans/blog/no-such-post-seatprobe/)。       ← withheld
話題頁:[話題](/zh-Hans/blog/topics/ai-agents/)。                    ← withheld
圖片:[x](/zh-Hans/img/seatprobe.png)                              ← untouched
```bash
curl https://www.objectos.ai/zh-Hans/blog/mcp-governed-tool-layer/   ← left

Six for six. And the generator does not do this silently:

⚠ zh-Hant: left 2 /zh-Hans/ link(s) as-is — no provable Traditional build,
  and a manufactured 404 is worse than a cross-locale link:
    …/index.zh-Hant.mdx: /zh-Hans/blog/no-such-post-seatprobe/
    …/index.zh-Hant.mdx: /zh-Hans/blog/topics/ai-agents/

That is ruling (1) working exactly as intended, and the reporting is what makes the withheld case reviewable instead of invisible. Note also that OpenCC converted the prose around the code span (行内代码 → 行內程式碼) while the span's Simplified path survived — the mask is on the link rewrite, not on the conversion, which is the correct boundary.

On the parts you declined to guess at. Not rewriting topic hubs — because their locale set lives in src/lib/terms.ts, which the generator cannot import — and reporting them instead is the right call, and it is now a smaller gap than it was an hour ago: #182 landed the taxonomy as an import-free data module, so a follow-up has a clean surface to reach for. Not inventing checks for routes that build for zh-Hant exactly when they build for zh-Hans is also right, and the comment says why per route rather than asserting it.

The 16-case fixture running inside the generator on every build is the right answer for a repo with no test runner, and it catching a bug in its own stub during development is the kind of evidence a fixture is supposed to produce.

Your finding that the hand-written locales carry no cross-locale defect — all 20 non-self-locale links are /en/blog/… to posts that exist only in English — is useful input for #179 and saves it a pass. Recorded.

Landing on main. #144 is unblocked.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 3, 2026 02:18
@hotlong
hotlong merged commit 435ff61 into main Sep 3, 2026
1 check passed
hotlong pushed a commit that referenced this pull request Sep 3, 2026
The generator converts zh-Hans to zh-Hant with OpenCC's `s2twp`. Two of that
preset's 603 phrase entries are wrong for this corpus, and one segmentation
accident pins a locative:

  权限 -> 許可權   a Microsoft-glossary rendering; 權限 is the ordinary term
  实例 -> 例項     not standard Taiwanese usage in any register
  X里            straddling STPhrases matches pin the locative 里 as 里

Keep the preset — its other 142 firing entries (資料, 程式, 物件, 介面,
伺服器, 軟體, 資訊, 快取, 使用者, 預設 …) are all correct, and plain `tw`
would lose every one of them. Instead put identity entries at the head of the
TWPhrases group, which disables exactly those two rules and nothing beneath
them; a genuine 许可权 in a source still converts to 許可權 on its own terms.

The locative is a different defect: STCharacters already reads a bare 里 as
裏/裡, and 541 of 553 come out right unaided. The twelve that do not are all a
two-character STPhrases entry (本里, 里根, 里拉, 道里, 里加, 里长, 数里)
matching across a word boundary. Those entries are correct in their own right,
so the repair is a whitelist applied after conversion, behind a listed word
only, guarded so 里程碑 is never touched.

Both are pinned in the generator's fixture, which runs on every dev and build,
so a preset upgrade cannot silently put 許可權 back in 435 places. A new
unclaimed 里 is reported rather than guessed at.

Regenerated: 許可權 435 -> 0, 權限 0 -> 435 (the exact count of 权限 in the
Simplified sources), 例項 2 -> 0, 實例 0 -> 2, 裡 541 -> 553. The 8 links
#187 pointed at /zh-Hant/ are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr
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.

gen-zh-hant.mjs leaves /zh-Hans/ body links intact, so Traditional readers are sent to Simplified pages

2 participants