Internationalization
Verql centralizes every user-facing string in a typed message catalogue so copy
can be edited in one place and the app can be translated later. The subsystem is
homegrown, dependency-free, and cross-process — the same catalogue and
t() work in the renderer (React) and the main process (menus, etc.).
Read order: this doc →
shared/i18n/(the core) →src/renderer/src/i18n/(the React layer). Keep this doc in sync with the code when you change the catalogue shape or the resolution rules.
- One home for copy. No hardcoded UI strings; everything resolves through a key. Editing wording is a catalogue change, not a code hunt.
- Translation-ready. English ships in-bundle as the default and the structural source of truth; other locales register at runtime and fall back to English per-key.
- Cross-process. Both Electron processes import the same core.
- Lean. No runtime dependency; a tiny formatter instead of a full ICU engine.
- Plugin-friendly. Plugins can ship their own catalogues without touching the host’s, respecting the glue↔plugin boundary.
Architecture
Section titled “Architecture”flowchart TD
subgraph shared["shared/i18n (framework-free, both processes)"]
fmt["format.ts<br/>formatMessage(): {placeholders} + {n, plural, …}"]
cat["locales/en/*<br/>per-domain catalogue files"]
idx["locales/en/index.ts<br/>composes en = { common, command, … }"]
core["index.ts<br/>registry: locale state,<br/>registerLocale / setLocale /<br/>subscribeLocale, translate()/t()"]
types["types.ts<br/>Locale, MessageKey (typed dotted paths)"]
cat --> idx --> core
fmt --> core
idx --> types
types --> core
end
subgraph renderer["renderer (React)"]
prov["i18n/I18nProvider.tsx<br/><I18nProvider> syncs locale from<br/>settings.general.language"]
hook["useTranslation() → { t, locale }<br/>(useSyncExternalStore over subscribeLocale)"]
comp["components & stores<br/>t('settings.general.queryTimeout.label')"]
end
subgraph main["main process"]
menu["index.ts native menu<br/>t('menu.file')"]
end
subgraph plugins["plugins (future)"]
pcat["registerLocale('fr', {…})<br/>ship their own catalogue"]
end
core --> prov
prov --> hook --> comp
core --> menu
pcat -. registerLocale .-> core
The core — shared/i18n/
Section titled “The core — shared/i18n/”| File | Responsibility |
|---|---|
format.ts | formatMessage(template, vars) — the ICU subset formatter: {name} placeholders and {count, plural, one {# row} other {# rows}} (with optional =N exact matches; # renders the count). English plural rules. No dependencies. |
locales/en/*.ts | The English catalogue, one file per domain (common, command, settings, connections, menu, …). Each exports const <domain> = { … } as const. |
locales/en/index.ts | Composes the domain modules into en — the structural source of truth. |
index.ts | The registry: holds the active locale + registered catalogues, exposes registerLocale, setLocale, getLocale, availableLocales, subscribeLocale, and translate() / t(). |
types.ts | Locale, Messages, MessageKey (typed dotted key paths derived from en), LocaleCatalog (a deep-partial of en). |
The React layer — src/renderer/src/i18n/I18nProvider.tsx
Section titled “The React layer — src/renderer/src/i18n/I18nProvider.tsx”<I18nProvider>keeps the core’s active locale in sync with the user’sgeneral.languagesetting.useTranslation()returns{ t, locale }; it re-renders its consumers when the locale changes (viauseSyncExternalStoreover the core’ssubscribeLocale).useLocale()exposes the active locale on its own.
How translation resolves
Section titled “How translation resolves”t(key, vars) resolves a key against the active locale, then English,
then falls back to the raw key (so a missing string is visible, never blank).
The chosen template is then formatted with vars.
sequenceDiagram
participant C as Component / menu
participant Core as t() (shared/i18n)
participant Cat as catalogs
C->>Core: t('connections.connected', { name: 'pg' })
Core->>Cat: lookup(activeLocale, key)
alt found in active locale
Cat-->>Core: template
else missing
Core->>Cat: lookup('en', key)
alt found in English
Cat-->>Core: English template
else still missing
Core-->>Core: use key itself
end
end
Core->>Core: formatMessage(template, vars)
Core-->>C: "Connected to pg"
Switching locale (reactivity)
Section titled “Switching locale (reactivity)”sequenceDiagram
participant S as Settings (general.language)
participant P as I18nProvider
participant Core as i18n core
participant Subs as useTranslation consumers
S->>P: language changes (e.g. 'fr')
P->>Core: setLocale('fr')
Core->>Subs: notify() (subscribeLocale listeners)
Subs->>Subs: useSyncExternalStore re-render
Subs->>Core: t(key) → French (or English fallback)
The main process is not reactive: it calls setLocale() once (if needed) and
then t() when building the menu. Rebuilding the menu on a live locale change is
not wired yet (English-only today).
Key naming convention
Section titled “Key naming convention”domain.surface.key, lowerCamelCase leaves. Examples:
common.resetToDefaultscommand.category.viewsettings.general.queryTimeout.label/.descriptionconnections.connected→'Connected to {name}'menu.about→'About {appName}'
Group by where the string appears, not by component name, so moving a component doesn’t churn keys.
Authoring strings
Section titled “Authoring strings”Add or change a string
Section titled “Add or change a string”- Add the key to the right
shared/i18n/locales/en/<domain>.tsfile (create a new domain module + import it inlocales/en/index.tsif needed). - Use it:
- React:
const { t } = useTranslation()thent('domain.key'). - Stores / non-React / main:
import { t } from '@shared/i18n'.
- React:
MessageKeyis derived fromen, so unknown keys are a compile error and keys autocomplete.
Interpolation & plurals
Section titled “Interpolation & plurals”t('connections.connected', { name }) // "Connected to {name}"t('explorer.rowCount', { count }) // "{count, plural, one {# row} other {# rows}}"- Placeholders:
{name}— unknown names are left as{name}(visible, not a crash). - Plurals:
{count, plural, one {…} other {…}}, optional=0 {…};#renders the count. English rules only — a locale can extendpluralCategoryinformat.ts.
Add a locale
Section titled “Add a locale”import { registerLocale, setLocale } from '@shared/i18n'
registerLocale('fr', { common: { resetToDefaults: 'Réinitialiser' }, connections: { connected: 'Connecté à {name}' }, // …partial; missing keys fall back to English})setLocale('fr')A locale catalogue is a deep-partial of en (LocaleCatalog), so you only
translate what you have; everything else falls back to English.
Plugins
Section titled “Plugins”Plugins localize their own copy by calling registerLocale(locale, partial)
with their namespace — the host never holds plugin strings. Driver-authored copy
(connection-field labels, SQL-autocomplete detail: text) stays plugin-owned and
is migrated via plugin catalogues, not the host catalogue.
In scope: all host chrome — menus, settings, command palette, toasts/ notifications, dialogs, explorer/query/plugin UI, errors. Out of scope (for now): plugin-authored domain strings (SQL-autocomplete reference text, driver field labels), which belong to the plugins.
Testing
Section titled “Testing”tests/unit/i18n-format.test.ts— the formatter (interpolation, plurals,=N, nested placeholders).tests/unit/i18n-core.test.ts— resolution order, locale switch, partial fallback,registerLocale, subscribe semantics.