|
|
|
|
@@ -4,11 +4,21 @@
|
|
|
|
|
* preference row into the settings General section — the locale feature owns
|
|
|
|
|
* its own settings surface.
|
|
|
|
|
*/
|
|
|
|
|
/* eslint-disable @typescript-eslint/no-redundant-type-constituents --
|
|
|
|
|
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
|
|
|
|
|
* ui-slots): in THIS unit the map holds only this package's own merges, but
|
|
|
|
|
* consumers merge more namespaces in and the intersection keeps them
|
|
|
|
|
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
|
|
|
|
import type { Context } from 'cordis'
|
|
|
|
|
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
|
|
import {
|
|
|
|
|
deferRegistration,
|
|
|
|
|
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
|
|
|
|
} from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
|
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
|
|
import { en } from '../locales/en.ts'
|
|
|
|
|
import { zh } from '../locales/zh.ts'
|
|
|
|
|
import { en, zh, type CommonKey } from '../locales/index.ts'
|
|
|
|
|
import {
|
|
|
|
|
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
|
|
|
|
|
} from '../locales/settings.ts'
|
|
|
|
|
import type { LanguageRowInjected } from './LanguageRow.tsx'
|
|
|
|
|
import { LanguageRow } from './LanguageRow.tsx'
|
|
|
|
|
import { createLanguageRowStore } from './settings-store.ts'
|
|
|
|
|
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
|
|
|
|
|
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
|
|
|
|
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
|
|
|
|
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
|
|
|
|
export type { CommonKey } from '../locales/index.ts'
|
|
|
|
|
|
|
|
|
|
/** Translate a key with optional params. */
|
|
|
|
|
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
|
|
|
|
// The translate currency lives in ui-slots (the render machinery synthesizes
|
|
|
|
|
// the seat); re-exported here so dictionary owners import one package.
|
|
|
|
|
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
|
|
|
|
|
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
|
|
|
|
|
|
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
|
|
interface LocaleNamespaceMap {
|
|
|
|
|
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
|
|
|
|
|
common: CommonKey
|
|
|
|
|
/** This feature's own settings-row copy (the Language row). */
|
|
|
|
|
'settings.locale': SettingsLocaleKey
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
|
|
|
|
export type LocaleDict = Record<string, string>
|
|
|
|
|
@@ -50,7 +72,10 @@ declare module 'cordis' {
|
|
|
|
|
}
|
|
|
|
|
interface Events {
|
|
|
|
|
/**
|
|
|
|
|
* Locale state changed (active locale switched or registry updated).
|
|
|
|
|
* The active locale switched. Dictionary registrations do NOT emit this
|
|
|
|
|
* event (listeners may re-register slots in response, and boot registers
|
|
|
|
|
* one namespace per package); continuous render refresh rides the
|
|
|
|
|
* LocaleFace revision instead.
|
|
|
|
|
* @param snapshot - Current immutable locale snapshot.
|
|
|
|
|
* @mode emit
|
|
|
|
|
*/
|
|
|
|
|
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Dictionary registry plus locale preference. Lookup chain per key: active
|
|
|
|
|
* locale -> zh fallback -> the key itself (missing text stays visible, fail
|
|
|
|
|
* loud in the UI rather than blank). Reads go through {@link getLocale};
|
|
|
|
|
* writes only through {@link setLocale}; continuous sync only through the
|
|
|
|
|
* `locale/change` event.
|
|
|
|
|
* Dictionary registry plus locale preference. Lookup chain per key: the
|
|
|
|
|
* entry's namespace in the active locale -> that namespace's zh fallback ->
|
|
|
|
|
* the shared common namespace (active, then zh) -> the key itself (missing
|
|
|
|
|
* text stays visible, fail loud in the UI rather than blank). Reads go
|
|
|
|
|
* through {@link getLocale}; writes only through {@link setLocale};
|
|
|
|
|
* continuous sync through the `locale/change` event, or through the
|
|
|
|
|
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
|
|
|
|
|
* (installed via `ctx.slots.installLocale`).
|
|
|
|
|
*/
|
|
|
|
|
export class LocaleService {
|
|
|
|
|
private dicts = new Map<string, Map<string, LocaleDict>>()
|
|
|
|
|
private bound = new Map<string, Translate>()
|
|
|
|
|
private snapshot: LocaleSnapshot
|
|
|
|
|
private listeners = new Set<() => void>()
|
|
|
|
|
private readonly ctx: Context
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -105,6 +134,27 @@ export class LocaleService {
|
|
|
|
|
return this.snapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
|
|
|
|
|
* reference between changes, uSES-safe).
|
|
|
|
|
* @returns the current snapshot.
|
|
|
|
|
*/
|
|
|
|
|
getSnapshot(): LocaleSnapshot {
|
|
|
|
|
return this.snapshot
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* LocaleFace subscribe: notified on every snapshot change (locale switch
|
|
|
|
|
* or dictionary registration — registrations bump the revision so already
|
|
|
|
|
* rendered outlets pick up late-arriving dictionaries).
|
|
|
|
|
* @param fn - change callback.
|
|
|
|
|
* @returns unsubscribe.
|
|
|
|
|
*/
|
|
|
|
|
subscribe(fn: () => void): () => void {
|
|
|
|
|
this.listeners.add(fn)
|
|
|
|
|
return () => { this.listeners.delete(fn) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Switch the active locale — the only preference write entry. Persists the
|
|
|
|
|
* id and emits `locale/change`.
|
|
|
|
|
@@ -114,44 +164,80 @@ export class LocaleService {
|
|
|
|
|
const match = this.snapshot.locales.find(l => l.id === id)
|
|
|
|
|
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
|
|
|
|
if (this.snapshot.active === match.id) return
|
|
|
|
|
this.snapshot = Object.freeze({
|
|
|
|
|
active: match.id,
|
|
|
|
|
locales: this.snapshot.locales,
|
|
|
|
|
revision: this.snapshot.revision + 1,
|
|
|
|
|
})
|
|
|
|
|
persistPreference(match.id)
|
|
|
|
|
this.ctx.emit('locale/change', this.snapshot)
|
|
|
|
|
this.publish(match.id, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
|
|
|
|
* throws (single occupant; a namespace's texts have one owner).
|
|
|
|
|
* Register a declared namespace's dictionaries, all locales in one call —
|
|
|
|
|
* the typed form: each dictionary is checked against the namespace's
|
|
|
|
|
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
|
|
|
|
|
* compile error), and every shipped locale is required (bilingual balance
|
|
|
|
|
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
|
|
|
|
|
* namespace's texts have one owner). Registration bumps the revision so
|
|
|
|
|
* mounted outlets pick up late-arriving dictionaries.
|
|
|
|
|
* @param ns - a namespace merged into LocaleNamespaceMap.
|
|
|
|
|
* @param dicts - complete dictionaries keyed by locale id.
|
|
|
|
|
* @returns disposer removing every locale registered by this call (idempotent).
|
|
|
|
|
*/
|
|
|
|
|
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
|
|
|
|
|
/**
|
|
|
|
|
* Single-locale untyped form for namespaces outside the merge table
|
|
|
|
|
* (dynamic composition, tests).
|
|
|
|
|
* @param ns - namespace.
|
|
|
|
|
* @param locale - locale tag (zh/en to start).
|
|
|
|
|
* @param locale - locale tag.
|
|
|
|
|
* @param dict - dictionary.
|
|
|
|
|
* @returns disposer (idempotent).
|
|
|
|
|
*/
|
|
|
|
|
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
|
|
|
|
register(ns: string, locale: string, dict: LocaleDict): () => void
|
|
|
|
|
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
|
|
|
|
|
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
|
|
|
|
|
// Overload guarantees dict on the single-locale arm.
|
|
|
|
|
? [[localeOrDicts, dict as LocaleDict]]
|
|
|
|
|
: Object.entries(localeOrDicts)
|
|
|
|
|
let locales = this.dicts.get(ns)
|
|
|
|
|
if (!locales) {
|
|
|
|
|
locales = new Map()
|
|
|
|
|
this.dicts.set(ns, locales)
|
|
|
|
|
}
|
|
|
|
|
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
|
|
|
|
locales.set(locale, dict)
|
|
|
|
|
for (const [locale] of pairs) {
|
|
|
|
|
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
|
|
|
|
}
|
|
|
|
|
for (const [locale, entries] of pairs) locales.set(locale, entries)
|
|
|
|
|
this.publish(this.snapshot.active, false)
|
|
|
|
|
return () => {
|
|
|
|
|
const owner = this.dicts.get(ns)
|
|
|
|
|
if (owner?.get(locale) === dict) owner.delete(locale)
|
|
|
|
|
/* v8 ignore next -- defensive: a namespace's locales map is created on
|
|
|
|
|
* first register and never removed, so the disposer always finds it. */
|
|
|
|
|
if (!owner) return
|
|
|
|
|
let removed = false
|
|
|
|
|
for (const [locale, entries] of pairs) {
|
|
|
|
|
if (owner.get(locale) === entries) {
|
|
|
|
|
owner.delete(locale)
|
|
|
|
|
removed = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (removed) this.publish(this.snapshot.active, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bind a namespace to a translate function. The returned reference is
|
|
|
|
|
* stable per namespace (repeat binds return the same function), so it can
|
|
|
|
|
* ride inject surfaces without breaking memoization.
|
|
|
|
|
* @param ns - namespace.
|
|
|
|
|
* @returns the translate function (reads the active locale at call time).
|
|
|
|
|
* Bind a declared namespace to a translate function typed to its
|
|
|
|
|
* dictionary key union (plus the shared common vocabulary) — the same key
|
|
|
|
|
* domain the framework-injected `t` seat carries. The returned reference
|
|
|
|
|
* is stable per namespace (repeat binds return the same function), so it
|
|
|
|
|
* can ride inject surfaces without breaking memoization.
|
|
|
|
|
* @param ns - a namespace merged into LocaleNamespaceMap.
|
|
|
|
|
* @returns the typed translate function (reads the active locale at call time).
|
|
|
|
|
*/
|
|
|
|
|
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
|
|
|
|
|
/**
|
|
|
|
|
* Untyped form for namespaces outside the merge table (dynamic
|
|
|
|
|
* composition, tests).
|
|
|
|
|
* @param ns - namespace.
|
|
|
|
|
* @returns the translate function.
|
|
|
|
|
*/
|
|
|
|
|
bind(ns: string): Translate
|
|
|
|
|
bind(ns: string): Translate {
|
|
|
|
|
let t = this.bound.get(ns)
|
|
|
|
|
if (!t) {
|
|
|
|
|
@@ -163,14 +249,43 @@ export class LocaleService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
|
|
|
|
const locales = this.dicts.get(ns)
|
|
|
|
|
const template = locales?.get(this.snapshot.active)?.[key]
|
|
|
|
|
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
|
|
|
|
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, (match, name: string) =>
|
|
|
|
|
name in params ? String(params[name]) : match)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private lookup(ns: string, key: string): string | undefined {
|
|
|
|
|
const locales = this.dicts.get(ns)
|
|
|
|
|
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Advance the snapshot revision and notify LocaleFace subscribers (render
|
|
|
|
|
* refresh). Only an active-locale switch additionally emits
|
|
|
|
|
* `locale/change` — dictionary registrations stay off the event so
|
|
|
|
|
* registration-heavy boot cannot storm event listeners (which may
|
|
|
|
|
* re-register slots in response).
|
|
|
|
|
*/
|
|
|
|
|
private publish(active: LocaleId, localeChanged: boolean): void {
|
|
|
|
|
this.snapshot = Object.freeze({
|
|
|
|
|
active,
|
|
|
|
|
locales: this.snapshot.locales,
|
|
|
|
|
revision: this.snapshot.revision + 1,
|
|
|
|
|
})
|
|
|
|
|
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
|
|
|
|
|
for (const fn of [...this.listeners]) {
|
|
|
|
|
try {
|
|
|
|
|
fn()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// One throwing subscriber must not strand the rest on a stale
|
|
|
|
|
// revision (outlets would keep the previous language).
|
|
|
|
|
console.error('locale subscriber crashed:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
|
|
|
|
@@ -208,11 +323,12 @@ export const inject = ['slots']
|
|
|
|
|
*/
|
|
|
|
|
export function apply(ctx: ClientContext): void {
|
|
|
|
|
const locale = new LocaleService(ctx)
|
|
|
|
|
locale.register(COMMON_NS, 'zh', zh)
|
|
|
|
|
locale.register(COMMON_NS, 'en', en)
|
|
|
|
|
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
|
|
|
|
|
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
|
|
|
|
|
locale.register(COMMON_NS, { zh, en })
|
|
|
|
|
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
|
|
|
|
ctx.provide('locale', locale)
|
|
|
|
|
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
|
|
|
|
|
// so the render machinery can synthesize the `t` standard seat.
|
|
|
|
|
ctx.slots.installLocale(locale)
|
|
|
|
|
|
|
|
|
|
const store = createLanguageRowStore()
|
|
|
|
|
let bound: BoundActions<typeof store> | undefined
|
|
|
|
|
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
|
|
|
|
|
// first render (the store's revision guard drops stale duplicates).
|
|
|
|
|
sync(locale.getLocale())
|
|
|
|
|
return {
|
|
|
|
|
t: locale.bind(SETTINGS_NS),
|
|
|
|
|
setLocale: (id) => { locale.setLocale(id) },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
|
|
id: 'language',
|
|
|
|
|
order: 0,
|
|
|
|
|
store,
|
|
|
|
|
locale: SETTINGS_NS,
|
|
|
|
|
inject: injected,
|
|
|
|
|
}, LanguageRow))
|
|
|
|
|
return () => { deferred.dispose() }
|
|
|
|
|
|