The web UI localizes through one service, ctx.locale (@deepseek-ai/dsh-client-locale). Its unit of translation is a namespace → per-locale dictionary, and its extension point is the typed LocaleNamespaceMap that every module merges into — the translation twin of SlotMap.
Catalog structure
Dictionaries are flat key → template-string maps; params use {name} placeholders. Two locale ids ship: LOCALE_IDS = ['zh', 'en'], with FALLBACK_LOCALE: LocaleId = 'zh'. A namespace registration declares every shipped locale in one call (bilingual balance is enforced at registration):
// package client/index.ts
locale.register(COMMON_NS, { zh, en })
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })COMMON_NS = 'common' is the shared cross-feature vocabulary. Module dictionaries live beside their React code — e.g. packages/client/locale/src/locales/{en,zh}.ts, packages/client/ui-theme/src/client/locales.ts, packages/client/ui-settings-models/src/client/locales.ts — and each owner merges its key set into LocaleNamespaceMap so a missing/extra key at a typed registration is a compile error.
A dictionary is a plain object literal of key → template string. A typical excerpt (theme's Appearance copy) reads:
export const en = {
appearanceTitle: 'Appearance',
preferenceLabel: 'Theme',
light: 'Light', dark: 'Dark', system: 'Use system',
}The locale package itself merges two namespaces: common (shared vocabulary) and settings.locale (the Language row's own copy). Because LocaleDictOf<N> is Record<LocaleNamespaceMap[N] & string, string>, a namespace's key union is its single source of truth: typo a key in the en dictionary and the registration call fails to compile, not the UI at runtime.
The two dictionary faces: runtime vs docs
The same word "locale" spans two very different artifacts:
| Artifact | Where | Content |
|---|---|---|
| Runtime dictionaries | packages/*/src/**/locales.ts + LocaleNamespaceMap | live key → template strings the UI calls t(key) with |
*.i18n.yaml pairing records | repo root + per package | consistency hashes of EN/ZH document pairs, not text |
It is a common trap to assume README.i18n.yaml holds translations — it holds hashes, and the actual prose lives in README.md / README.zh.md side by side. See the .i18n.yaml convention below.
The LocaleRuntime
LocaleRuntime (packages/client/locale/src/client/index.ts) keeps a Map<namespace, Map<locale, dict>> and a monotonic revision. Its lookup chain per key (translate):
- the entry's namespace in the active locale,
- that namespace's zh fallback,
- the shared
commonnamespace (active, then zh), - the raw key itself — missing text stays visible (fail loud in the UI rather than blank),
- then
{name}template substitution.
private translate(ns, key, params?) {
const template = this.lookup(ns, key)
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (m, name) =>
name in params ? String(params[name]) : m)
}bind(ns) returns a stable Translate (memoized, so it can ride inject surfaces), and register bumps the revision so mounted outlets pick up late-arriving dictionaries.
Language resolution
resolveInitialLocale() runs at construction: the browser's language wins (detectBrowserLocale() over navigator.languages then navigator.language, matched on the primary subtag so zh-Hans-CN → zh), otherwise FALLBACK_LOCALE (zh). An explicit Host selection may then override it: the host plugin registers the durable locale.preference settings section (LocaleSettingsSchema, a schemastery union over the two ids, optional), and the client binds it through a SettingsScope<LocaleSettings>; adopt() swaps the active locale when the stored preference differs. Writes go only through setLocale(id), which persists via host.set(LOCALE_PREFERENCE_FIELD, id).
navigator.languages → detectBrowserLocale() → provisional locale (zh default)
└─ Host settings section locale.preference → adopt() overrides
└─ LocaleRuntime.provisional / active → published as LocaleSnapshotSwitching locales emits ctx.emit('locale/change', snapshot) only on a real active-locale change — dictionary registrations do not storm the event (they bump the LocaleFace revision instead). The section also holds the ui-theme Appearance row, so language and theme live in the same settings General stack.
The renderer LocaleFace and the t seat
The service itself IS the LocaleFace (bind + getSnapshot/subscribe), installed once into the shell:
// locale client apply
ctx.slots.installLocale(locale)With a face installed, any slot registration that declares a locale: namespace gets a framework-synthesized t seat on its component props, typed to that namespace's dictionary union plus the shared common vocabulary (TranslateNS<N>). The active language then follows automatically — components call props.t('key', {name}) and re-render when the LocaleFace revision changes.
The Language preference row
The locale feature owns its own settings surface, exactly as ui-theme owns Appearance. apply (locale client) does three things in order:
- bind the durable scope:
ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE }), - create the
LocaleRuntimeand register the base dictionaries, - install the service as the LocaleFace and contribute the Language row into
settings.general.item:
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
locale: SETTINGS_NS, // → framework-synthesized `t` seat
inject: injected, // → { setLocale } business face
}, LanguageRow))LanguageRow.tsx / createLanguageRowStore keep a snapshot of { active, locales, revision }; the injected setLocale proxies to locale.setLocale(id). LOCALE_IDS is ['zh', 'en'] and the row labels each self-described locale (中文 / English) from LocaleDefinition.label, so the switcher never depends on an already-translated environment. The row subscribes to locale/change to re-sync its current value and revision.
Dictionaries per package, namespaces per feature
Because cross-plugin collaboration is service-only (the client bundle purity gate), each package reaches ctx.locale and imports nothing; it just calls register/bind. The namespace identity is what keeps dictionaries namespaced-safe; two features may even share a memory-of-lookup without collision as long as keys differ, but the typed pattern is one namespace per feature:
| Feature | Namespace | Dictionary file | Registers |
|---|---|---|---|
| Locale (shared + Language row) | common, settings.locale | packages/client/locale/src/locales/{en,zh}.ts | in locale apply |
| Theme appearance | settings.theme | packages/client/ui-theme/src/client/locales.ts | in ui-theme apply |
| Settings General shell + chrome | settings | packages/client/ui-settings-general/src/client/locales.ts | in ui-settings-general |
| Provider editors (Models page) | settings.models | packages/client/ui-settings-models/src/client/locales.ts | in ui-settings-models |
Registering a namespace that is not yet merged into LocaleNamespaceMap uses the second, untyped register overload; typed registers are the norm inside the shipped composition.
Because the row exposes no translated label of its own — each option's label is the locale's self-description (中文 / English) — the switcher remains usable before the active locale is known. apps/web/index.html declares lang="zh-CN" statically, giving the bundled page and assistive tech a language hint from first paint.
The .i18n.yaml convention (docs pairing)
Do not confuse the runtime dictionaries with the repo's *.i18n.yaml files (CONTRIBUTING.i18n.yaml, README.i18n.yaml, and per-package README.i18n.yaml). Those are bilingual-pair consistency records, not translation payloads: each records the git blob hash of the English and Chinese sides of a paired document at the last confirmed-consistent state, so pnpm run verify-translation-pairing can detect drift. Docs themselves are edited as EN/ZH file pairs (e.g. README.md / README.zh.md, packages/client/locale/README.md / README.zh.md).
The web shell's routing hardware is bilingual too: apps/web/index.html declares lang="zh-CN" at the pinned revision.
Packages in this section
| Package |
|---|
@deepseek-ai/dsh-client-locale |
@deepseek-ai/dsh-client-ui-theme |
@deepseek-ai/dsh-client-ui-slots |
@deepseek-ai/dsh-client-ui-settings |
@deepseek-ai/dsh-client-web |
Further reading
- Frontend: UI modules — how
LocaleNamespaceMapmerges and thetseat plug into slots. - Frontend: The web frontend —
installLocale, the LocaleFace, and the boot chain. - Frontend: Schema form — settings scopes that both locale and theme rows bind through.
packages/client/locale/src/client/index.ts—LocaleRuntime,translate,resolveInitialLocale.packages/client/locale/src/locales/{en,zh}.ts— the shippedcommonandsettings.localedictionaries.README.i18n.yamlandCONTRIBUTING.i18n.yamlat the repo root — the pairing-record format.