Saltar a contenido

🌍 Internationalization (i18n)

LibreFolio supports multiple languages to ensure accessibility for a global audience. The frontend uses svelte-i18n for managing translations and locale switching.

🗣️ Supported Languages

Currently, the following languages are supported:

  • 🇬🇧 English (en) - Default
  • 🇮🇹 Italian (it)
  • 🇫🇷 French (fr)
  • 🇪🇸 Spanish (es)

🏗️ Architecture

The app loads one catalogue per language, frontend/src/lib/i18n/{en,it,fr,es}.json: index.ts registers each with svelte-i18n (register('en', () => import('./en.json'))). These four files are generated, so never edit them. Their sources are the generic vocabulary in base/, one folder per subsystem, and each plugin's own fragment:

frontend/src/lib/i18n/
├── base/{en,it,fr,es}.json        # Source: the generic vocabulary
├── <subsystem>/                   # Source: one folder per subsystem (17)
│   ├── prefixes.txt               #   the key prefixes it owns
│   └── {en,it,fr,es}.json
├── {en,it,fr,es}.json             # Generated: the catalogue the app loads (ignored by git, read-only)
├── .generated                     # Generated: the sha256 stamp of the four files
├── index.ts                       # Configuration and initialization
└── catalogIcuLocale.test.ts       # Unit test: no locale-sensitive ICU message shared by two catalogues

backend/app/services/<family>/<plugin>/i18n/{en,it,fr,es}.json   # Source: a plugin's own keys

🏭 The Generated Catalogue

scripts/i18n_catalog.py merges the sources into the four files. It uses the standard library only and runs on Python 3.9 or later, because the npm hooks call it with the system python3 (the PYTHON environment variable picks another interpreter).

  • Not tracked. frontend/.gitignore ignores the four files and the stamp. The files are read-only (0444), and .generated records the sha256 of each language; it is not a .json, so nothing takes it for a language.
  • Written only when its content changes, so a run that changes nothing leaves the files, and the bundle built from them, untouched.
  • Never edited by hand. A generated file whose content matches neither this build nor the stamp of the last one stops the next build, which names the file and asks to move the change to its source (./dev.py i18n where <key>); ./dev.py i18n build --force overwrites it.
  • Who writes it:
    • the npm pre-hooks of dev, build, build:debug, check and check:watch, through frontend/scripts/i18n-catalog-build.mjs (by hand: npm run i18n:build), so also ./dev.py front dev and ./dev.py front check;
    • Vitest's globalSetup (frontend/vitest.config.ts), before every run;
    • ./dev.py front build, before svelte-check;
    • ./dev.py lint --dead-code, before knip;
    • the AI Export real-prompt probe (backend/test_scripts/diagnostics/ai_export_real_prompt_probe.py), before it starts its frontend bridge;
    • every ./dev.py i18n command that writes.
  • Staleness. check_frontend_needs_build() (scripts/cli_base.py) asks for a frontend build when a generated file is missing, even when the bundle is fresh. Besides the sources under src/, it watches the plugin fragments and scripts/i18n_catalog.py.
  • Prettier skips the generated files (frontend/.prettierignore). The sources under src/ stay under Prettier, which writes them in the generator's own form, json.dumps(indent=2, ensure_ascii=False) plus a trailing newline: the *.json override of .prettierrc sets the two-space indent.

📚 The Sources

Every source is nested JSON with absolute keys, one file per language, and there are three kinds.

base/<lang>.json, the generic vocabulary, owns every key nobody else claims: common, datePicker, csvImport, dataEditor, sectors, table, import, prices, assetPicker, error, header, filter, sharedResource, nav, date.

