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: BuiltInLocaleId = 'en' (packages/client/locale/src/client/index.ts:107) — the terminal language of every fallback chain, not just the zh-dict miss. 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. Keys may be dotted to group a surface's copy — the theme dictionary (namespace settings.theme) uses appearance.title/appearance.description, plus the newer fontSize.title, fontSize.description, fontSize.increase, and fontSize.decrease keys:
export const en = {
'appearance.title': 'Appearance',
'preference.label': 'Theme',
light: 'Light', dark: 'Dark', system: 'Use system',
'fontSize.title': 'Font size',
'fontSize.increase': 'Increase', 'fontSize.decrease': 'Decrease',
}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) follows the active locale's declared fallback chain — per-locale chains that terminate at English:
- the entry's namespace in the active locale,
- that namespace in each next language of the active locale's fallback chain (built-in
BUILT_IN_LOCALE_METADATApinszh → en;addLanguageextends the catalog with rules that must terminate at English), - the shared
commonnamespace (same per-locale chain), - 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 (en). 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 (en default)
└─ Host settings section locale.preference → adopt() overrides
└─ LocaleRuntime.provisional / active → published as LocaleSnapshot
└─ syncDocumentLanguage(snapshot) → document.documentElement.lang = 'zh-CN' | 'en'The active language is kept on the document: syncDocumentLanguage (index.ts:146-150) writes document.documentElement.lang (zh-CN when active is zh, else the active id) on every real change — assisted tech and the browser's own language negotiation follow the UI, not the static lang attribute on the served shell.
Switching 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="en" at the pinned revision, and the runtime's syncDocumentLanguage keeps the live document.documentElement.lang in sync from first paint onward.
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="en" at the pinned revision (the zh UI switches it via syncDocumentLanguage once the client is live).
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.