Skip to content

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):

ts
// 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:

ts
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:

ArtifactWhereContent
Runtime dictionariespackages/*/src/**/locales.ts + LocaleNamespaceMaplive key → template strings the UI calls t(key) with
*.i18n.yaml pairing recordsrepo root + per packageconsistency 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:

  1. the entry's namespace in the active locale,
  2. that namespace in each next language of the active locale's fallback chain (built-in BUILT_IN_LOCALE_METADATA pins zh → en; addLanguage extends the catalog with rules that must terminate at English),
  3. the shared common namespace (same per-locale chain),
  4. the raw key itself — missing text stays visible (fail loud in the UI rather than blank),
  5. then {name} template substitution.
ts
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).

txt
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:

ts
// 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:

  1. bind the durable scope: ctx.settingsScope.bind<LocaleSettings>({ namespace: LOCALE_SETTINGS_NAMESPACE }),
  2. create the LocaleRuntime and register the base dictionaries,
  3. install the service as the LocaleFace and contribute the Language row into settings.general.item:
ts
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:

FeatureNamespaceDictionary fileRegisters
Locale (shared + Language row)common, settings.localepackages/client/locale/src/locales/{en,zh}.tsin locale apply
Theme appearancesettings.themepackages/client/ui-theme/src/client/locales.tsin ui-theme apply
Settings General shell + chromesettingspackages/client/ui-settings-general/src/client/locales.tsin ui-settings-general
Provider editors (Models page)settings.modelspackages/client/ui-settings-models/src/client/locales.tsin 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 LocaleNamespaceMap merges and the t seat 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 shipped common and settings.locale dictionaries.
  • README.i18n.yaml and CONTRIBUTING.i18n.yaml at the repo root — the pairing-record format.