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
8.7 KiB
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. 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:
const STATUS_LABELS = $derived({
draft: m.documents_status_draft(),
published: m.documents_status_published()
});
Using messages
<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:
"landing_greeting_morning": "Guten Morgen, {name}"
{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:
"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:
const THEME_OPTIONS = $derived([{ value: 'light', label: m.settings_theme_light() }]);
Dates and numbers go through Intl, and the locale is always passed
explicitly:
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-labeland thelabelprop on icon-only controlstitleattributes (tooltips, truncated text, disabled explanations)alttext on imagesplaceholderon inputs and textareasconfirm()/alert()text- The page
<title>in<svelte:head> - Option lists converted from
constto$derived Intlformatters takei18n.localeexplicitly- 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):
custom-userPreferencereadsusers.locale. A language someone chose should follow them to every device, so this outranks everything.cookiekeeps that decision available before/mehas answered.preferredLanguageis the browser'sAccept-Language, the first contact guess for someone who has never chosen.baseLocaleis 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:
- 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
enbut not indefail too: the source language defines the set. - 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 indocs/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. Seeauthoring-templates.md. - Backend strings. The backend never renders UI-language text. API
errors are
{detail, code}and the frontend translates bycode; SSEstateevents 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.