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: LocaleId = 'zh'. 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. A typical excerpt (theme's Appearance copy) reads:

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

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

  1. the entry's namespace in the active locale,
  2. that namespace's zh fallback,
  3. the shared common namespace (active, then zh),
  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 (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).

txt
navigator.languages → detectBrowserLocale() → provisional locale (zh default)
   └─ Host settings section locale.preference → adopt() overrides
   └─ LocaleRuntime.provisional / active  →  published as LocaleSnapshot

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="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 LocaleNamespaceMap merges and the t seat plug into slots.
  • Frontend: The web frontendinstallLocale, 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.tsLocaleRuntime, 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.