<subsystem>/<lang>.json: one folder per product area, whose prefixes.txt declares the key prefixes it owns, one per line (# starts a comment). The 17 subsystems today (the prefixes.txt files are the truth):

Subsystem Prefixes
aiExport aiExport
app changelog, support, updateCheck, donationPopup, help, connectionSecurity
assets assets, assetDetail, providerErrors, events
auth auth
brokers brokers
charts chartSettings, chart, measure, signals
dashboard dashboard
dataQuality dataQuality
files uploads, fileStatus, files
fx fx, fxDetail
importWizard importWizard
onboarding onboarding
pacAllocator tools.pacAllocator
risk risk
settings settings
tools tools
transactions transactions

backend/app/services/<family>/<plugin>/i18n/<lang>.json: a plugin's own keys, beside its code (71 fragments today). Which keys a plugin may hold, how to add them and the gate that keeps them there: A Plugin's Translations, Adding a Plugin's Keys and The Ownership Gate.

🔑 Who Owns a Key

A key belongs to the plugin whose fragment holds it; otherwise to the subsystem with the longest declared prefix (tools.pacAllocator.* to pacAllocator, the rest of tools.* to tools); otherwise to base. A fragment wins over the prefixes: risk.analytics.stress.name lives in the stress plugin, the rest of risk.* in the risk subsystem. Keys several plugins share stay in their subsystem (signals.reference.* and signals.riskRollingReturn.*, in charts), and so does the risk.warnings.* vocabulary, in risk.

The sources must agree. These are errors, and the build, ./dev.py i18n check and the gate report them all at once:

  • a key in two sources;
  • a string in one source and a branch in another;
  • a key outside the folder its prefix names;
  • a prefix declared twice;
  • a subsystem without prefixes;
  • a source without English;
  • a key English lacks;
  • a value that is not a string;
  • unreadable JSON;
  • a language base does not have.

The generated file lists base first, then the subsystems by name, then the plugins by path; each source keeps its own key order in each language.

🌐 Languages and Fallback

English is mandatory: base/en.json must exist, the languages are those of base/, and a source with any file must have its en.json. When a source lacks a translation, the build fills it from English and prints a warning, so the app always ships every key; the strict check — ./dev.py i18n check, the audit and utils gate-i18n-catalog — fails on it instead. Backend tests read the catalogue with load_catalog(lang, fallback=False) from scripts/i18n_catalog.py: the sources merged in memory, without the English fallback and without a frontend build, so a test asserting "translated in every language" can still fail.

💻 Usage in Components

To use translations in Svelte components, import the t store:

<script>
  import { t } from '$lib/i18n';
</script>

<h1>{$t('dashboard.welcome')}</h1>
<p>{$t('common.save')}</p>

$lib/i18n also re-exports _, svelte-i18n's other name for the same store: $_('common.save') and $t('common.save') are interchangeable, and both appear in the codebase.

🔀 Dynamic Values

Messages use the ICU MessageFormat syntax (svelte-i18n formats them with intl-messageformat):

{
  "greeting": "Hello, {name}!",
  "positions": "{n, plural, one {# position} other {# positions}}"
}

Pass the variables under the values option:

<p>{$t('greeting', {values: {name: 'Alice'}})}</p>
<p>{$t('positions', {values: {n: 3}})}</p>

Without values the message comes back as written, placeholders included: $t('greeting', {name: 'Alice'}) renders Hello, {name}!.

⚠️ Locale-Sensitive ICU Messages

svelte-i18n 4.0.1 (the locked version) caches compiled message formatters by message text only: getMessageFormatter is wrapped in monadicMemoize, which keys the cache on its first argument and never sees the locale (frontend/node_modules/svelte-i18n/dist/runtime.js, lines 383–392 and 496). The app switches language in place, so a message that is byte-identical in two catalogues keeps the rules of whichever locale compiled it first.

This changes what the user reads only for locale-sensitive elements: plural, selectordinal, number, date and time. Plain text, {name} substitutions and select render the same in every locale. For example, 0 selects one in French but other in English: if the French catalogue reused the English positions text above verbatim, a page rendered in English first would keep showing 0 positions after a switch to French, instead of 0 position.

Rule: a locale-sensitive message must never be byte-identical across catalogues. When a translation would coincide with another locale's text, make it differ. For a French plural, add the CLDR many branch:

{
  "positions": "{n, plural, one {# position} many {# positions} other {# positions}}"
}

The unit test frontend/src/lib/i18n/catalogIcuLocale.test.ts enforces the rule on the four catalogues. It also pins the library behaviour: if a svelte-i18n upgrade adds the locale to the cache key, its characterization test turns red, which is the signal to revisit the rule.

🔢 Numbers, amounts and dates

The rule (M-18, developer decision of 2026-10-12): every figure — number, amount, percentage, exact decimal, file size, duration, date, chart axis and tooltip — is written in the interface language, through one source: uiLocale() from $lib/stores/app/uiLocale. Never in the browser's own locale, so a percentage never sits beside an amount written another way. Paths below are relative to frontend/src/.

🗺️ Which locale

resolveUiLocale(uiLanguage, browserTag) builds the locale from the interface language. The browser counts only for its region: when its primary tag (navigator.language) is in the interface language and names a region, that tag is the locale. The navigator.languages list is never read: browsers list en-US among the secondary languages of most users, so reading it would give an Italian who chose English American dates.

Browser primary tag Locale Examples
In the interface language, with a region The browser's tag, canonical en-GB, en-US, it-CH, fr-CA, es-MX, es-419
Without a region, in another language, absent (server side) or invalid The language's default: en-GB for English (12/10/2026, 12 Oct 2026, 14:05), the bare language for it, fr, es English UI on en or it-IT → en-GB; Italian UI on en-US → it

So an Italian interface on an it-CH browser writes a decimal point (12.50%), a French one on fr-CA writes numeric dates the ISO way (2026-10-12), and a bare en browser gets en-GB: kept as it is, Intl would write it American (10/12/2026, 2:05 PM).

🧰 Formatters

Every formatter below defaults to uiLocale(), so runes code passes no locale; a legacy component passes $uiLocaleStore (see Reactivity), and a unit test may pass a fixed locale to stay deterministic.

Formatter File Writes
formatPercent(value, {digits, signed, scale, suffix, empty, locale}) lib/utils/core/formatPercent.ts A percentage through Intl (style: 'percent'), rounded first with toFixed so the digits are the ones toFixed gave. digits defaults to 2 and signed to true: a + before a positive value, no sign on zero. scale: 100 for a fraction, suffix: 'pp' for percentage points, '' for a bare number.
formatFixed(value, digits, {signed, trim, locale}) lib/utils/core/formatFixed.ts The localized toFixed: the same digits and rounding, with the locale's decimal mark and grouping. trim turns digits into a maximum; signed defaults to false.
formatExactDecimal(value, {minFrac, maxFrac, locale}) lib/utils/core/formatFixed.ts An exact decimal — a backend decimal string, a quantity, a rate, a split ratio — with no float in between. The digits are those of formatDecimalForDisplay: cut after maxFrac (default 8), never rounded, trailing zeros dropped, padded to minFrac (default 0), -0 read as 0. Intl.NumberFormat is handed them as a string and only writes them. Empty input gives ''; a value that is not a plain decimal is returned unformatted.
formatCurrencyAmountPlain, formatCurrencyAmountHtml lib/utils/currency/currencyFormat.ts Money, masked under privacy; option locale.
formatDateTime(value, locale?) lib/utils/core/formatDateTime.ts A date and time, to the minute.
formatAxisDate(locale, value, withYear?) lib/utils/core/formatAxisDate.ts A short date for a chart axis or tooltip. An empty locale means uiLocale(); a bare YYYY-MM-DD is read as a local calendar day.

The domain helpers built on them take a locale parameter that defaults to uiLocale() as well:

  • lots, in lib/components/brokers/lots/: formatLotQuantity, formatLotQuantityMasked, formatLotQuantityCell, formatQuantity (Gantt), formatAxisPercent, formatAxisCurrency, formatAxisAmount, formatAxisNumber;
  • risk, in lib/components/risk/: formatCurrencyAmount, formatScopedCurrencyAmount, dayFormatter;
  • the PAC planner, in lib/features/tools/pac-allocator/planner/format.ts: formatPlannerDate, formatPlannerTimestamp, formatCopyStamp;
  • file sizes: formatBytes, in lib/utils/files/upload.ts.

🚫 Never, for display

  • toFixed or toPrecision: they write no locale at all, so an Italian screen read 9.2 beside 4788,01;
  • formatDecimalForDisplay for a figure that is only read: it writes a dot in every language, where formatExactDecimal writes the same digits in the interface locale;
  • toLocaleString(undefined, …), a toLocaleString(), toLocaleDateString() or toLocaleTimeString() with no locale, Intl.NumberFormat(undefined, …) or any other Intl.*Format(undefined, …): the browser's locale;
  • a hard-coded 'en' or 'en-US';
  • $currentLanguage or $locale as the locale of a figure: a bare language drops the browser's region, and a bare en writes American dates.

$currentLanguage and $locale stay right for text, which this rule does not cover: translations, country, currency and sector names, Intl.ListFormat, the AI Export response language. Name collation (nameComparator) is text as well; its callers pass no locale today, so it sorts by the browser's rules.

⚡ Reactivity

uiLocale() reads the store through Svelte's fromStore, so the read is tracked wherever Svelte tracks:

  • Runes components: a read in a template, a $derived or an $effect is tracked, even inside a helper's default parameter, so {formatPercent(value)} follows an in-place language switch.
  • Legacy components (no runes: export let, $:): a function call in a template expression is compiled into $.untrack(…), so a read inside the helper is untracked and the figure keeps the language of the mount. The template reads $uiLocaleStore itself and passes it down: formatSharePercentage(broker.user_share_percentage, $uiLocaleStore) in routes/(app)/brokers/[id]/+page.svelte, formatDate($currentUser?.created_at, $uiLocaleStore) in lib/components/settings/tabs/ProfileTab.svelte. It is the trap behind the privacy freeze (devWiki: problems/legacy-component-language-untracked).
  • ECharts: formatter and renderItem callbacks run outside any tracking. The $effect that calls setOption reads the locale itself, void uiLocale(); beside its other void dependencies, so a switch rebuilds the option and redraws the chart (lib/components/charts/ScatterChart.svelte), as it does for the privacy flag.

🧩 Known exceptions

  • Decimal fields: formatDecimalForDisplay (lib/utils/core/formatDecimal.ts) stays locale-neutral, with a dot, wherever its output must read back through normalizeDecimalInput (lib/utils/core/parseDecimalInput.ts): the value of ExactDecimalInput and ExactQuantityInput, the edit buffer of CompactCashCell. Localizing a field means localizing its reading too. It also serves logic that displays nothing: pluralCount in the PAC planner's format.ts, and the "did the amount change" comparison of WacPreviewSection.
  • Native date fields: an <input type="date"> is drawn by the browser, in the browser's language (backlog T-DI).
  • Figures inside catalogue messages (backlog T-ICU): svelte-i18n writes an ICU number argument and the # of a plural in its own locale, the bare language, without uiLocale()'s region. The only number argument is {covered, number, percent} in risk.warnings.historical_replay_mostly_excluded, and the catalogues hold no ICU dates. It shows only where the region changes the notation: on an es-MX browser a plural's # reads 1234 where the rest of the page writes 1,234.
  • The AI Export prompt writes its figures locale-neutral by design, so uiLocale() never changes a byte of it; the interface language reaches it only as text (the response language, the translated additional-data section). The panel's token count and byte size, and the copied message, are figures like any other.
  • Currency minor digits: new Intl.NumberFormat('en', {style: 'currency', currency}) only reads maximumFractionDigits (lib/features/ai-export/templates/snapshotDataRenderer.ts, lib/features/tools/pac-allocator/planner/format.ts); it writes nothing.
  • The time zone: Intl.DateTimeFormat().resolvedOptions().timeZone (lib/components/settings/tabs/GlobalSettingsTab.svelte) reads the browser's zone, not a notation.

🧪 Testing

  • Drive the language with currentLanguage.set(…) ($lib/stores/app/language), as the language selector does, and set it back to English after the test.
  • Pin navigator.language when the region matters: the test runtime's tag can follow the host (LANG=it_CH.UTF-8 gives it-CH). Stub it before the first import, as lib/utils/core/__tests__/formatPercent.test.ts does with vi.hoisted(() => { vi.stubGlobal('navigator', {language: 'en-US', languages: ['en-US']}); }), or per test with vi.stubGlobal, vi.resetModules() and a fresh import(), as lib/stores/app/uiLocale.test.ts does.
  • Compute a localized expectation with Intl in the same runtime, never as a typed literal: French, Italian and Spanish output carries no-break spaces (U+202F, U+00A0), and ICU has changed which one goes where between releases. Check that the expectation differs from the English text, or the case proves nothing.
  • Switch the language in place, after mount and in both directions: only that catches a legacy freeze.
  • Two source gates read figure sites. The HTML escaping gate (htmlInterpolation.gate.test.ts) trusts a computed-key read passed as the first argument of formatFixed or formatPercent, as it trusts x[k].toFixed(n): only those two names and only that argument, so formatExactDecimal(x[k]) in an HTML template is still reported. The money gate (lib/utils/privacy/moneyRenderSites.test.ts) keys its registry by file and snippet: rewriting a registered expression, a toFixed into a formatFixed, means updating its snippet.

🛠️ Managing Translations

LibreFolio provides a full CLI toolkit for the translation sources via ./dev.py i18n (./dev.py i18n --help gives the exact text of every command). Every command that writes rebuilds the catalogue.

🧭 Where a Key Goes

./dev.py i18n where KEY names the file that holds a key, or the one add would write it to:

$ ./dev.py i18n where common.save
frontend/src/lib/i18n/base/en.json (exists)
$ ./dev.py i18n where brokers.newFeatureTitle
frontend/src/lib/i18n/brokers/en.json (new key)
$ ./dev.py i18n where signals.rsi.newLevel
backend/app/services/signal_plugins/rsi/i18n/en.json (new key)
$ ./dev.py i18n where myFeature.title
frontend/src/lib/i18n/base/en.json (new key; 'myFeature' is a new namespace in base, the generic vocabulary: if it belongs to a subsystem or a plugin, use --into, or move it (dev.py i18n move myFeature --into <subsystem|family/plugin>))

A new key goes to the owner its path names (Who Owns a Key): under the branch a plugin's keys share (signals.rsi above), to that plugin; under a branch that only plugins share (brimPlugins.<new>, risk.analytics.<new>, assets.providerParams.<new>), nowhere — add refuses and asks for --into <family>/<plugin>; otherwise to the subsystem with the longest declared prefix, else to base. A key that opens a new top-level namespace in base, which holds only the generic vocabulary, comes with the notice above: if it belongs to a subsystem or a plugin, name the target with --into, or move it.

➕ Add, Update, Remove Keys

# Add a key in the four languages, to the source that owns it
./dev.py i18n add "dashboard.new_feature" --en "New Feature" --it "Nuova Funzionalità" --fr "Nouvelle Fonctionnalité" --es "Nueva Característica"

# Name the source: base, a subsystem, or a plugin as <family>/<plugin> (its folder must hold plugin.py)
./dev.py i18n add "signals.typicalPrice.name" --en "Typical Price" --it "…" --fr "…" --es "…" --into signal_plugins/typical_price

# Update an existing key in specific languages, in the source that holds it
./dev.py i18n update "dashboard.title" --en "My Dashboard" --it "La mia Dashboard"

# Remove a key from every source and language
./dev.py i18n remove "obsolete.key"

# Remove without confirmation prompt
./dev.py i18n remove "obsolete.key" -f

add prints the file it wrote (📁 Source: …/<lang>.json). Without --into, a key that opens a new namespace in base also prints the notice; an explicit --into is the author's choice and prints none. Always pass --into for a plugin's keys (Known Limits). Removing the last key of a plugin's fragment deletes the fragment.

🔎 Search & Explore

# Search for keys or values containing a query (keys + values in every language)
./dev.py i18n search "broker"

# Search only in keys
./dev.py i18n search "broker" -k

# Search only in values
./dev.py i18n search "broker" -v

# Restrict the value search to one or more languages (comma-separated)
./dev.py i18n search "courtier" -l fr

# Show key tree structure
./dev.py i18n tree

# Show tree for a prefix with key counts
./dev.py i18n tree "fx" --counts

search shows every key it finds in the four languages, followed by the source that owns it (the Source column).

⚙️ Build and Check

# Regenerate src/lib/i18n/<lang>.json from the sources
./dev.py i18n build

# A missing translation is an error instead of an English fallback
./dev.py i18n build --strict

# Overwrite a generated file edited by hand
./dev.py i18n build --force

# Validate the sources, strictly: every rule above, every language complete
./dev.py i18n check

# Accept a missing translation, as the build does
./dev.py i18n check --lenient

# Also prove the catalogue equal to the one at a git revision
./dev.py i18n check --against <rev>

--against compares key by key and language by language, without the English fallback. It reads the revision in either layout: the four tracked single files of the old layout, or the sources tracked at that revision. It is the proof that a reorganisation lost nothing.

🚚 Move, Split, Absorb

# Move every key under a prefix, in every language, to another source
./dev.py i18n move "signals.typicalPrice" --into signal_plugins/typical_price

# Put every key of base and of the subsystems in the folder its declared prefix names
./dev.py i18n split

# Replay what a branch changed in the catalogue since its merge-base
./dev.py i18n absorb --base <merge-base> --theirs <branch>
  • move PREFIX --into SOURCE moves a key, or every key under a prefix, in every language. SOURCE is base, a subsystem, or <family>/<plugin>. A move that would break a rule of Who Owns a Key — a key outside the folder its prefix names — is refused and writes nothing: to hand a branch to another subsystem, declare its prefix there and run split.
  • split puts every key of base and of the subsystems where its declared prefix says, and leaves the plugins' keys alone. It is re-runnable: with nothing to move it answers «Every key is already in place».
  • absorb --base REV --theirs REV [--prefer ours|theirs] replays on the sources what a branch on an older layout changed in the catalogue. It is a three-way merge of base, ours (the sources here) and theirs, per key and language: what only theirs changed is applied, a new key going where add would put it, and a key one side removed while the other changed one of its translations is one conflict on the whole key. Without --prefer every conflict is listed and nothing is written; --prefer settles them all one way. All or nothing: any problem writes nothing. A new key under a branch only plugins share has no owner to guess: add it first with add --into, then absorb again.

🔍 Audit

Report translation coverage, key usage and, optionally, duplicate values across the four languages. The audit reads the sources, merged per language without the English fallback, so a translation a source lacks is reported missing; it starts with the strict check of the sources (Checking the sources (strict)):

# Default: warnings only, on the terminal (no full translation table)
./dev.py i18n audit

# Include the duplicate-value analysis
./dev.py i18n audit --duplicates

# Full Markdown report (adds the complete translation table), on the terminal
./dev.py i18n audit --format md

# Excel workbook: i18n-audit.xlsx in the current directory
./dev.py i18n audit --format xlsx

# Markdown and Excel files in a chosen directory
./dev.py i18n audit --format both --output ./reports/

# Markdown report to a chosen file
./dev.py i18n audit --format md --output i18n-report.md
Flag Description
--format, -f none (default): warnings only, on the terminal. md: the full Markdown report, on the terminal or, with --output, in a file. xlsx: an Excel workbook. both: the md and xlsx files.
--output, -o Directory or file path for file output (default: the current directory). A directory receives i18n-audit.md / i18n-audit.xlsx; a path ending in .md or .xlsx is used as given.
--duplicates, -d Also report keys sharing the same value (case-insensitive), grouped by how many of the four languages share it: 🔴 all four, 🟠 three, 🟡 two, 🟢 one.

The key-usage verdicts below are part of the Markdown output only (terminal or .md file). The workbook holds the translation table, the missing translations, a coverage summary and, with --duplicates, one sheet per tier. The command exits with status 1 when a key the backend assigns in a metadata field (message_i18n_key="…", label_key="…", an x-i18n-key schema entry) is missing from the catalogue, or when the sources break a rule of the strict check (Source problems in the summary); unused keys never fail it.

🧾 What the Audit Reports

Besides coverage (keys missing in some languages) and the optional duplicates, the audit gives every catalogue key one of three verdicts. The classifier is classify() in scripts/i18n_usage.py, called by generate_unused_keys_report() in frontend/scripts/i18n-audit.py.

Verdict Meaning Report section
used Proven by the evidence below. not listed
not verified Under a real dynamic family, or only under a legacy truncated prefix (listed as Dynamic prefixes detected): the final segment is built at runtime and named nowhere. Not evidence of death. 🔵 Not Verified
dead No evidence anywhere. The only actionable list. ❌ Likely Unused

How the evidence is gathered:

  • Product sources only: *.svelte, *.ts and *.js under frontend/src/, plus the Python sources of backend/app/. Tests prove nothing: *.test.ts, *.spec.ts (and their .js twins), anything under __tests__/ or __mocks__/. Generated clients are skipped too, even where a source imports them: ./dev.py api sync writes generated.ts, generated-tools.ts and *.generated.ts into frontend/src/lib/api/ and git ignores them, so a verdict resting on them would change on a fresh clone. The backend they are generated from is read directly anyway.
  • Any whole-key literal counts, whatever receives it: wrappers such as translateOr($_, 'k', …), label('k', …) or tr('k', …), props such as afterCopyKey: 'k'. Backend literals count too: dictionaries, ..._i18n_key= fields.
  • Templates are expanded: string constants (`${NS}.leaf`), literal conditionals (cond ? 'a.b' : 'a.c'), typed unions and single-literal parameter types (prefix: 'errors'), nested templates, camelCase continuations (`…scheduler.historyDays${day}`) and 'ns.' + code concatenations. A final segment built at runtime leaves a family; backend f-strings such as f"aiExport.additionalData.reason.{reason}" are families too.
  • Family members are proven by the producer's vocabulary or by what the file building the family names (titleKey: values, the values of an indexed map). The vocabulary is the backend's string literals, and a member also matches a code spelling it in capitals or in lower case (IN_TRANSIT proves brokers.lots.modal.state.in_transit); a code may begin with a digit ("3m" proves aiExport.additionalData.period.3m), but a number or a sentence is no code. A narrow family, at least two segments deep (risk.assetSet.band.) or a camelCase continuation, also accepts the words its file quotes and those of the modules that file imports directly, in any form (static, type-only, re-export, dynamic import()) with a relative or $lib specifier: CorrelationHeatmap.svelte builds `risk.assetSet.band.${band}` and the band names live in the correlationHelpers.ts it imports. One hop only: a word two imports away, or in a neighbouring module the file does not import, proves nothing, and a wide family (`ns.${x}`) reads no import.
  • Shapes: a template with several runtime segments, or with one before a literal tail (`assets.providerParams.${code}.${kind}.${field.key}`, `assets.panels.${panel.id}Hint`), keeps its shape. Each runtime segment becomes a slot of exactly one segment, and a segment that resolves (a constant, a union such as kind: 'label' | 'description') takes only its values. A key with that shape is used when every slot is a word of the building file or of a module it imports directly, or a backend code. One slot named nowhere leaves the key not verified, a key with an extra segment does not match, and a template without a dotted literal head (`${a}.${b}`) has no shape: any two quoted words would make a key.
  • 👻 Backend Families Without Catalogue Keys lists the prefixes the backend builds inside an existing namespace but with no catalogue key under them. It is informational and never counts as unused.
  • 📦 Kept Alive Only by Unreferenced Sources lists the keys whose verdict would weaken without the sources no SvelteKit entry reaches, each followed by the verdict it would then get (→ dead or → not verified), then every unreferenced source: a file nothing reaches renders nothing, yet still proves the keys it reads. The entries are the route files under frontend/src/routes/ (+page, +layout, +error.svelte, +server, .server variants and layout resets such as +page@.svelte included), the hooks at the root of frontend/src/ (hooks.ts, hooks.server.ts, hooks.client.ts) and the service worker; reachability follows every import form listed above, dynamic import() included. A .d.ts file is never reported, and a test is no source, so a component imported only by tests is unreferenced. The section appears only when some key depends on such a source. It is informational, never counts as unused and changes no verdict: decide whether the source is dead, then remove it with its keys.

🛡️ Gates

Unit What it holds
./dev.py test utils gate-i18n-catalog The library's rules, each proven both ways on synthetic trees, and the repository's sources: a strict merge, the four languages, the generator's format, the ignore rules, and no generated file tracked beside base/ (backend/test_scripts/test_utilities/test_i18n_catalog_gate.py).
./dev.py test utils gate-plugin-i18n-ownership Each plugin's keys in its own fragment, judged by the metadata its registry publishes: see The Ownership Gate.
./dev.py test utils gate-i18n-usage The audit's three-verdict classifier, scripts/i18n_usage.py (see What the Audit Reports).

🚧 Known Limits

  • A plugin mounted at runtime has no translations (U-B1). The fragments are merged when the frontend is built, so a plugin added with a docker-compose volume has its code but not its keys, and the UI shows its untranslated fallback.
  • The development server does not watch the sources (U-B6). npm run dev generates the catalogue when it starts; a source you edit by hand reaches it only after ./dev.py i18n build. The dev.py i18n commands rebuild by themselves.
  • add does not know the plugins' spaces (U-B7). Without --into, the first key of a new signal or Tool lands in a subsystem (charts, tools), and only utils gate-plugin-i18n-ownership catches it: always pass --into <family>/<plugin> for a plugin's keys.