Chapter 11Build
Multilingual
One URL per language, built at build time. A fallback that keeps the page usable while it is being translated, and a check that refuses to let the remaining work slip through.
One URL per language
/ → build-time redirect to /fr/
/fr/ French home page
/fr/services/
/en/ English home page
/en/services/
The prefix is present even for the reference language
(prefixDefaultLocale: true). Without it the same page would be served at two
addresses — the root and the prefix — and a canonical would have to be arbitrated between two
identical URLs.
Neither on the site nor in the overlay, where the language choice is made of real links. A script-based switch would produce one URL for two contents: invisible to engines, impossible to share.
The files
src/content/pages/
├── fr/ the reference language — it makes the list of pages
│ ├── home.json
│ └── services.json
└── en/ the translation
├── home.json
└── services.json
The key structure must be identical across languages. That is not a
convention: it is checked, in both directions, by check-locales.mjs.
When a translation is missing
Two mechanisms that seem to contradict each other and in fact complete each other:
On display
The page stays usable
A field missing from the translation is taken from the reference language rather than
leaving a hole. The field is marked (data-cms-untranslated), and the overlay
bar shows “2 texts still to translate on this page”.
On check
Nothing slips through silently
npm run check fails on any key present in one language and absent from
another — in both directions, including a translated key that does not
exist in the reference and would therefore never be displayed.
So the fallback is not a tolerance: it is what makes a page usable while it is being translated, without hiding the remaining work.
export { localePath, mergeWithDefault, type MergeResult } from 'inline-core/translate';
export const LOCALES = ['fr', 'en'] as const;
export const DEFAULT_LOCALE = 'fr';
/** The language name in the language itself — never a code on screen. */
export const LOCALE_LABELS = {
fr: 'Français',
en: 'English',
};The mechanism of the fallback lives in the package; the list of languages lives in the site. A shared package cannot know how many languages a given site has, nor what they are called.
Lists and translations
Items are reconciled by id, never by position. So a translation can order its testimonials differently without texts sliding from one item to another.
An item added in one language must get the same id in the others. That is what the overlay does when the client adds a testimonial then switches language; it has to be done by hand when writing the JSON directly.
Adding a language
Four places, and all four are needed:
-
The integration and i18n routing
astro.config.mjs js integrations: [ inline({ locales: ['fr', 'en', 'de'] }), // reference language first ], i18n: { locales: ['fr', 'en', 'de'], defaultLocale: 'fr', routing: { prefixDefaultLocale: true }, },The code must be two lowercase letters: the integration refuses anything else.
-
The site languages
src/lib/locales.ts ts export const LOCALES = ['fr', 'en', 'de'] as const; export const LOCALE_LABELS = { fr: 'Français', en: 'English', de: 'Deutsch', };The label is the language name in that language: the client sees “Deutsch”, never “de”.
-
The parity check
scripts/check-locales.mjs js const LOCALES = ['fr', 'en', 'de']; -
The HTML check
scripts/check-html.mjslists the built pages explicitly, one entry per page and per language. This is the most frequent omission: the check passes, but it verifies nothing for the new language.scripts/check-html.mjs js const PAGES = [ { content: 'src/content/pages/fr/home.json', html: 'dist/fr/index.html' }, { content: 'src/content/pages/en/home.json', html: 'dist/en/index.html' }, { content: 'src/content/pages/de/home.json', html: 'dist/de/index.html' }, ];
Then create src/content/pages/de/. While the files are missing, pages display in
the reference language and npm run check reports it.
Routes receive the site languages (createSaveRoute({ locales: LOCALES })) and
only accept writes into a declared language folder. Without that,
src/content/pages/zz/home.json would create a folder nothing builds, and the
repository would fill with invisible content.
Reciprocal links
Each page declares itself in addition to its translations: that is the reciprocity engines expect — a page that does not cite itself is ignored by the group.
<link rel="canonical" href="https://the-site.com/en/services/" />
<link rel="alternate" hreflang="fr" href="https://the-site.com/fr/services/" />
<link rel="alternate" hreflang="en" href="https://the-site.com/en/services/" />
<link rel="alternate" hreflang="x-default" href="https://the-site.com/fr/services/" />
x-default points at the reference language. All of it is produced by the layout
from the language list — there is nothing to maintain by hand.
Editing in another language
In the overlay, the language selector is a set of real links to the same page
in the other languages, read from data-cms-locales, placed at build time. The
client switches language like a visitor, and their local draft stays attached to the page they
were editing.
The bar shows how many fields on the current page are still taken from the reference language. That is the interface's only translation indicator — there is no progress table, no overview, no tree.
A monolingual site
Declare a single language everywhere. Nothing else changes: the fallback has nothing to do, the parity check passes trivially, the language selector does not appear. Moving to two languages later needs only the four changes above.
Checks
npm run checkpasses: keys are identical in every language.- Every page exists in the reference language — otherwise it is not built at all.
hreflanglinks are reciprocal and includex-default.- List item ids are the same across languages.
check-html.mjshas one entry per page and per language.- Language labels are written in their own language.