# Internationalization The interface ships in German and English. **German is the source language**: messages are authored in `messages/de.json` first, and English is written in the same change. Product CONTENT stays out of this system entirely (see "What is not translated"). Compiler: [Paraglide JS](https://paraglidejs.com). Messages compile to tree-shakable functions, so an unused string costs nothing at runtime and there is no runtime i18n library in the bundle. ## Where things live | Path | What | |---|---| | `frontend/project.inlang/settings.json` | locales, base locale, message file pattern | | `frontend/messages/{de,en}.json` | the messages themselves | | `frontend/src/lib/paraglide/` | compiler output, generated on every build, gitignored | | `frontend/src/lib/i18n/locale.svelte.ts` | the reactive locale store and the client strategy | | `frontend/src/lib/i18n/strategy.server.ts` | the server strategy that reads `users.locale` | | `frontend/scripts/check-messages.py` | the two lint rules, run by `make lint` | `npm run messages` compiles the message files into `src/lib/paraglide/`. `npm run check` and `npm run lint` both run it first, because `svelte-kit sync` does not execute vite plugins: without it, a newly added message is a type error against a stale build, and the fix looks like it belongs in the component. `npm run dev` and `npm run build` compile through the vite plugin as usual. ## Message keys Keys are **feature-scoped**, `snake_case`, and read as `__`: ``` settings_theme_light good settings_password_repeat good common_cancel good, for genuinely shared words light bad, collides across features button_label_2 bad, says nothing ``` A key belongs to the surface that owns the string. Reach for `common_*` only when a word is shared by unrelated features and would be identical in both languages in every one of them, which is rarer than it looks: "Cancel" qualifies, "Name" usually does not, because the noun it labels differs by context in German. Never reuse a key just because two strings happen to match in German today. Translation splits them apart sooner than you expect. **Keys are never constructed at runtime.** `m['status_' + doc.status]` is forbidden, however tempting it looks next to a status enum. Paraglide is a compiler: it can only check that a key exists, and only tree-shake the ones you do not use, if every key appears literally in the source. A computed key silently ships every message in the bundle and turns a typo into a runtime blank instead of a build error. Write the mapping out: ```ts const STATUS_LABELS = $derived({ draft: m.documents_status_draft(), published: m.documents_status_published() }); ``` ## Using messages ```svelte

{m.settings_title()}

``` Interpolation passes an object; the placeholder name is the key: ```json "landing_greeting_morning": "Guten Morgen, {name}" ``` ```svelte {m.landing_greeting_morning({ name })} ``` Pluralization lives in the message rather than in the component, as a list of **variants**. Inline ICU (`{count, plural, one {...}}`) inside a plain string is NOT parsed by the message-format plugin: it compiles into a placeholder with a nonsense name, which type-checks against nothing and renders as broken text. Use the variant form: ```json "landing_review_pending": [ { "declarations": ["input count", "local countPlural = count: plural"], "selectors": ["countPlural"], "match": { "countPlural=one": "Ein Dokument wartet auf deine Prüfung.", "countPlural=other": "{count} Dokumente warten auf deine Prüfung." } } ] ``` The compiler resolves the category through `Intl.PluralRules` for the active locale, so a language with more categories than German or English (Polish, Arabic) only needs more `match` entries, never a code change. Never build a sentence by concatenating messages. Word order differs between languages, so a message has to be a whole sentence with holes in it, not a sentence assembled from fragments. **Option lists must be `$derived`, not `const`.** A constant array of labels is evaluated once and then never again, so it keeps the language it was born in: ```svelte const THEME_OPTIONS = $derived([{ value: 'light', label: m.settings_theme_light() }]); ``` Dates and numbers go through `Intl`, and **the locale is always passed explicitly**: ```ts const dateFormat = $derived(new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium' })); ``` Not `new Intl.DateTimeFormat(undefined, ...)` and not `date.toLocaleDateString()`. Both fall back to the *browser's* locale, which is a different question from the interface language: someone running an English browser who set the interface to German would get German labels around English dates. Passing `i18n.locale` also makes the formatter `$derived`, so it rebuilds when the language changes. ## Per-route migration checklist A route is done when all of these are true, not just the visible sentences: - [ ] Body copy, headings, button labels, empty states, error messages - [ ] `aria-label` and the `label` prop on icon-only controls - [ ] `title` attributes (tooltips, truncated text, disabled explanations) - [ ] `alt` text on images - [ ] `placeholder` on inputs and textareas - [ ] `confirm()` / `alert()` text - [ ] The page `` in `<svelte:head>` - [ ] Option lists converted from `const` to `$derived` - [ ] `Intl` formatters take `i18n.locale` explicitly - [ ] No computed message keys introduced The invisible ones matter most in practice, because nothing on screen reminds you they are wrong. A screen reader user on a German interface hitting an English `aria-label` gets no visual cue that anything is off. ## How the locale is resolved Strategy chain, highest precedence first (`frontend/vite.config.ts`): 1. `custom-userPreference` reads `users.locale`. A language someone chose should follow them to every device, so this outranks everything. 2. `cookie` keeps that decision available before `/me` has answered. 3. `preferredLanguage` is the browser's `Accept-Language`, the first contact guess for someone who has never chosen. 4. `baseLocale` is the floor. There is deliberately **no `url` strategy**. Pablan is one installation for one company; `/de/` path prefixes would buy nothing and invalidate every existing link. Server side, `hooks.server.ts` runs `auth` before `i18n`: the auth handle fetches `/me` anyway, so it stashes the user's locale in a `WeakMap` keyed by the `Request`, and the custom strategy reads it from there. A strategy only receives the request, and a second `/me` per page render to answer the same question would be waste. Client side, `i18n.init()` points Paraglide's `getLocale()` at a `$state` rune. Message functions call `getLocale()` internally, so that read is tracked by Svelte and **switching the language re-renders the strings in place**: no reload, no flash of the previous language, and no lost form input. `setLocale(..., { reload: false })` still runs the chain so the cookie is right for the next server render. ## The two lint rules `frontend/scripts/check-messages.py`, wired into `make lint`: 1. **Every locale carries every key.** Paraglide falls back to the base locale for a missing message, which means an untranslated string ships silently as German inside an English interface. A missing key fails the build instead. Keys present in `en` but not in `de` fail too: the source language defines the set. 2. **No em or en dashes in messages** (`—`, `–`). They are awkward to type, drift in style when hand-written across a codebase, and in German they collide with the Gedankenstrich convention. Use a comma, a colon, or two sentences. The rule reads message files only, so prose in `docs/` and in code comments is unaffected. ## What is not translated - **Knowledge content.** Documents are whatever language their author wrote them in. The embedding model is multilingual, so a German document answers an English question; one eval case covers exactly that. - **Template content.** Personas and section hints are customer content, not UI. Blueprints carry a `locale:` field and a filename suffix (`prozess.de.yaml`), the catalog collapses variants to one entry per id, and the picker lists templates matching the reader's language first. See `authoring-templates.md`. - **Backend strings.** The backend never renders UI-language text. API errors are `{detail, code}` and the frontend translates by `code`; SSE `state` events carry counts and markers, and the frontend writes the sentence. - **Language names** in the switcher. "Deutsch" stays "Deutsch" in the English interface, because the point of that entry is to be recognised by someone who cannot read the language currently on screen.