From 015d9abb07ba1156781c89a97dc855fe8342c7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 04:40:03 +0000 Subject: [PATCH] content-lint: reject a body link that names another locale when the same-locale target is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link can resolve and still be wrong. `resolveInternalLink` finds `/zh-Hans/blog/x/` in an `index.ja.mdx` perfectly real, because it is: the page builds, the link works, and the reader is moved out of the language they were reading in — at which point the language switcher offers to "switch" them to the locale they were already in. Nothing in the gate chain looked at the locale segment at all. The rule is conditional and the condition is the design: it fires only when the same-locale target EXISTS. A deliberate cross-locale link is a real thing (an English-only companion post is the author's only option), and the alternative — a same-locale URL nobody built — is the hard 404 this script already rejects. That is the same rule `scripts/gen-zh-hant.mjs` enforces on the generated side: rewrite `/zh-Hans/` to `/zh-Hant/` only when the Traditional target is provably built. One rule, two enforcement points. "The same-locale target exists" is answered by calling `resolveInternalLink` on the swapped URL, not by re-deriving existence per surface — a second answer to that question would drift exactly where it matters, and produce a cross-locale error pointing at a same-locale URL that 404s. Measured at ca3983b before writing the rule: 335 authored files, 187 internal links carrying a routable locale segment, 28 of them cross-locale — every one a `/en/blog/…` link from de/es/fr/ja/ko/ zh-Hans/zh-Hant to one of the three English-only posts (enterprise-ontology-platform-comparison, ontology-vs-semantic-layer-vs-knowledge-graph, forward-deployed-engineer-trap). All 28 pass: none has a same-locale target. The rule turns nothing in the corpus red. Part of #179 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FeA1nwBz1ohH65dvffUGKr --- scripts/content-lint.mjs | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index 4a5a5eb..ffa260e 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -760,6 +760,87 @@ function resolveInternalLink(href, registries) { return { severity: 'error', reason: 'not a route this site builds' }; } +// ─── Cross-locale body links ─────────────────────────────────────────────── +// +// A link is resolvable and still wrong when it names a locale the file is not +// written in. `resolveInternalLink` above finds `/zh-Hans/blog/x/` in an `index.ja.mdx` +// perfectly real, because it is: the page builds, the link works, and the reader +// is quietly moved out of the language they were reading in — at which point the +// language switcher offers to "switch" them to the locale they were already in. +// Nothing else in the gate chain looks at the locale segment at all. +// +// THE RULE IS CONDITIONAL, AND THE CONDITION IS THE WHOLE DESIGN. A deliberate +// cross-locale link is a real thing: an English-only companion post linked from +// a translation is the author's only option, and the alternative — a same-locale +// URL that was never built — is a hard 404 the check above already rejects. So +// the finding fires only when the SAME-LOCALE TARGET EXISTS. Then the link is +// unambiguously a mistake, because the page the reader wanted is right there in +// their language. +// +// That is the same rule `scripts/gen-zh-hant.mjs` enforces on the generated side: +// it rewrites a `/zh-Hans/` link to `/zh-Hant/` only when the Traditional target +// is provably built, and withholds the rewrite otherwise because a manufactured +// 404 is worse than a cross-locale link. One rule, two enforcement points — the +// generator repairs the links it can reach, this catches the hand-written locales +// (`.ja`, `.ko`, `.de`, `.es`, `.fr`) that have no generator to blame. If the two +// ever disagree, one of them is wrong and a shared rule makes that visible. +// +// "The same-locale target exists" is answered by calling `resolveInternalLink` +// on the swapped URL rather than by re-deriving existence per surface. A second +// answer to that question is a second producer of one fact, and it would drift +// exactly where it matters: the two would disagree about some URL, and the +// disagreement would surface as a cross-locale error on a link whose same-locale +// form 404s — the manufactured 404 this rule exists to avoid. Reusing the +// resolver also means a `warn` verdict (the noindexed English fallback the +// glossary and marketing registries serve) does NOT count as existing. That is +// deliberate and conservative: choosing the real English page over a noindexed +// English-bodied duplicate is a defensible authoring call, not a mistake, and +// option 2's discipline is to fire only on the unambiguous case. + +/** `/en/blog/x/#anchor` -> `/ja/blog/x/#anchor`. Null when there is no segment to swap. */ +function withLocale(href, locale) { + const match = href.match(/^\/[^/#?]+(\/[^#?]*)?([#?].*)?$/); + if (!match) return null; + return `/${locale}${match[1] ?? ''}${match[2] ?? ''}`; +} + +/** + * One body link read from a file written in `fileLocale`. Returns null when the + * link is same-locale, carries no routable locale segment, or has no built + * same-locale counterpart; otherwise `{ severity, reason }`. + */ +function crossLocaleMiss(href, fileLocale, registries) { + const clean = href.split('#')[0].split('?')[0]; + const segments = clean.split('/').filter(Boolean); + if (segments.length === 0) return null; // "/" — the locale-picking root page + const linkLocale = segments[0]; + // A root route or a `public/` asset has no locale to be wrong about. + if (!registries.locales.has(linkLocale)) return null; + if (linkLocale === fileLocale) return null; + + const sameLocale = withLocale(href, fileLocale); + if (!sameLocale || resolveInternalLink(sameLocale, registries) !== null) return null; + + // zh-Hant posts are generated from zh-Hans unless hand-maintained, so naming + // the generated file as the place to edit would send the author to a file the + // next `pnpm gen:zh-hant` overwrites. + const authored = + fileLocale === 'zh-Hant' + ? ' If this file carries the `@generated` marker, fix the link in the zh-Hans source and re-run `pnpm gen:zh-hant`.' + : ''; + + return { + severity: 'error', + reason: + `this file is the ${fileLocale} version, the link points at ${linkLocale}, and ` + + `${sameLocale} IS built — so the reader is sent out of the language they were ` + + `reading in for a page that exists in it. Change the locale segment to ` + + `${sameLocale}; do not delete the link. (A cross-locale link is accepted when ` + + `the same-locale target does not exist — an English-only companion post is the ` + + `author's only option there, and this rule stays silent on it.)${authored}`, + }; +} + const registries = { blog: await readBlogRegistry(), glossary: await readLocaleTree(GLOSSARY), @@ -922,6 +1003,20 @@ for (const file of files) { `Dangling internal link [${link.text}](${link.href}): ${miss.reason}` ); } + // A link that does not resolve at all is reported once, above. Its locale is + // not the interesting fact about it, and a second finding on the same link + // would send the author to fix the segment of a URL that 404s either way. + if (miss?.severity === 'error') continue; + const crossed = crossLocaleMiss(link.href, locale, registries); + if (crossed) { + addIssue( + issues, + rel, + data, + crossed.severity, + `Cross-locale internal link [${link.text}](${link.href}): ${crossed.reason}` + ); + } } const seenBodyImages = new Map();