๐ 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/.gitignoreignores the four files and the stamp. The files are read-only (0444), and.generatedrecords 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 --forceoverwrites it. - Who writes it:
- the npm pre-hooks of
dev,build,build:debug,checkandcheck:watch, throughfrontend/scripts/i18n-catalog-build.mjs(by hand:npm run i18n:build), so also./dev.py front devand./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 i18ncommand that writes.
- the npm pre-hooks of
- 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 undersrc/, it watches the plugin fragments andscripts/i18n_catalog.py. - Prettier skips the generated files (
frontend/.prettierignore). The sources undersrc/stay under Prettier, which writes them in the generator's own form,json.dumps(indent=2, ensure_ascii=False)plus a trailing newline: the*.jsonoverride of.prettierrcsets 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
basedoes 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):
Pass the variables under the values option:
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:
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, inlib/utils/files/upload.ts.
๐ซ Never, for display
toFixedortoPrecision: they write no locale at all, so an Italian screen read9.2beside4788,01;formatDecimalForDisplayfor a figure that is only read: it writes a dot in every language, whereformatExactDecimalwrites the same digits in the interface locale;toLocaleString(undefined, โฆ), atoLocaleString(),toLocaleDateString()ortoLocaleTimeString()with no locale,Intl.NumberFormat(undefined, โฆ)or any otherIntl.*Format(undefined, โฆ): the browser's locale;- a hard-coded
'en'or'en-US'; $currentLanguageor$localeas the locale of a figure: a bare language drops the browser's region, and a bareenwrites 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
$derivedor an$effectis 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$uiLocaleStoreitself and passes it down:formatSharePercentage(broker.user_share_percentage, $uiLocaleStore)inroutes/(app)/brokers/[id]/+page.svelte,formatDate($currentUser?.created_at, $uiLocaleStore)inlib/components/settings/tabs/ProfileTab.svelte. It is the trap behind the privacy freeze (devWiki:problems/legacy-component-language-untracked). - ECharts:
formatterandrenderItemcallbacks run outside any tracking. The$effectthat callssetOptionreads the locale itself,void uiLocale();beside its othervoiddependencies, 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 throughnormalizeDecimalInput(lib/utils/core/parseDecimalInput.ts): the value ofExactDecimalInputandExactQuantityInput, the edit buffer ofCompactCashCell. Localizing a field means localizing its reading too. It also serves logic that displays nothing:pluralCountin the PAC planner'sformat.ts, and the "did the amount change" comparison ofWacPreviewSection. - 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
numberargument and the#of a plural in its own locale, the bare language, withoutuiLocale()'s region. The onlynumberargument is{covered, number, percent}inrisk.warnings.historical_replay_mostly_excluded, and the catalogues hold no ICU dates. It shows only where the region changes the notation: on anes-MXbrowser a plural's#reads1234where the rest of the page writes1,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 readsmaximumFractionDigits(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.languagewhen the region matters: the test runtime's tag can follow the host (LANG=it_CH.UTF-8givesit-CH). Stub it before the first import, aslib/utils/core/__tests__/formatPercent.test.tsdoes withvi.hoisted(() => { vi.stubGlobal('navigator', {language: 'en-US', languages: ['en-US']}); }), or per test withvi.stubGlobal,vi.resetModules()and a freshimport(), aslib/stores/app/uiLocale.test.tsdoes. - Compute a localized expectation with
Intlin 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 offormatFixedorformatPercent, as it trustsx[k].toFixed(n): only those two names and only that argument, soformatExactDecimal(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, atoFixedinto aformatFixed, 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 SOURCEmoves a key, or every key under a prefix, in every language.SOURCEisbase, 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 runsplit.splitputs every key ofbaseand 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 whereaddwould put it, and a key one side removed while the other changed one of its translations is one conflict on the whole key. Without--preferevery conflict is listed and nothing is written;--prefersettles 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 withadd --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,*.tsand*.jsunderfrontend/src/, plus the Python sources ofbackend/app/. Tests prove nothing:*.test.ts,*.spec.ts(and their.jstwins), anything under__tests__/or__mocks__/. Generated clients are skipped too, even where a source imports them:./dev.py api syncwritesgenerated.ts,generated-tools.tsand*.generated.tsintofrontend/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', โฆ)ortr('k', โฆ), props such asafterCopyKey: '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.' + codeconcatenations. A final segment built at runtime leaves a family; backend f-strings such asf"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_TRANSITprovesbrokers.lots.modal.state.in_transit); a code may begin with a digit ("3m"provesaiExport.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, dynamicimport()) with a relative or$libspecifier:CorrelationHeatmap.sveltebuilds`risk.assetSet.band.${band}`and the band names live in thecorrelationHelpers.tsit 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 askind: '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 (
โ deadorโ 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 underfrontend/src/routes/(+page,+layout,+error.svelte,+server,.servervariants and layout resets such as+page@.svelteincluded), the hooks at the root offrontend/src/(hooks.ts,hooks.server.ts,hooks.client.ts) and the service worker; reachability follows every import form listed above, dynamicimport()included. A.d.tsfile 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 devgenerates the catalogue when it starts; a source you edit by hand reaches it only after./dev.py i18n build. Thedev.py i18ncommands rebuild by themselves. adddoes 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 onlyutils gate-plugin-i18n-ownershipcatches it: always pass--into <family>/<plugin>for a plugin's keys.