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
This commit is contained in:
+214
@@ -0,0 +1,214 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user