Translation

Translation is a first-class dimension of the content model rather than a feature added on top of it. Retrofitting one costs a rewrite of the router, the nav, the head, and the conformance ledger, so the seams go in from M1 even though the machinery lands later.

The locale dimension

Content mirrors per locale. The default locale sits at the root of the URL space, so existing URLs stay valid; every other locale takes a prefix.

src/content/en/displays/index.md      →  /displays/
src/content/es/displays/index.md      →  /es/displays/
src/content/zh-Hant/displays/index.md →  /zh-Hant/displays/

A directory mirror rather than a filename suffix, because it makes the question that actually gets asked ("what is not translated yet?") a directory diff instead of a query.

site.yml declares the set:

locales:
  default: en
  available:
    - { code: en,      label: English,  dir: ltr }
    - { code: es,      label: Español,  dir: ltr }
    - { code: zh-Hant, label: 繁體中文,  dir: ltr }

The renderer takes lang and dir from this, emits hreflang alternates and an x-default in every page's head, and builds the language switcher from it. A locale added here appears everywhere without a page changing.

Strings outside the content

Nav labels, footer headings, the skip link, the new-tab note, the theme toggle, and every other piece of chrome text live in src/data/strings.<locale>.yml, keyed. nav.yml holds structure and references keys; it never holds English.

# src/data/strings.es.yml
nav.agencies:      Agencias
nav.developers:    Desarrolladores
a11y.new_tab:      (se abre en una pestaña nueva)
a11y.skip:         Saltar al contenido
theme.to_dark:     Colores oscuros

A missing key falls back to the default locale and is reported by cms check as untranslated, never as a blank.

Staleness, reusing the conformance mechanism

Every translated page records the content hash of the source page it was translated from. When the source changes, the translation is marked stale, and cms check reports it. This is the same invalidation the accessibility ledger uses for attestations, and it is deliberately the same code: a fact that was true about a piece of content on a date, invalidated when that content moves.

# .cms/translations.lock.yml
es/displays/index.md:
  source: en/displays/index.md
  source_hash: 9f3c1a...
  translated_on: 2026-08-19
  reviewed_by: null

Whether a stale translation blocks the build or only warns is set in site.yml:

locales:
  default: en
  stale: warn     # or `block`
  available:
    - { code: en, label: English, dir: ltr }
    - { code: es, label: Español, dir: ltr }

Labels are written in their own language, because each switcher link carries its own lang and a screen reader should pronounce "Español" in Spanish rather than reading it with an English voice.

Whether a stale translation blocks the build or only warns is a per-site setting. A site with a bilingual legal obligation blocks; a site where the second language is a courtesy warns and keeps serving the older text with a notice. Both are defensible, so the choice is explicit rather than assumed.

Drafting and review

cms translate <route> --to es hands the source page's blocks to the agent with the site's prompt layers and the target locale's own voice override, and writes the result. It translates field by field against the schema, so the structure is guaranteed to match and only the text moves. Fields typed href are remapped to the target locale's routes where a translation exists and left pointing at the default locale where it does not.

The draft is a draft. reviewed_by stays null until a person who reads the language signs it, and the ledger shows which pages are machine-drafted and unreviewed. Publishing unreviewed machine translation as if it were finished work is the failure mode worth designing against, so the state is recorded rather than implied.

What translation does to the rest of the system

Accessibility. The conformance ledger becomes per-locale. Reading level (3.1.5) has to be judged in each language by someone who reads it. Language of parts (3.1.2) needs an inline field type that carries a lang, so a Spanish page quoting an English product name marks it. The plain block exists per locale and is not a translation of the English plain block, because a plain-language version is written for its own readers.

Field limits. A max_chars sized to the source language fails on translation. Measured on this site: English card bodies top out at 219 characters and their Spanish translations at 242, and German and Finnish run further. The cards limit moved from 240 to 300 because of exactly that, found by a translated page failing the build.

The rule for setting one: take the longest real value in the source language and allow at least a third again. A limit nobody can meet in a second language is not a constraint, it is a trap that surfaces long after the schema was written.

Layout. Text expands. German and Finnish run roughly a third longer than English, and the 44px target check, the measure cap, and the nav wrap all have to hold at that length. The gate already runs every page at three widths; with translation it runs every page in every locale at three widths, which is the main cost translation adds to the check.

Typography. Overpass covers Latin. A locale outside that range needs a face with the coverage to match, declared in fonts.yml and handled by the font pipeline in ARCHITECTURE.md. Subsetting is per locale, so a site adding Traditional Chinese does not make its English pages carry the glyphs. A face serving every locale on the site belongs on the CDN rather than in one site's assets.

Ingest. A multilingual source site is crawled per locale, and the locale mapping is part of the ingest report.

What is built

All of the above, and verified on the fixture site with a Spanish locale:

What is built
Routing `src/content/es/…` serves `/es/…`, default locale at the root
Chrome strings Per locale, falling back to the default rather than rendering blank
`hreflang` Every alternate plus `x-default`, from the pages that actually exist
`lang` and `dir` On the html element, from the locale record
Switcher In the footer, listing only the locales this page exists in, each link carrying its own `lang`
Staleness `translations.lock.yml`, checked on every build
Subsetting Per locale, so one script's glyphs do not ride on another's pages

The lifecycle the lock file enforces, each state reported differently:

untranslated        2 of 3 pages have no es translation
no record           Translated page with no record of what it was translated from
unreviewed          Machine-drafted and not reviewed by anyone who reads the language
reviewed            (silent)
source changed      Stale: /privacy/ changed since this was translated on 2026-08-20

Editing one line of the English page moves it from the fourth state to the last, which is the whole point of the mechanism.

cms translate <route> --to <locale> prints what to copy and the block structure to preserve; --record registers the source hash; --reviewed <name> records who read it. The command deliberately does not translate. The agent does that, field by field against the schema, so the structure cannot drift and only the text moves.

Subsetting is keyed on the subset's own content hash, so two locales needing the same glyphs share one file and a Latin-only site pays nothing for the per-locale machinery.

Not in scope

Right-to-left layout. The token system and component set would need a logical -property pass across base.css, which is a design system project rather than a content engine one. dir is carried in the locale record so the day that work happens the content side is ready, and until then a site declaring an RTL locale fails the check with that explanation rather than shipping a broken page.

Next

Continue with site context.