Translations
The interface is translated with typesafe-i18n. The source is not, and neither is this site.
English and French ship today.
English is the base
Section titled “English is the base”Every key lives in src/i18n/en/index.ts. Other locales are extensions of it:
const fr = extendDictionary(en, { // only the keys you have actually translated});A key missing from a locale falls back to English rather than rendering blank. That is the whole design, and it has two consequences worth knowing:
- Adding a string is cheap. Touch
en/and nothing else. Nothing breaks, nothing goes empty. - Gaps are invisible. Which is why there is a script.
pnpm run i18n:statusIt reports coverage per locale, and exits non-zero on one specific thing: a locale declaring a key
that no longer exists in en/. That is a stale override, usually a rename left behind, and it is a
real bug rather than a missing translation.
Types are generated
Section titled “Types are generated”typesafe-i18n watches the dictionaries and regenerates src/i18n/i18n-types.ts. It runs
alongside Vite in pnpm run dev, so a key added while the dev server is up is typed within a
second. Never edit the generated file.
Strings carry typed arguments and plural forms:
contextMessagesInContext: '{count:number} {{message|messages}} in context',The compiler then refuses a call that forgets count, which is the reason for the whole setup.
Adding a locale
Section titled “Adding a locale”- Create
src/i18n/<code>/index.tsextendingen, likefr/does. - Add the code to
localesinsrc/i18n/i18n-util.ts. - Translate what you can. The rest falls back to English, and the app stays usable throughout.
- Run
pnpm run i18n:statusto see where you are.
There is no requirement to finish before opening a pull request. A half-translated locale renders as a mix of two languages, which is worse than English for some people and much better for others, and that trade is theirs to make.

