From 04e3d5668343a8d462126fc0203cfb060f13276d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 01:43:11 +0000 Subject: [PATCH] fix(gen-zh-hant): point generated Traditional body links at /zh-Hant/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- .../index.zh-Hant.mdx | 4 +- .../index.zh-Hant.mdx | 2 +- .../index.zh-Hant.mdx | 2 +- .../index.zh-Hant.mdx | 6 +- .../index.zh-Hant.mdx | 2 +- scripts/gen-zh-hant.mjs | 228 +++++++++++++++++- 6 files changed, 232 insertions(+), 12 deletions(-) diff --git a/content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx b/content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx index 238c65e..f493f4b 100644 --- a/content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx +++ b/content/blog/ai-wrote-your-app-dare-to-merge/index.zh-Hant.mdx @@ -31,7 +31,7 @@ tags: [] 被推到前臺的,是另一件一直都在、但以前被寫程式碼的成本掩蓋的事:**有人得為這段程式碼負責。** 得有人能回答"它讀了哪些資料、能做哪些動作、出錯算誰的、審計查得到嗎"。在 AI 把"寫"的成本砍到零之後,"審與治"就成了整個流程裡最貴、也最關鍵的一段。**可審查性,是 AI 寫程式碼時代的新護城河**——不是誰生成得多,而是誰生成的東西敢上線。 -這正是 [Vibe Coding 技術債](/zh-Hans/blog/vibe-coding-technical-debt-2026/) 和 [AI Agent 試點失敗的四層原因](/zh-Hans/blog/why-ai-agent-pilots-fail-four-layers/) 兩篇講過的同一件事的另一面:vibe coding 讓人人都能生成應用,但生成完沒人敢上線——因為沒人能審、沒人能擔保。區別只在於,那兩篇是從企業和決策者視角看,這篇是從你——那個手指懸在 Merge 按鈕上的人——的視角看。 +這正是 [Vibe Coding 技術債](/zh-Hant/blog/vibe-coding-technical-debt-2026/) 和 [AI Agent 試點失敗的四層原因](/zh-Hant/blog/why-ai-agent-pilots-fail-four-layers/) 兩篇講過的同一件事的另一面:vibe coding 讓人人都能生成應用,但生成完沒人敢上線——因為沒人能審、沒人能擔保。區別只在於,那兩篇是從企業和決策者視角看,這篇是從你——那個手指懸在 Merge 按鈕上的人——的視角看。 ## "讓另一個 AI 去審"為什麼閉不了環 @@ -45,7 +45,7 @@ tags: [] ## 出路:把 AI 要交給你的東西,縮小到你審得動 -你審不動八千行,和這套系統半年後會爛掉,是**同一個原因**——沒人真正理解那一坨實現(這就是[那篇](/zh-Hans/blog/vibe-coding-technical-debt-2026/)講的"理解債")。 +你審不動八千行,和這套系統半年後會爛掉,是**同一個原因**——沒人真正理解那一坨實現(這就是[那篇](/zh-Hant/blog/vibe-coding-technical-debt-2026/)講的"理解債")。 那就換掉 AI 交付的**形態**:不讓它生成實現,讓它生成**宣告**。同一個退款應用,AI 該交給你的不是八千行程式碼,而是這樣一份後設資料 diff: diff --git a/content/blog/automation-cross-system-flows/index.zh-Hant.mdx b/content/blog/automation-cross-system-flows/index.zh-Hant.mdx index 9b36a01..2e287a7 100644 --- a/content/blog/automation-cross-system-flows/index.zh-Hant.mdx +++ b/content/blog/automation-cross-system-flows/index.zh-Hant.mdx @@ -50,7 +50,7 @@ CRM 改了狀態,ERP 要查應收,合同系統要確認簽署,通知服務 在 ObjectStack 裡,Webhook 是出站的。你為某個物件宣告一條訂閱,當記錄被建立、更新或刪除時,平臺把這條變化推送到你指定的外部地址;你可以配置一個共享金鑰,平臺會用它對請求體簽名。批次更新和批次刪除是兩類單獨的訂閱事件,因為它們送出的內容本來就不一樣——沒有具體記錄,只有物件和命中條數。總之,Webhook 是平臺告訴外部系統“這裡發生了什麼”的方式,不是外部系統告訴平臺的方式。 -外部系統要把事件送進平臺,用的是另一套機制:把流程宣告成 api 型別,引擎會為它掛出一條專屬的入站地址,路徑形如 /api/v1/automation/hooks/流程名/鉤子標識。這條地址只做兩件事——校驗簽名、放進佇列,然後回一個“已接收”;它從不在這次請求裡把流程跑完。金鑰按流程配置,鉤子標識可以輪換,等於在不改流程名的前提下作廢舊地址。入口模型本身,[觸發模型那篇](/zh-Hans/blog/automation-trigger-model/)單獨講過。 +外部系統要把事件送進平臺,用的是另一套機制:把流程宣告成 api 型別,引擎會為它掛出一條專屬的入站地址,路徑形如 /api/v1/automation/hooks/流程名/鉤子標識。這條地址只做兩件事——校驗簽名、放進佇列,然後回一個“已接收”;它從不在這次請求裡把流程跑完。金鑰按流程配置,鉤子標識可以輪換,等於在不改流程名的前提下作廢舊地址。入口模型本身,[觸發模型那篇](/zh-Hant/blog/automation-trigger-model/)單獨講過。 方向之所以值錢,是因為兩個方向交給你的問題幾乎相反。 diff --git a/content/blog/enterprise-ontology-race-open-vs-closed/index.zh-Hant.mdx b/content/blog/enterprise-ontology-race-open-vs-closed/index.zh-Hant.mdx index 366670a..434adde 100644 --- a/content/blog/enterprise-ontology-race-open-vs-closed/index.zh-Hant.mdx +++ b/content/blog/enterprise-ontology-race-open-vs-closed/index.zh-Hant.mdx @@ -172,7 +172,7 @@ os start # 同一份定義,跑在你自己的基礎設施上 現在再問那個要命的問題:"H 集團明年續約風險多高?"agent 面對的是**一個**統一的、帶許可權和審計的"客戶":訂單健康,但疊著兩次高管級投訴和一筆 90 天回款爭議——它會答"高風險,建議提前介入"。同一個模型、同一個問題,只因為腳下的定義不再分裂,結論從"丟了幾百萬"變成了"提前一個季度預警"。 -MCP 那一點在這裡同樣成立,而且方向恰好是對的:同一份定義,正是執行時作為受治理工具交給 agent 的那一份——讀取通道是開放的,**同時**定義是你的。這正是[為什麼定義與執行時都該開放](/zh-Hans/blog/ai-ontology-open-protocol/)完整論證的那件事。 +MCP 那一點在這裡同樣成立,而且方向恰好是對的:同一份定義,正是執行時作為受治理工具交給 agent 的那一份——讀取通道是開放的,**同時**定義是你的。這正是[為什麼定義與執行時都該開放](/zh-Hant/blog/ai-ontology-open-protocol/)完整論證的那件事。 這就是 ObjectStack 與 ObjectOS 的分工,也是它對這場競賽的回答: diff --git a/content/blog/give-your-agent-rules-for-governable-apps/index.zh-Hant.mdx b/content/blog/give-your-agent-rules-for-governable-apps/index.zh-Hant.mdx index 83a98e8..00c1cad 100644 --- a/content/blog/give-your-agent-rules-for-governable-apps/index.zh-Hant.mdx +++ b/content/blog/give-your-agent-rules-for-governable-apps/index.zh-Hant.mdx @@ -31,7 +31,7 @@ tags: ## 把"可治理性"寫進規則,而不是事後補 -事後補治理,是當下最常見、也最貴的錯法:先讓 agent 生成,再回頭審許可權、補審計、加審批——也就是 [AI Agent 試點進不了生產那篇](/zh-Hans/blog/why-ai-agent-pilots-fail-four-layers/)裡說的治理返工。每生成一個應用就返工一次,規模化之後這筆賬會越來越難付。 +事後補治理,是當下最常見、也最貴的錯法:先讓 agent 生成,再回頭審許可權、補審計、加審批——也就是 [AI Agent 試點進不了生產那篇](/zh-Hant/blog/why-ai-agent-pilots-fail-four-layers/)裡說的治理返工。每生成一個應用就返工一次,規模化之後這筆賬會越來越難付。 更省的做法,是讓規則把 agent 指向一個**本身就可治理的目標**。一份這樣的規則,核心不是風格,是約束產出的形態: @@ -50,13 +50,13 @@ tags: 這裡有個繞不過去的問題:憑什麼 agent 能把一個特定的目標格式寫對?規則寫得再好,模型不會寫也白搭。 -答案正是 [開放語義層那篇](/zh-Hans/blog/enterprise-ontology-race-open-vs-closed/) 論證過的飛輪——**agent 更容易生成它見過、能檢索到、能被規則約束的東西**。一個封閉平臺的私有格式,如果公開材料少、示例少、校驗反饋弱,再怎麼寫規則也很難穩定生成。而 ObjectStack 是 Apache 2.0 的開放協議,這讓"讓 agent 寫對它"具備了三個現實前提: +答案正是 [開放語義層那篇](/zh-Hant/blog/enterprise-ontology-race-open-vs-closed/) 論證過的飛輪——**agent 更容易生成它見過、能檢索到、能被規則約束的東西**。一個封閉平臺的私有格式,如果公開材料少、示例少、校驗反饋弱,再怎麼寫規則也很難穩定生成。而 ObjectStack 是 Apache 2.0 的開放協議,這讓"讓 agent 寫對它"具備了三個現實前提: 1. **可被學到**:開放 + 公開 + 被討論 → 更容易被模型和檢索系統學到; 2. **可被約束**:一份規則檔案把 agent 的產出錨定到這個宣告式目標; 3. **可在生成時校驗**:執行時會校驗生成的後設資料——非法的定義直接被拒,agent **當場拿到糾錯訊號**。這是自由格式程式碼給不了的關鍵差別:一段錯的 SQL 可能照樣跑、上線才爆,而一份錯的後設資料進不了門、當場就被打回重寫。 -正是第 3 點,把"讓 agent 寫對"變成了有反饋的收斂過程——agent 寫錯、被拒、改對,像編譯錯誤一樣即時。更進一步,你還可以把執行時的受治理工具通過 **MCP** 暴露出來,讓 agent 在生成時就調取權威定義、在執行時又能在邊界內操作它——寫和用,同一套治理。(受治理工具層這件事,[MCP 那篇](/zh-Hans/blog/mcp-governed-tool-layer/) 單獨講過。) +正是第 3 點,把"讓 agent 寫對"變成了有反饋的收斂過程——agent 寫錯、被拒、改對,像編譯錯誤一樣即時。更進一步,你還可以把執行時的受治理工具通過 **MCP** 暴露出來,讓 agent 在生成時就調取權威定義、在執行時又能在邊界內操作它——寫和用,同一套治理。(受治理工具層這件事,[MCP 那篇](/zh-Hant/blog/mcp-governed-tool-layer/) 單獨講過。) ## 落地:今天就能加的三條 diff --git a/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx b/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx index 073df00..e2f3a7c 100644 --- a/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx +++ b/content/blog/objectos-agent-permission-boundaries/index.zh-Hant.mdx @@ -17,7 +17,7 @@ tags: **先給結論**:agent 該不該受許可權約束,這件事已經不用爭了。真正決定這道邊界是真是假的,是**檢查跑在哪一層**——在資料進入模型上下文之前,還是之後。之後才補的過濾不是邊界,是**紙面許可權**:寫下來像訪問控制,執行時什麼都不做。所以採購和評審時該問的不是"你們有沒有許可權模型",而是"你們能不能證明某一條許可權不是紙做的"。 -"agent 到底該不該繼承使用者身份、該不該受審批和審計約束",我們在 [AI Agent 資料安全邊界:如何在企業許可權內工作](/zh-Hans/blog/ai-agent-business-data-security-boundaries/) 裡單獨論證過。這篇預設你已經同意那一層,只問後面那個更難回答的問題:這道邊界具體落在哪一行程式碼上,以及它失效的時候,你能看見嗎? +"agent 到底該不該繼承使用者身份、該不該受審批和審計約束",我們在 [AI Agent 資料安全邊界:如何在企業許可權內工作](/zh-Hant/blog/ai-agent-business-data-security-boundaries/) 裡單獨論證過。這篇預設你已經同意那一層,只問後面那個更難回答的問題:這道邊界具體落在哪一行程式碼上,以及它失效的時候,你能看見嗎? ![同一個請求的兩條路徑:紅線之前攔,還是紅線之後補](./security-pipeline.svg) diff --git a/scripts/gen-zh-hant.mjs b/scripts/gen-zh-hant.mjs index 141567c..761c2ae 100644 --- a/scripts/gen-zh-hant.mjs +++ b/scripts/gen-zh-hant.mjs @@ -17,13 +17,215 @@ const convert = OpenCC.Converter({ from: 'cn', to: 'twp' }); const MARKER = '# @generated zh-Hant from zh-Hans (s2twp) — edit the zh-Hans file; delete this line to hand-maintain.'; -const BLOG = path.join(process.cwd(), 'content', 'blog'); +const ROOT = process.cwd(); +const BLOG = path.join(ROOT, 'content', 'blog'); +const GLOSSARY = path.join(ROOT, 'content', 'glossary'); + +// ─── Cross-locale body links ─────────────────────────────────────────────── +// +// OpenCC only rewrites Han characters, so an internal link written in the +// Simplified source as `[标题](/zh-Hans/blog/some-slug/)` survives verbatim into +// the generated file, and the Traditional page then sends a Traditional reader +// to the Simplified companion post. Rewriting `/zh-Hans/…` to `/zh-Hant/…` on +// the way out fixes that — but only where the Traditional target is actually +// built. A wrong-locale link is a defect; a link the generator turned into a +// 404 is a worse one, so a target this script cannot prove is left alone and +// reported instead. +// +// WHERE THE CONDITIONAL IS LOAD-BEARING. Two routes are emitted per-locale with +// no fallback, so only they can 404 after a rewrite: +// +// * blog posts — `src/pages/[lang]/blog/[...slug].astro` emits a path only for +// the locales where the post has a file. That is the check below: the +// Traditional file exists after this run when the Simplified source exists +// (this script writes it) or a hand-maintained copy is already there. +// * topic hubs — `src/pages/[lang]/blog/topics/[...slug].astro` emits a path +// only for locales where a post actually *uses* the term. Resolving that +// needs the term extractor in `src/lib/terms.ts`, which this script does not +// import, so `/zh-Hans/blog/topics/…` is never rewritten — reported instead. +// +// WHERE NO CHECK IS NEEDED, AND 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. +// * 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 is the term existing anywhere, which is checked. +// * clusters are emitted for every locale unconditionally, but a bare +// `/zh-Hans//` 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 NEVER APPLIES. Link targets only. Fenced blocks and inline +// spans are masked out first, 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 is excluded by construction — only the body +// is handed to the rewriter — and a slug is never a link target, so slugs (in +// frontmatter, in the URL itself, or in a `relatedTerms` list) are never touched. + +/** URL segments under `//` that are pages rather than content slugs. */ +const STATIC_ROUTES = new Set([ + 'blog', + 'glossary', + 'pricing', + 'privacy', + 'refunds', + 'rss.xml', + 'security', + 'terms', +]); + +/** Locales the glossary/marketing registries can serve a zh-Hant page from. */ +const HANT_SOURCE_LOCALES = ['zh-Hant', 'zh-Hans', 'en']; + +/** Fenced code blocks and inline code spans. Mirrors content-lint's stripCode. */ +const CODE = /^ {0,3}(`{3,}|~{3,})[\s\S]*?^ {0,3}\1|`[^`\n]*`/gm; + +/** `[text](/path)` and `[text](/path "title")`, never images. Mirrors content-lint. */ +const MARKDOWN_LINK = /(?`? `rest` is the path after the locale + * segment, e.g. `/blog/some-slug/`. Unproven shapes answer false so the caller + * leaves the Simplified link in place rather than inventing a 404. + */ +function zhHantTargetBuilds(rest) { + const segments = rest.split('/').filter(Boolean); + if (segments.length === 0) return true; // locale home + if (segments.length === 1 && STATIC_ROUTES.has(segments[0])) return true; + + if (segments.length === 2 && segments[0] === 'blog') { + const slug = segments[1]; + if (slug === 'topics') return false; // needs the term extractor; see above + const dir = path.join(BLOG, slug); + return ( + existsSync(path.join(dir, 'index.zh-Hans.mdx')) || + existsSync(path.join(dir, 'index.zh-Hant.mdx')) + ); + } + + if (segments.length === 2 && segments[0] === 'glossary') { + const slug = segments[1]; + return HANT_SOURCE_LOCALES.some((locale) => + existsSync(path.join(GLOSSARY, locale, `${slug}.ts`)) + ); + } + + return false; +} + +/** + * `/zh-Hans/blog/x/#anchor` -> `/zh-Hant/blog/x/#anchor`, or null when this is + * not a Simplified link or its Traditional target is not provably built. + */ +function localizeHref(href, targetBuilds) { + const match = href.match(/^\/zh-Hans(\/[^#?]*)?([#?].*)?$/); + if (!match) return null; + const rest = match[1] ?? ''; + const suffix = match[2] ?? ''; + if (!targetBuilds(rest)) return null; + return `/zh-Hant${rest}${suffix}`; +} + +/** Rewrite link targets in one run of prose (never called on code). */ +function rewriteProse(text, targetBuilds, onSkip) { + const swap = (open, href, close) => { + const localized = localizeHref(href, targetBuilds); + if (localized) return `${open}${localized}${close}`; + if (href.startsWith('/zh-Hans/') || href === '/zh-Hans') onSkip(href); + return `${open}${href}${close}`; + }; + return text + .replace(MARKDOWN_LINK, (_whole, open, href, close) => swap(open, href, close)) + .replace(HREF_ATTRIBUTE, (_whole, open, href, close) => swap(open, href, close)); +} + +/** Rewrite link targets in a post body, leaving every code region byte-identical. */ +function rewriteBodyLinks(body, targetBuilds, onSkip) { + let out = ''; + let cursor = 0; + for (const match of body.matchAll(CODE)) { + out += rewriteProse(body.slice(cursor, match.index), targetBuilds, onSkip); + out += match[0]; // code, verbatim + cursor = match.index + match[0].length; + } + return out + rewriteProse(body.slice(cursor), targetBuilds, onSkip); +} + +/** Split the leading `---\n…\n---\n` block off, so the body alone is rewritten. */ +function splitFrontmatter(source) { + const match = source.match(/^---\n[\s\S]*?\n---\n/); + if (!match) return { head: '', body: source }; + return { head: match[0], body: source.slice(match[0].length) }; +} + +// ─── Fixture: the scoped rewrite, pinned ─────────────────────────────────── +// +// This repo has no test runner, and the property worth pinning is a negative +// one — a link is rewritten, an identical string inside code is not — which a +// later edit to the patterns above would break silently in generated output no +// one reads. So the cases run on every `pnpm dev` and `pnpm build`, off a stub +// registry where `known` is built and `ghost` is not. Pure string work; the +// generator prints nothing unless a case fails. +function selfTest() { + const stub = (rest) => + rest === '' || rest === '/' || rest === '/blog/known/' || rest === '/glossary/known/'; + const cases = [ + // A body link is rewritten… + ['[标题](/zh-Hans/blog/known/)', '[标题](/zh-Hant/blog/known/)'], + ['x', 'x'], + ['[t](/zh-Hans/blog/known/#anchor)', '[t](/zh-Hant/blog/known/#anchor)'], + ['[t](/zh-Hans/blog/known/ "title")', '[t](/zh-Hant/blog/known/ "title")'], + ['[t](/zh-Hans/glossary/known/)', '[t](/zh-Hant/glossary/known/)'], + ['[t](/zh-Hans/)', '[t](/zh-Hant/)'], + // …and the same string in code is not. + ['`/zh-Hans/blog/known/`', '`/zh-Hans/blog/known/`'], + ['`[t](/zh-Hans/blog/known/)`', '`[t](/zh-Hans/blog/known/)`'], + ['```\n[t](/zh-Hans/blog/known/)\n```', '```\n[t](/zh-Hans/blog/known/)\n```'], + ['~~~md\n[t](/zh-Hans/blog/known/)\n~~~', '~~~md\n[t](/zh-Hans/blog/known/)\n~~~'], + // Neither is anything whose Traditional target is not provably built. + ['[t](/zh-Hans/blog/ghost/)', '[t](/zh-Hans/blog/ghost/)'], + ['[t](/zh-Hans/blog/topics/agents/)', '[t](/zh-Hans/blog/topics/agents/)'], + // Not link targets: images, other locales, and a look-alike prefix. + ['![alt](/zh-Hans/blog/known/cover.png)', '![alt](/zh-Hans/blog/known/cover.png)'], + ['[t](/en/blog/known/)', '[t](/en/blog/known/)'], + ['[t](/zh-Hansard/blog/known/)', '[t](/zh-Hansard/blog/known/)'], + // Prose either side of a fence is still rewritten. + [ + '[a](/zh-Hans/blog/known/)\n```\n/zh-Hans/blog/known/\n```\n[b](/zh-Hans/blog/known/)', + '[a](/zh-Hant/blog/known/)\n```\n/zh-Hans/blog/known/\n```\n[b](/zh-Hant/blog/known/)', + ], + ]; + const failures = []; + for (const [input, expected] of cases) { + const actual = rewriteBodyLinks(input, stub, () => {}); + if (actual !== expected) failures.push({ input, expected, actual }); + } + if (failures.length > 0) { + console.error('✗ gen-zh-hant: link-rewrite fixture failed'); + for (const { input, expected, actual } of failures) { + console.error(` in: ${JSON.stringify(input)}`); + console.error(` expected: ${JSON.stringify(expected)}`); + console.error(` actual: ${JSON.stringify(actual)}`); + } + process.exit(1); + } +} + +selfTest(); const entries = await readdir(BLOG, { withFileTypes: true }).catch(() => []); const slugs = entries.filter((d) => d.isDirectory()).map((d) => d.name); let made = 0; let kept = 0; +let relinked = 0; +const skipped = []; for (const slug of slugs) { const src = path.join(BLOG, slug, 'index.zh-Hans.mdx'); const out = path.join(BLOG, slug, 'index.zh-Hant.mdx'); @@ -40,10 +242,28 @@ for (const slug of slugs) { const raw = await readFile(src, 'utf8'); // OpenCC only rewrites Han characters, so YAML keys, slugs, markdown - // syntax and code stay intact. Convert first, then stamp the marker. - const converted = convert(raw).replace(/^---\n/, `---\n${MARKER}\n`); + // syntax and code stay intact. Convert first, then point body links at the + // Traditional locale, then stamp the marker. + const { head, body } = splitFrontmatter(convert(raw)); + const rel = path.relative(ROOT, out); + const localized = rewriteBodyLinks(body, zhHantTargetBuilds, (href) => + skipped.push({ file: rel, href }) + ); + if (localized !== body) relinked++; + const converted = `${head}${localized}`.replace(/^---\n/, `---\n${MARKER}\n`); await writeFile(out, converted, 'utf8'); made++; } -console.log(`✓ zh-Hant: generated ${made}, kept ${kept} hand-maintained`); +console.log( + `✓ zh-Hant: generated ${made}, kept ${kept} hand-maintained, ` + + `${relinked} with body links pointed at /zh-Hant/` +); + +if (skipped.length > 0) { + console.warn( + `⚠ zh-Hant: left ${skipped.length} /zh-Hans/ link(s) as-is — no provable ` + + `Traditional build, and a manufactured 404 is worse than a cross-locale link:` + ); + for (const { file, href } of skipped) console.warn(` ${file}: ${href}`); +}