mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-10-19 15:44:27 +00:00
docs(localization): move the proposal into an architecture doc and ADRs 0029 and 0030
This commit is contained in:
parent
9fb1f5df1b
commit
862a3bfadd
8 changed files with 149 additions and 177 deletions
|
|
@ -1,15 +1,16 @@
|
|||
---
|
||||
name: translate
|
||||
description: Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when writing a key in translations/en.json, moving hard-coded UI text into messages, or filling in ja.json and de.json. Owns the key shape, the tone per language and the glossary.
|
||||
description: Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when adding a message to the code, moving hard-coded UI text into messages, or filling in ja.json and de.json. Owns the key shape, the tone per language and the glossary.
|
||||
---
|
||||
|
||||
# Translate
|
||||
|
||||
The desktop app's interface text lives in `surfsense_local/frontend/translations/`, one ICU MessageFormat file per language. English is the source; Japanese and German are translated from it. Design: [`docs/proposals/localization.md`](../../../docs/proposals/localization.md) (or `docs/architecture/localization.md` once it ships).
|
||||
The desktop app's interface text lives in `surfsense_local/frontend/translations/`, one ICU MessageFormat file per language. English is the source; Japanese and German are translated from it. How it works: [`docs/architecture/localization.md`](../../../docs/architecture/localization.md).
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `translations/en.json`, `ja.json`, `de.json` | `{"key": "ICU string"}`, flat, sorted, tab-indented |
|
||||
| `translations/en.json` | generated by `formatjs extract` from every `defaultMessage` in the code; never edited by hand |
|
||||
| `translations/ja.json`, `de.json` | `{"key": "ICU string"}`, flat, sorted, two-space indent, written with the steps below |
|
||||
| [`glossary.md`](glossary.md) | terms that stay English, and the chosen term per language |
|
||||
|
||||
Only fixed text in the app's code is translated. Never user content, model output, model names or descriptions, file paths, or logs.
|
||||
|
|
@ -21,11 +22,13 @@ Only fixed text in the app's code is translated. Never user content, model outpu
|
|||
- A backend error code maps to `<feature>_error_<code>`.
|
||||
- One key per place. Never reuse another feature's "Cancel"; never add a `common` key.
|
||||
- A whole sentence per value. No joining keys, no leading or trailing space. A link that is an action becomes its own key and its own element.
|
||||
- Visible apostrophes are `’`, never `'`: ICU uses `'` as its escape character.
|
||||
- Pass numbers raw, never preformatted text, and format them in the message with an ICU skeleton: `{size, number, ::unit/gigabyte .#}`, `{percent, number, ::percent}` (a fraction), `{downloads, number, ::compact-short}`. ICU cannot pick a unit, so megabytes and gigabytes are two messages.
|
||||
- Named placeholders only: `{name}`, `{count}`. Plurals are ICU: `{count, plural, one {# document} other {# documents}}`; FormatJS prints `#` with the language's digit grouping. Never `(s)`.
|
||||
- Call it with FormatJS's own API and a literal id: `intl.formatMessage({ id: "sources_list_empty" })`, or `intl.formatMessage({ id }, { name })` with values. Import `intl` from `@/i18n/intl`. An id outside `en.json` is a type error.
|
||||
- A styled part of a sentence (a bold name, an accent) is an ICU tag, FormatJS's native rich text: `"Using <b>{name}</b> via {source}"`, rendered with `intl.formatMessage({ id }, { b: (chunks) => <span …>{chunks}</span> })`. An element can also be a value: `{ time: <RelativeTime … /> }`.
|
||||
- Write the English in the call, as FormatJS recommends: `intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" })`, or `intl.formatMessage({ id, defaultMessage }, { name })` with values. The id is literal and explicit. Import `intl` from `@/i18n/intl`. Then run `pnpm translations` in `surfsense_local/frontend`, which regenerates `en.json`; commit it with the code.
|
||||
- A styled part of a sentence (a bold name, an accent) is an ICU tag, FormatJS's native rich text: `"Using <b>{name}</b> via {source}"`, rendered with `intl.formatMessage({ id, defaultMessage }, { b: (chunks) => <span …>{chunks}</span> })`. An element can also be a value: `{ time: <RelativeTime … /> }`.
|
||||
- Numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList`, `formatDisplayName`, never a bare `Intl.*` or `toLocale*`.
|
||||
- Keep the file sorted. Add the same key to `ja.json` and `de.json` in the same change, translated with the steps below.
|
||||
- Add the same key to `ja.json` and `de.json` in the same change, translated with the steps below, keeping those files sorted.
|
||||
|
||||
## Translating
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ Canonical dir: `.agents/skills/`. `.claude/skills` is a symlink to it.
|
|||
| `frontend-workflow` | Frontend work in `surfsense_web` and `surfsense_local/frontend`: React/Next performance, color tokens, UI polish, motion. Bundles `react-performance/`, `color/`, `polish/`, `motion/`; routes out to `shadcn`. |
|
||||
| `shadcn` | UI components in a tree with `components.json`. Standalone — it inspects the project live and grants its own CLI, which only works as a discovered skill. Load it after `frontend-workflow`, not instead of it. |
|
||||
| `migrate-radix-to-base` | Radix UI → Base UI migration. Stays top-level; not part of `frontend-workflow`. |
|
||||
| `translate` | Interface strings in `surfsense_local`: adding a key to `translations/en.json`, moving hard-coded text into messages, translating Japanese and German. Holds the key shape, tone and glossary. |
|
||||
| `translate` | Interface strings in `surfsense_local`: adding a message in code (`en.json` is generated from it), moving hard-coded text into messages, translating Japanese and German. Holds the key shape, tone and glossary. |
|
||||
|
||||
Do not install skill catalogs. Do not add `CONTEXT.md` or a second rules tree.
|
||||
|
||||
|
|
|
|||
24
docs/adr/0029-icu-translation-catalogs.md
Normal file
24
docs/adr/0029-icu-translation-catalogs.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# ADR 0029: Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-24
|
||||
- **Source:** [localization proposal L49–116](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L49-L116), [FormatJS ICU syntax](https://formatjs.github.io/docs/core-concepts/icu-syntax), commit [c3edca1b9](https://github.com/MODSetter/SurfSense/commit/c3edca1b9d44de178e7d8338d4a0b80144cd6fc7)
|
||||
|
||||
## Context
|
||||
|
||||
The desktop app's interface was English written into components. Japanese and German were next ([`03-international.md`](../../plans/community-local/seo/03-international.md)), and more languages would follow. The translations had to outlive whichever library renders them, be written by an agent and reviewed by anyone who reads the language, and survive plurals and word order that differ from English. Localization as built is in [localization](../architecture/localization.md).
|
||||
|
||||
## Decision
|
||||
|
||||
- One file per language in `surfsense_local/frontend/translations/`, a flat `{"id": "ICU string"}`. No nesting, no `common` block. `en.json` is generated from the code ([ADR 0030](0030-formatjs-renders-interface-text.md)); the others are written by translators.
|
||||
- Messages are ICU MessageFormat 1: named placeholders, `plural` with each language's CLDR categories and `#`, `select`, and tags for styled parts of a sentence.
|
||||
- Ids are `<feature>_<surface>_<purpose>`: the feature folder, then the component, then what the text does. Each place gets its own id, even when the English repeats.
|
||||
- A value is a whole sentence. No joining two messages, no pieces around a link.
|
||||
- Keys sorted with a two-space indent, the layout `formatjs extract` writes, the same order in every language.
|
||||
|
||||
## Consequences
|
||||
|
||||
- FormatJS, i18next with `i18next-icu`, Lingui and every major translation platform read the files, so the library can change without rewriting a translation.
|
||||
- Word order stays with the translator: Japanese and German move placeholders and tags where their grammar needs them.
|
||||
- Repetition in `en.json` is expected, and a feature's ids leave with the feature.
|
||||
- The files hold no translator notes: the `simple` format has no field for them, and the key and the calling component, where the English is written, are the context.
|
||||
26
docs/adr/0030-formatjs-renders-interface-text.md
Normal file
26
docs/adr/0030-formatjs-renders-interface-text.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# ADR 0030: FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-09-25
|
||||
- **Source:** [localization proposal L35–47](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L35-L47), [inlang: installing plugins](https://inlang.com/docs/install-plugin), [FormatJS performance guide](https://github.com/formatjs/formatjs/blob/main/docs/src/docs/guides/performance.mdx), commit [c03b68aaa](https://github.com/MODSetter/SurfSense/commit/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884)
|
||||
|
||||
## Context
|
||||
|
||||
The app is air-gapped, so no translation may be fetched at run time, and a release build should come only from the lockfile. The catalogs are ICU ([ADR 0029](0029-icu-translation-catalogs.md)). Paraglide JS was built first: it compiles messages to typed functions, but reads ICU only through a separate inlang plugin, first released in January 2026, that inlang recommends loading from a CDN; loading it from `node_modules` is supported but described as local-only. inlang also removed its lint rules in 2.0, and its native rich text needs inlang's own format. Localization as built is in [localization](../architecture/localization.md).
|
||||
|
||||
## Decision
|
||||
|
||||
- `react-intl` renders the frontend, `@formatjs/intl` Electron main's menu, and `@formatjs/cli` compiles and checks the catalogs. All three come from npm, pinned by the lockfile.
|
||||
- `formatjs compile-folder --ast` precompiles the catalogs before every build, and Vite aliases the ICU parser to its no-parser build.
|
||||
- All three languages are bundled. One `createIntl` instance serves the page, given to React through `RawIntlProvider`, and a language change reloads the window.
|
||||
- Messages are declared inline with their usage, as the FormatJS docs recommend: `intl.formatMessage({ id, defaultMessage }, values)`, with an explicit id and no wrapper. `formatjs extract` generates `en.json` from them, and `@formatjs/unplugin` strips `defaultMessage` from the bundle.
|
||||
- FormatJS's ESLint plugin checks every call: English present (`enforce-default-message`), every placeholder given (`enforce-placeholders`), the id shape (`enforce-id`). Ids are typed through `FormatjsIntl.Message`, keyed on `en.json`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The build needs no network, and text is on screen at the first render. Measured on the real catalogs: about 48 KB gzipped for all three languages, about 1 µs per message.
|
||||
- Rich text, formatting, extraction, `verify` and the lint rules are FormatJS's own, so the project keeps only a plural-category test and a short check for its id prefix and file layout.
|
||||
- `en.json` cannot hold a message the code does not use, or miss one it does.
|
||||
- The English sits next to where it is shown, which makes calls longer.
|
||||
- A language change drops in-memory state such as an open dialog.
|
||||
- Replacing FormatJS means rewriting every call site; the catalogs stay.
|
||||
|
|
@ -63,3 +63,5 @@ The Date is the day the decision was first written down. Source links are permal
|
|||
| 0026 | [Curated models are ordered by their position in the manifest, with no score](0026-curated-order-is-list-position.md) | Accepted, in part revised by the model catalog proposal |
|
||||
| 0027 | [Egress consent is per host, so model search and downloads share one](0027-egress-consent-per-host.md) | Accepted |
|
||||
| 0028 | [Model-written code may run on the user's machine without a sandbox, and the agent asks before each shell command](0028-model-written-code-runs-with-approval.md) | Accepted |
|
||||
| 0029 | [Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language](0029-icu-translation-catalogs.md) | Accepted |
|
||||
| 0030 | [FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time](0030-formatjs-renders-interface-text.md) | Accepted |
|
||||
|
|
|
|||
86
docs/architecture/localization.md
Normal file
86
docs/architecture/localization.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Localization
|
||||
|
||||
The desktop app's interface is in English, Japanese and German, chosen from the OS languages or in Settings. Translations are ICU MessageFormat catalogs in the repo, precompiled and bundled into the app, so nothing is fetched at run time and the build needs no network. FormatJS renders them.
|
||||
|
||||
**Code:** [`surfsense_local/frontend/translations/`](../../surfsense_local/frontend/translations/), [`surfsense_local/frontend/src/i18n/`](../../surfsense_local/frontend/src/i18n/), [`surfsense_local/electron/src/main/i18n/`](../../surfsense_local/electron/src/main/i18n/), [`scripts/check_translations.mjs`](../../scripts/check_translations.mjs), [`.agents/skills/translate/`](../../.agents/skills/translate/SKILL.md)
|
||||
**Decisions:** [ADR 0029](../adr/0029-icu-translation-catalogs.md), [ADR 0030](../adr/0030-formatjs-renders-interface-text.md)
|
||||
|
||||
## What is translated
|
||||
|
||||
Only text written into the app's own code: labels, buttons, headings, menus, tooltips, placeholders, empty states, toasts, dialogs, `aria-label`, `title` and `alt`, fixed sentences with a value inserted, the frontend's text for a backend error code, and main's menu labels.
|
||||
|
||||
Not translated: user content (workspace, document and thread names, file contents, chat messages), model output, backend and manifest data (model names and descriptions, provider names, file paths), product and technical names used alone (SurfSense, Studio, llama.cpp, GGUF), and logs. The language a chat answer or a Studio output is written in is set elsewhere ([chat](chat.md), [Studio](studio.md)).
|
||||
|
||||
English is the source. Japanese and German came first, in the order [`03-international.md`](../../plans/community-local/seo/03-international.md) set for the website.
|
||||
|
||||
## Catalogs
|
||||
|
||||
One flat JSON file per language, `{"id": "ICU string"}`, keys sorted with a two-space indent, the same order in every file ([ADR 0029](../adr/0029-icu-translation-catalogs.md)). `en.json` is generated: `formatjs extract` reads every `defaultMessage` in the code. `ja.json` and `de.json` are written by the `translate` skill and reviewers.
|
||||
|
||||
```json
|
||||
{
|
||||
"onboarding_model_ready_server_status": "Using <b>{name}</b> via {source}",
|
||||
"sources_delete_dialog_title": "Delete {count, plural, one {# source} other {# sources}}?"
|
||||
}
|
||||
```
|
||||
|
||||
- An id is `<feature>_<surface>_<purpose>`. `<feature>` is a folder under `src/features/`, or `app` for the shell and `menu` for main. `<purpose>` is one of `title`, `body`, `label`, `placeholder`, `button`, `tooltip`, `empty`, `error`, `toast`, `aria`, `link`, `status`. A backend code is `<feature>_error_<code>`.
|
||||
- Each place has its own id, even where the English repeats. There is no `common` block.
|
||||
- A value is a whole sentence with named placeholders. Plurals are ICU `plural` with the language's CLDR categories and `#`; Japanese writes only `other`. Styled parts of a sentence are tags.
|
||||
- Visible apostrophes are `’`: ICU uses `'` as its escape character.
|
||||
- Numbers go in raw and the message formats them with an ICU skeleton, as FormatJS's best practices ask: `{size, number, ::unit/gigabyte .#}`, `{percent, number, ::percent}`, `{downloads, number, ::compact-short}`. Each language then writes 1,2 GB, 40 % or 1.2万 its own way.
|
||||
|
||||
## Rendering
|
||||
|
||||
- [`intl.ts`](../../surfsense_local/frontend/src/i18n/intl.ts) builds one `createIntl` instance for the page, in the language preload reports, with all three precompiled catalogs bundled and each merged over English. [`main.tsx`](../../surfsense_local/frontend/src/main.tsx) gives it to React through `RawIntlProvider`.
|
||||
- Every call declares its English inline, as the FormatJS docs recommend: `intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" })`. The id is literal and explicit. [`message-ids.d.ts`](../../surfsense_local/frontend/src/i18n/message-ids.d.ts) types the ids through `FormatjsIntl.Message`, so an id outside `en.json` is a type error.
|
||||
- The build strips `defaultMessage` (`@formatjs/unplugin` with `removeDefaultMessage`), so each message ships once, in the catalogs.
|
||||
- Rich text is FormatJS's: `{ b: (chunks) => <span …>{chunks}</span> }` for a tag, and an element can be a value, such as `<RelativeTime>` in "Last call: {time}".
|
||||
- Numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList` and `formatDisplayName`.
|
||||
- ESLint forbids importing the catalogs or `compiled/` outside `src/i18n/`.
|
||||
|
||||
## Locale
|
||||
|
||||
- Main resolves it: a saved choice, else the first entry of `app.getPreferredSystemLanguages()` whose base language ships (`ja-JP` → `ja`), else English ([`resolve-locale.ts`](../../surfsense_local/electron/src/main/i18n/resolve-locale.ts)).
|
||||
- Settings › General has a Language select beside Appearance: Match system, then each language in its own name. The choice is `locale-prefs.json` in `userData`.
|
||||
- Preload reads the locale synchronously before first paint and exposes `locale.get()`, `preference()`, `set()` and `onChange()`. `index.html` sets `<html lang>` in the first frame, and [`locale.ts`](../../surfsense_local/frontend/src/i18n/locale.ts) keeps it.
|
||||
- A change saves the choice, rebuilds the menu, and reloads the window, which builds its `IntlShape` in the new language. In-memory state, such as an open dialog, resets.
|
||||
|
||||
## Main process
|
||||
|
||||
[`app-locale.ts`](../../surfsense_local/electron/src/main/i18n/app-locale.ts) builds an `IntlShape` with `@formatjs/intl` from the same catalogs, imported directly and parsed at run time, for the View menu's three labels. The `role` menus take their labels from Electron and the OS; on macOS only for languages whose `.lproj` ships, so `mac.electronLanguages` in [`electron-builder.yml`](../../surfsense_local/electron/electron-builder.yml) must keep `en`, `ja` and `de` if it is ever narrowed ([electron#26231](https://github.com/electron/electron/issues/26231)).
|
||||
|
||||
## Backend text
|
||||
|
||||
The backend stays English. Where it sends a code with its prose, the frontend shows its own text for the code and falls back to the backend's English for a code it does not know: the chat's error kinds ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)) and the license rejection reasons ([`license-error-text.ts`](../../surfsense_local/frontend/src/features/license/license-error-text.ts)).
|
||||
|
||||
## Build
|
||||
|
||||
`pnpm translations` runs before `dev`, `build`, `typecheck` and `test`:
|
||||
|
||||
1. `formatjs extract` writes `translations/en.json` from every `defaultMessage` in `frontend/src` and `electron/src/main`.
|
||||
2. `formatjs compile-folder translations src/i18n/compiled --format simple --ast` precompiles the catalogs, failing on a malformed message.
|
||||
|
||||
Vite aliases `@formatjs/icu-messageformat-parser` to its no-parser build, since no message is parsed at run time. `compiled/` is gitignored. Everything comes from npm and the lockfile.
|
||||
|
||||
## Checks
|
||||
|
||||
- ESLint, with FormatJS's plugin: `enforce-default-message` (every call carries its English), `enforce-placeholders` (every placeholder gets a value), and `enforce-id` (ids match `<feature>_<surface>_<purpose>`).
|
||||
- `formatjs-extract`, a pre-commit hook: re-runs extraction, so a commit whose `en.json` does not match the code fails as a modified file.
|
||||
- `formatjs-verify`, a pre-commit hook: `formatjs verify --missing-keys --extra-keys --structural-equality` over the three catalogs.
|
||||
- `check-translations`, a pre-commit hook: [`check_translations.mjs`](../../scripts/check_translations.mjs) for the rules FormatJS does not know: an id prefix that is not a feature folder, `app` or `menu`, a leading or trailing space, a straight apostrophe, an unsorted file.
|
||||
- [`plural-categories.test.ts`](../../surfsense_local/frontend/src/i18n/plural-categories.test.ts), in `pnpm test`: every plural writes exactly the categories `Intl.PluralRules` gives its language.
|
||||
|
||||
[`code-quality.yml`](../../.github/workflows/code-quality.yml) runs the hooks on a non-draft pull request's changed files.
|
||||
|
||||
## Translating
|
||||
|
||||
Developers write English only, inline in the `formatMessage` call; `pnpm translations` or the pre-commit hook updates `en.json`. The [`translate`](../../.agents/skills/translate/SKILL.md) skill drafts Japanese and German for every id that is new or whose English changed, with its glossary and tone (です/ます; German *du*). Anyone who reads a language can correct it in a pull request; no release waits on review. No machine-translation service is used ([ADR 0017](../adr/0017-egress-off-by-default.md)).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- `enforce-placeholders` checks values only when they are passed as an object literal; a call that passes a variable, as `model-ready.tsx` does, is not checked.
|
||||
- `plural-categories.test.ts` runs only in `pnpm test`, and no workflow runs the desktop tests on pull requests.
|
||||
- Backend prose without a code stays English in every language: model install messages, fit verdicts, `not_runnable_reason`, a Studio format's `unavailable_reason`, and the disk-space `detail` that `lib/api.ts` wraps in a translated sentence.
|
||||
- Studio's fallback "Needs {models}" joins translated noun phrases with `formatList`, so German case agreement is not guaranteed. It shows only when a format lacks the backend's `unavailable_reason`.
|
||||
- A chat failure caught before the stream starts shows the `unknown` kind's text, not the error's own detail.
|
||||
|
|
@ -112,6 +112,7 @@ The rules that keep the processes out of each other's way:
|
|||
- [Studio](studio.md): artifact formats, jobs, viewers.
|
||||
- [Connections](connections.md): OpenAI-compatible endpoints and where keys live.
|
||||
- Local models: [runtime](local-models/runtime.md), [fit](local-models/fit.md), [catalog](local-models/catalog.md), [selection and onboarding](local-models/selection.md).
|
||||
- [Localization](localization.md): the interface in English, Japanese and German.
|
||||
- [Egress](egress.md), [import](import.md), [license in the app](license/app.md), [license portal](license/portal.md), [updates](updates.md), [packaging](packaging.md), [sunset](sunset.md).
|
||||
- [Contracts](../contracts/README.md) between the trees.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
---
|
||||
status: proposed
|
||||
code:
|
||||
- surfsense_local/frontend/translations/
|
||||
- surfsense_local/frontend/src/i18n/
|
||||
- surfsense_local/electron/src/main/i18n/
|
||||
- .agents/skills/translate/
|
||||
- scripts/check_translations.mjs
|
||||
---
|
||||
|
||||
# Localization
|
||||
|
||||
> The desktop app's interface in English, Japanese and German, chosen from the OS languages or by the user. Translations are ICU MessageFormat JSON files in the repo, precompiled and bundled into the app, so nothing is fetched at run time and the build needs no network. FormatJS renders them, through its documented APIs only.
|
||||
|
||||
## Languages
|
||||
|
||||
English is the source. Japanese and German come first, in the order [`03-international.md`](../../plans/community-local/seo/03-international.md) set for the website: Japan is the second-largest market with no competitor on `ローカルLLM`, and Germany the largest non-English "alternative" demand. French, Spanish and Brazilian Portuguese are the next tier there and are added here as files only when the website's pages in those languages exist.
|
||||
|
||||
## What gets translated
|
||||
|
||||
Only text written into the app's own code. Anything a user or a model produces, and anything the backend sends as data, is shown as it is.
|
||||
|
||||
| Translated | Not translated |
|
||||
|---|---|
|
||||
| Labels, buttons, headings, menus, tooltips, placeholders, empty states, toasts, dialogs | User content: workspace, document and thread names, file contents, chat messages |
|
||||
| Text read aloud or shown on hover: `aria-label`, `title`, `alt` | Model output: chat answers and Studio artifacts |
|
||||
| Fixed sentences with a value inserted, `Deleted {name}` or a plural; the sentence is translated, the value is passed in unchanged | Backend and manifest data: model names and descriptions, provider names, file paths |
|
||||
| The frontend's message for each backend error code (see [Backend text](#backend-text)) | Product and technical names: SurfSense, Studio, llama.cpp, GGUF |
|
||||
| Main-process menu labels (see [Main process](#main-process)) | Logs, console output and developer-only text |
|
||||
|
||||
Model descriptions in the manifest are fixed text, but they reach the frontend as data from the backend, so they stay English. Translating them is catalog work, not part of this proposal.
|
||||
|
||||
The language a chat answer or a Studio output is written in is also out of scope. The chat already answers in the question's language ([chat](../architecture/chat.md)), and a podcast brief carries its own `language` ([Studio](../architecture/studio.md)). Both are independent of the interface language.
|
||||
|
||||
## Library
|
||||
|
||||
[FormatJS](https://formatjs.github.io/): `react-intl` in the renderer, `@formatjs/intl` (the same engine without React) in Electron main, `@formatjs/cli` at build time. Weighed against:
|
||||
|
||||
| Library | Why not |
|
||||
|---|---|
|
||||
| Paraglide JS 2 | built first, then replaced. Its ICU support is a separate inlang plugin (`@inlang/plugin-icu1`, first released Jan 2026) that inlang recommends loading from a CDN; loading it from `node_modules` works offline but is an off-default setting. inlang removed its lint rules in 2.0, and native rich text needs inlang's own file format. |
|
||||
| Lingui 5 | its macros need Babel or SWC, which Rolldown cannot run; Vite 8 and `@vitejs/plugin-react` 6 would have to bring one back |
|
||||
| react-i18next | not ICU without a plugin; keys untyped; the usual Electron setup loads JSON over IPC at run time, a problem a bundled app does not have |
|
||||
|
||||
What decides it for an air-gapped app: FormatJS reads ICU natively and is plain npm packages pinned by the lockfile, so an offline, reproducible build is its default rather than a setting to protect. It has shipped since 2014 (react-intl, about 2.3M weekly downloads in Sep 2026), and rich text, number and date formatting, and a translation checker (`formatjs verify`) are built in.
|
||||
|
||||
Measured on the 740 real strings in three languages: all catalogs cost about 48 KB gzipped (react-intl without its parser 8 KB, precompiled catalogs 40 KB), and a message formats in about 1 µs, so a screen's text takes well under a millisecond. Paraglide was about 10% faster per message and the same size gzipped; neither difference is visible.
|
||||
|
||||
## Keeping the library replaceable
|
||||
|
||||
**Files are ICU MessageFormat 1.** One JSON file per language, `{"key": "ICU string"}`: `plural`, `select`, `selectordinal`, `=n`, `#`, number and date formatters, and rich-text tags. FormatJS reads them natively; i18next with `i18next-icu`, Lingui and every major translation platform read the same files.
|
||||
|
||||
**Names are generic.**
|
||||
|
||||
```
|
||||
surfsense_local/frontend/
|
||||
translations/ en.json, ja.json, de.json (ICU source)
|
||||
src/i18n/
|
||||
compiled/ formatjs compile-folder output, gitignored
|
||||
locales.ts the languages that ship
|
||||
intl.ts the page's one IntlShape, all catalogs bundled
|
||||
message-ids.d.ts FormatjsIntl.Message: ids are the keys of en.json
|
||||
locale.ts follow main's language; reload on a change
|
||||
surfsense_local/electron/src/main/i18n/
|
||||
locales.ts mirror of the frontend's list
|
||||
app-locale.ts main's IntlShape for the menu
|
||||
resolve-locale.ts preference + OS languages → locale
|
||||
locale-prefs.ts locale-prefs.json in userData
|
||||
locale-ipc.ts locale:get, locale:preference, locale:set
|
||||
```
|
||||
|
||||
**Call sites use FormatJS's own API.** `intl.formatMessage({ id: "sources_delete_confirm_title" }, { name })`, with `intl` imported from `@/i18n/intl`. It is one `createIntl` instance for the page's life, provided to React with `RawIntlProvider`, the pattern the FormatJS docs give for precompiled catalogs; code outside components uses the same instance. A language change reloads the window, so the instance never goes stale. ESLint forbids importing `compiled/` or the catalogs outside `src/i18n/`.
|
||||
|
||||
**The locale belongs to the app.** Main owns it; the renderer is told. The IPC channel and the stored preference never name the library.
|
||||
|
||||
## Shape of `en.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_composer_placeholder": "Ask about your sources",
|
||||
"chat_composer_send_button": "Send",
|
||||
"chat_error_provider_auth": "Your model connection needs a new API key.",
|
||||
"settings_general_language_label": "Language",
|
||||
"sources_delete_cancel_button": "Cancel",
|
||||
"sources_delete_confirm_body": "Delete {count, plural, one {# document} other {# documents}}? This cannot be undone.",
|
||||
"sources_delete_confirm_title": "Delete “{name}”?",
|
||||
"sources_list_empty": "No documents yet. Add a file or a folder to start."
|
||||
}
|
||||
```
|
||||
|
||||
**Keys.** `<feature>_<surface>_<purpose>`, snake_case.
|
||||
|
||||
- `<feature>` is a folder under `src/features/` (`chat`, `sources`, `settings`), or `app` for the shell and `menu` for main. A check fails on any other prefix, so deleting a feature's folder shows its keys as dead.
|
||||
- `<surface>` is the component or dialog: `composer`, `delete`, `list`.
|
||||
- `<purpose>` names what the text does, never what it says: `_title`, `_body`, `_label`, `_placeholder`, `_button`, `_tooltip`, `_empty`, `_error`, `_toast`, `_aria`, `_link`, `_status`. The suffix tells a translator how much room there is. `sources_delete_confirm_title`, not `are_you_sure`.
|
||||
- The text for a backend code is `<feature>_error_<code>`, the code as the backend sends it: `chat_error_provider_auth`.
|
||||
- A key names the meaning, as [Locize](https://www.locize.com/blog/guide-to-i18n-key-naming) and [Lokalise](https://lokalise.com/blog/translation-keys-naming-and-organizing/) recommend. Rewording that keeps the meaning keeps the key and the skill retranslates it; a new meaning is a new key.
|
||||
|
||||
**No shared strings.** "Cancel" in a delete dialog and "Cancel" in a download are two keys. German may want *Abbrechen* in one and *Verwerfen* in the other, and a feature's keys leave with the feature. Repetition in `en.json` is expected; a `common` block is not allowed.
|
||||
|
||||
**Whole sentences.** A value is a sentence or a label, never a piece of one. No joining two keys, no leading or trailing space, no label ending in a colon followed by a value in code. Japanese puts the verb last and German moves it, so a sentence built from parts cannot be translated. A value inserted mid-sentence is a named placeholder: `{name}`, `{count}`, `{size}`, never `{0}`.
|
||||
|
||||
**Plurals and choices are ICU.** `{count, plural, one {# document} other {# documents}}`; FormatJS prints `#` with the language's digit grouping (12,345; 12.345). `=0` only when zero reads differently ("No documents"). Never `(s)`, never a `_one` and `_other` key pair. Each language writes the categories its CLDR rules have: Japanese only `other`, German `one` and `other`.
|
||||
|
||||
**Literal ids only.** Code calls `intl.formatMessage({ id: "sources_list_empty" })`, never with a computed id. `message-ids.d.ts` makes an id outside `en.json` a type error. A backend code maps to text through a typed record, so a new code without a message is a type error:
|
||||
|
||||
```ts
|
||||
const chatErrorText: Record<ChatErrorKind, () => string> = {
|
||||
provider_auth: () => intl.formatMessage({ id: "chat_error_provider_auth" }),
|
||||
// one entry per ChatErrorKind
|
||||
}
|
||||
```
|
||||
|
||||
**Sorted.** Keys in each file sorted, tab-indented, one key per line, the same order in every language. A feature's keys sit together, diffs stay small, and two pull requests adding keys to different features rarely conflict. The check fails on an unsorted file.
|
||||
|
||||
**Other languages** hold exactly the keys of `en.json`: none missing, none extra. At run time each language's catalog is merged over English, so a missing key would show English, never its id.
|
||||
|
||||
## Locale
|
||||
|
||||
- **Detect.** On first launch, the first entry of `app.getPreferredSystemLanguages()` whose base language is supported (`ja-JP` → `ja`), else `en`.
|
||||
- **Override.** Settings › General gets a Language select beside Appearance: System, English, 日本語, Deutsch, each language written in itself. The choice is saved as `locale-prefs.json` in `userData`, the way [`theme-prefs.ts`](../../surfsense_local/electron/src/main/theme-prefs.ts) saves the theme.
|
||||
- **Hand over.** Preload reads the locale synchronously before the renderer's first paint, as it does the system theme, and exposes `locale.get()`, `locale.preference()`, `locale.set()` and `locale.onChange()`. `src/i18n/intl.ts` builds the page's `IntlShape` from `locale.get()` at load, with all three precompiled catalogs bundled, so text is there on the first render with no loading step.
|
||||
- **Change.** Main saves the choice, calls `installProductionMenu()` again and tells the window, which reloads; the reloaded page builds its `IntlShape` in the new language. Open dialogs and other in-memory state reset; people rarely switch languages. No restart.
|
||||
- **Formatting.** Numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList` and `formatDisplayName`, which cache formatters per language.
|
||||
- **Html.** `<html lang>` follows the locale, so the OS picks the right CJK glyphs and screen readers the right voice.
|
||||
|
||||
## Main process
|
||||
|
||||
Main shows three strings of its own: the View menu's "View", "Reload" and "Force Reload" in [`electron/src/main/index.ts`](../../surfsense_local/electron/src/main/index.ts). `app-locale.ts` builds an `IntlShape` with `@formatjs/intl` from the same `translations/` files, imported directly and parsed at run time: three labels do not justify a compile step. Its own `message-ids.d.ts` types the ids.
|
||||
|
||||
The `role` menus (`appMenu`, `fileMenu`, `editMenu`, `windowMenu`) take their labels from Electron and the OS. On macOS they are only translated if the app ships that language's `.lproj`, so `mac.electronLanguages` in [`electron-builder.yml`](../../surfsense_local/electron/electron-builder.yml) must include `en`, `ja` and `de` if it is ever narrowed ([electron#26231](https://github.com/electron/electron/issues/26231)).
|
||||
|
||||
## Backend text
|
||||
|
||||
The backend returns some English prose the UI shows as is: the chat's error messages in [`modules/chat/errors.py`](../../surfsense_local/backend/modules/chat/errors.py), the license rejection reasons in [`modules/license/router.py`](../../surfsense_local/backend/modules/license/router.py), and `detail` strings that [`lib/api.ts`](../../surfsense_local/frontend/src/lib/api.ts) surfaces. Where the backend sends a code (`provider_auth`, `bad_signature`), the frontend translates the code and falls back to the English text for a code it does not know. The backend stays English and gains no i18n.
|
||||
|
||||
## Build
|
||||
|
||||
`pnpm translations` runs `formatjs compile-folder translations src/i18n/compiled --format simple --ast`, before `dev`, `build`, `typecheck` and `test`. The AST output means no message is parsed at run time, so Vite aliases `@formatjs/icu-messageformat-parser` to its `no-parser` build, as the FormatJS performance guide describes. `compile-folder` fails the build on a malformed message, and `tsc -b` fails on an id that is not in `en.json`. Everything comes from npm and the lockfile; the build needs no network.
|
||||
|
||||
## Translating
|
||||
|
||||
1. **Developers write English only.** A new string is one line in `en.json` and an `intl.formatMessage({ id })` call.
|
||||
2. **An agent translates.** The `translate` skill below fills in Japanese and German for every key that is new or whose English changed.
|
||||
3. **Review is open.** Anyone who reads the language can correct a translation in a pull request; no release waits on it.
|
||||
4. **Two pre-commit hooks guard the files.** There is no pull request CI for `surfsense_local` yet, but [`code-quality.yml`](../../.github/workflows/code-quality.yml) runs every pre-commit hook on a non-draft pull request's changed files, so both run there too.
|
||||
- `formatjs-verify` is FormatJS's own check (`formatjs verify --missing-keys --extra-keys --structural-equality`): every key translated, no extra keys, the same placeholders and tags as English. pre-commit installs `@formatjs/cli` for it.
|
||||
- `check-translations` runs [`scripts/check_translations.mjs`](../../scripts/check_translations.mjs), the rules FormatJS does not know: the key shape and prefix, a key no code in the frontend or Electron main calls, a leading or trailing space, an unsorted file. No dependencies, since that job installs nothing and the runner ships Node.
|
||||
|
||||
No machine-translation service is used: the strings are not user data, but the app's position is that nothing leaves without a decision ([ADR 0017](../adr/0017-egress-off-by-default.md)), and a skill run by a developer is that decision.
|
||||
|
||||
## The `translate` skill
|
||||
|
||||
`.agents/skills/translate/`, top level, because it runs commands.
|
||||
|
||||
- **Glossary.** Terms that stay in English (SurfSense, Studio, llama.cpp), and the chosen term per language for the rest, taken from the per-language term table in `03-international.md` so the app and the website use the same words (`ローカルLLM`, `lokale KI`).
|
||||
- **Tone.** Japanese in です/ます, the default in Microsoft's Japanese style guide, with the plain form for short labels and 〜してください for instructions. German in *du*, which macOS has used since Sierra; never mixed with *Sie*.
|
||||
- **Steps.** Find the keys to translate: those missing from a language, and those whose English differs from `en.json` at the last commit that changed that language's file. Read the component that calls each changed key, translate it, keep every placeholder, tag and ICU branch, run both checks, stop on a failure.
|
||||
- **Limits.** Never reword English. Never translate a key name, a placeholder or a tag name. Flag a string whose meaning is unclear instead of guessing.
|
||||
|
||||
## Rich text
|
||||
|
||||
A styled part of a sentence is an ICU tag, FormatJS's native rich text: `"Using <b>{name}</b> via {source}"`, rendered with `intl.formatMessage({ id }, { b: (chunks) => <span …>{chunks}</span> })`. Each language places the tag where its grammar needs it, and `formatjs verify` checks every language keeps the same tags. A value can also be an element, such as a `<RelativeTime>` in `"Last call: {time}"`. A link that is an action gets its own element and key rather than a tag inside a sentence.
|
||||
|
||||
## Build order
|
||||
|
||||
1. **Seam.** `src/i18n/`, `translations/en.json`, the compile step, the lint rule, both checks, the preload bridge and the Language setting, with one feature moved to prove the path.
|
||||
2. **Extract.** Move the remaining strings feature by feature under `src/features/`. The frontend has about 120 component files.
|
||||
3. **Translate.** The skill, then `ja.json` and `de.json`.
|
||||
4. **Ship.** Fold what is true into `docs/architecture/localization.md`, record the library choice and the ICU file format as ADRs, delete this proposal.
|
||||
Loading…
Add table
Add a link
Reference in a new issue