Files
pablan/docs/i18n.md
T
ProfessorNovaandClaude Opus 5 784b76baf7 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 09:21:37 +02:00

8.7 KiB
Raw Blame History

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-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.