2,457 pre-built static pages across 16 languages (English + 15). No runtime translation.
Before editing anything in this subsystem, pull up its LayerView node (lv describe file:<path> vpn-astro). It carries the landmines below as per-node memory, and a PreToolUse hook (.claude/scripts/lv-translation-guard.sh) force-shows it on every edit of a translation SSOT file. This doc is the narrative; LayerView is the queryable, always-current source of gotchas.
Every translated page is assembled at build time from:
| Source | Provides | Location | Translated by |
|---|---|---|---|
| Content (markdown) | Article body, title, description, slug | src/content/{collection}/{lang}/ | Durable Object (publish) or translate-md.mjs |
| UI strings | Nav, buttons, form labels, hub-page copy | src/i18n/translations.ts | translate-ui-strings.mjs (per language) |
| Provider data | Taglines, guarantees, plans, review copy | src/data/providers/i18n/{lang}/{slug}.json | translate-provider-json.mjs |
Language SSOT: src/config/languages.ts. Every lang list must derive from here. Codes: fr de es pt it nl ja ar da fa zh ko id sv tr vi.
Brand figures: src/config/brand.ts provides {broker.*} / {vpn.*} tokens that interpolateFacts() resolves inside strings, so figures are single-sourced and never hardcoded per language.
translations.ts has two shapes (this matters)translations dict uses unique quoted keys ('nav.vpn'). Collision-safe.homePage, cybersecurityPage, domainBrokerPage, hostingPage, perksPage + FAQ siblings) use unquoted keys that REUSE names across blocks (title, eyebrow, heroSub, ctaTitle, ctaDesc, contentTitle, description).Anything that processes the page blocks by key name will cross-assign values between pages. These blocks have none of the source-binding (sourceHash) the content pipeline has, so nothing structurally ties a translated value to its block + English source. That gap caused the July 2026 contamination (below).
translate-ui-strings.mjs once keyed its batch by bare key name (Object.fromEntries([key, en])); reused key names collapsed, so one page's translation overwrote another's across the 7 newest languages (~209 cells). Fixed by keying on the unique line index. Never revert. Backstop: scripts/i18n-contamination-check.mjs (blocking gate; token-parity + cross-block dup).translate-md defaults, content/config/translations.json, the DO). Always derive from languages.ts./{lang}/ prefix. Translated pages have translated slugs; use resolveUrl() / getRouteMap(). Hub roots localize via HUB_SLUGS.Run by scripts/build.sh: key-leak, frontmatter-leak, content-lint (--en-only), translation-contamination, LayerView translation-doc freshness, translation-qa (now blocking), lint-translations --strict (links + access-only compliance + homoglyphs + completeness), stale-provider-translations, and the a11y gate. find-untranslated.mjs is advisory.
src/config/languages.ts (LANGUAGES + LANG_NAMES).node scripts/translate-md.mjs --execute --lang <code> (content).node scripts/translate-provider-json.mjs --execute --lang <code> (provider data).node scripts/translate-ui-strings.mjs --lang <code> --execute (UI strings). Then node scripts/i18n-contamination-check.mjs --gate and an LLM topic-check of the page-block cells.config:v5:/{code}/ redirect (it would 301-hijack the new homepage)./{code}/ pages. Full runbook: the add-a-language LayerView / memory note.| What | Fix |
|---|---|
| UI string | Edit src/i18n/translations.ts (the exact block + key), then run the contamination gate |
| Provider data | Edit src/data/providers/i18n/{lang}/{slug}.json |
| Content | Edit src/content/{collection}/{lang}/{file}.md |
After any change to a translation SSOT file, record it in LayerView (lv remember file:<path> -s changes "..."). The build gate enforces this.