Decision record 0005Accepted
Bilingual content: one canonical file plus thin translation overlays
Context
The site ships in English and Polish, with English as the default. Content therefore exists in two languages, but most of a content file is not language at all: dates, status, technology references, ordering flags, URLs, metric values.
The conventional approach — a full copy of each file per locale, either as
content/en/… and content/pl/… directories or as foo.en.md / foo.pl.md —
duplicates every one of those neutral fields. That contradicts ADR 0001
directly: the Polish file could say a project is archived while the English one
says active, and nothing would catch it.
Decision
Each entry has one canonical file in the default locale, carrying the complete frontmatter, and zero or more overlay files carrying only the translatable fields.
content/projects/stampulse.md # canonical (en) — everything
content/projects/stampulse.pl.md # overlay (pl) — translatable fields only
The canonical file has no locale suffix; that absence is what marks it as the
owner of the neutral data. Overlays are <slug>.<locale>.md.
Each content type therefore has two Zod schemas — the full one and a translation
subset — loaded into two Astro collections. Overlay entries get the id
<locale>/<slug>. Pages never read the collections directly: resolve.ts
exposes getProject(slug, locale) and friends, so the merge rule and the
fallback rule exist exactly once. ContentLocalizer is the C# mirror.
Missing overlay is a supported state, not an error. A locale with no overlay
falls back to the canonical entry and is flagged translated: false, so the
page can render an honest notice and emit correct hreflang. That means a new
project can ship in English immediately without blocking on translation.
Field classification:
| Type | Translatable | Neutral |
|---|---|---|
| profile | headline, summary, location.city, body |
name, email, links, languages, coreStack, availability, country, timezone |
| project | tagline, summary, role, client, metric labels, body, optional title |
status, period, stack, links, keywords, metrics[].value, featured, order, confidential, draft |
| experience | role, location, highlights, body |
company, employmentType, period, remote, stack, url, order, draft |
| stack | — | everything |
Two details worth naming:
- Metrics merge by id, not by position. An overlay supplies
[{ id, label }], joined against the canonical[{ id, value, label }]. A reordered or partial overlay cannot mislabel a number. - Link labels are not content. The visible label comes from
relthrough the UI dictionary; the optionallabelfield is a neutral override for proper nouns. UI strings do not belong in the content corpus.
Routing follows the same asymmetry: English at the root (/projects), Polish
prefixed (/pl/projects), via Astro’s prefixDefaultLocale: false.
Consequences
- A neutral fact exists in exactly one file, so it cannot disagree with itself.
- Changing a project’s status is a one-file edit regardless of locale count.
- Two schemas per content type instead of one, plus a resolver layer. This is the cost, and it is paid once.
- The overlay is a subset, so an overlay whose canonical entry has been deleted
is meaningless;
FileSystemContentStorerejects orphan overlays at startup. - Adding a third locale is a one-line change to
LOCALESplus content files — the glob patterns and the C# locale list are derived from that list.
Alternatives rejected
Directory or suffix per locale with full frontmatter in each. Idiomatic in most Astro projects and simpler to read, but duplicates every neutral field. Enforcing “these fields must be byte-identical across locales” in CI is possible but is a rule about copies, where the overlay design has no copies to reconcile.
A single file with per-locale field maps (tagline: { en: …, pl: … }).
Keeps neutral data in one place, but makes the markdown body — the largest
translatable unit — impossible to express, and makes every file harder to read
in either language.
Machine translation at build time. Not a content decision; a quality one. Prose in a portfolio is the product.