Files
pablan/docs/i18n.md
T
ProfessorNovaandClaude Opus 5 97dbff309c Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 08:36:17 +02:00

215 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
`<feature>_<thing>_<role>`:
```
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
<script lang="ts">
import { m } from '$lib/paraglide/messages';
</script>
<h2>{m.settings_title()}</h2>
```
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 `<title>` 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.