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:
ProfessorNova
2026-09-04 09:21:37 +02:00
co-authored by Claude Opus 5
parent 68d3a43191
commit 784b76baf7
346 changed files with 43430 additions and 0 deletions
+214
View File
@@ -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.