diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f3cf4b3bbe..216dae4dbb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -24,6 +24,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +// The English fallback surface: a browser naming no shipped language. +const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() @@ -496,6 +498,11 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // Golden of the English fallback dialog — the visible output this change + // produces. The zh golden above covers the detected-locale surface, so + // the pair pins both directions of the resolution. + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) } finally { @@ -506,6 +513,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md new file mode 100644 index 0000000000..605e2fe328 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md @@ -0,0 +1,45 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General": + - img + - text: General + - button "Models": + - img + - text: Models + - button "Plugins": + - img + - text: Plugins + - button "Agent presets": + - img + - text: Agent presets + - button "Open configuration file" + - button "Close": + - img + - text: Close + - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. + - button "Standard mode": + - text: Standard mode + - img + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write": + - text: Workspace Write + - img + - text: Language + - button "English": + - text: English + - img + - text: Appearance + - button "Light": + - img + - text: Light + - button "Dark": + - img + - text: Dark + - button "System" [pressed]: + - img + - text: System + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - button "Queue": + - text: Queue + - img diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 126c4ee685..e1cc2a1c88 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: a63807f093dc12831a41196e61008151868e3205 -README.zh.md: b302e6055ba30d2db0948c0a6f1551c4a9dcb24c +README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 +README.zh.md: 4f08344d6f030e408e570ff0ad31d0b0d4de3ecc diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index a63807f093..3fb5cce334 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index b302e6055b..4f08344d6f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ab84f054ad..3f14acf216 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -197,13 +197,20 @@ export class LocaleRuntime { /** * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { 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.publish(match.id, true) + if (this.snapshot.active !== match.id) this.publish(match.id, true) void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a5c70ed624..4f4d08a951 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' @@ -78,10 +78,6 @@ describe('locale apply', () => { // localized copy sets its locale explicitly via setLocale/Host preference // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, // so there is no `window` and no browser-language detection to stub. - afterEach(() => { - vi.unstubAllGlobals() - }) - it('declares the slot service', () => { expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index eb279f1295..a945ccd07e 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -138,7 +138,7 @@ describe('LocaleRuntime', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale writes through the scope and republishes only on a real change', () => { const host = stubSettingsScope() const { svc, events } = make(host) svc.setLocale('en') @@ -147,9 +147,27 @@ describe('LocaleRuntime', () => { expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) + // Re-selecting the active locale publishes nothing (no subscriber churn) + // but still writes: the active value may be a provisional browser-derived + // resolution nothing has stored, and picking it is an explicit choice that + // must outlive this browser. svc.setLocale('en') expect(events).toHaveLength(1) - expect(host.set).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenLastCalledWith('preference', 'en') + }) + + it('persists an explicit pick of the provisional locale, so a shared DSH home agrees', () => { + // A browser naming no shipped language opens at FALLBACK_LOCALE with + // nothing stored. Choosing that same language in the menu must become + // durable, or a Chinese browser sharing the home still opens Chinese. + stubLanguages('fr-FR') + const host = stubSettingsScope() + const { svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.set).not.toHaveBeenCalled() + svc.setLocale('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') }) it('setLocale without a host scope stays process-local', () => { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7d998c7e11..e0d5c69e4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -21,7 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' - const ROSTER_ONE = { rpcId: 'r', result: { diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index f5d04fcfd6..e66fb73262 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -12,7 +12,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' - const sid = (k: string): SessionId => k as SessionId async function bench() { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 79d597f9bc..f8c7407e5a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -10,7 +10,6 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' - /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index d73950a6f7..385f726d36 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -11,7 +11,6 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' - async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index d8022887fa..c3987e38a5 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -13,7 +13,6 @@ import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' - /** * @param served - namespaces the Host describes; omitted answers a failed read, * which is what most of these specs want (no card has anything to render). @@ -87,7 +86,6 @@ describe('ui-settings-plugins apply', () => { expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) }) - it('injects a live tab projection, the card directory, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 0ac3dc32b8..fa20e0dd3b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -14,7 +14,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' - const SLOT = 'settings.general.item' function deferred() { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 4a819c8587..abba4371c2 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -7,7 +7,6 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' - async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 58bbbdee3f..c6539aa46a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'locale', summary: 'Dictionary registry plus locale preference.', - description: '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 getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', + description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', methods: [ { signature: 'getLocale(): LocaleSnapshot', diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index d48fc3e808..ea40919fd9 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -9,83 +9,144 @@ * only one side breaks that: a reader of the other language sees a bare key * such as `list.aria` instead of text. This gate fails on the asymmetry rather * than waiting for the bare key to reach a UI. + * + * Discovery is deliberately broad, because a gate that silently narrows is + * worse than no gate. It sweeps every workspace package (not just + * `packages/client`), reads dictionaries wherever they are declared — + * `locales.ts`, a `locales/` directory, or inline in the plugin body — and + * pairs `zh`/`en` across sibling files as well as within one module. A `zh` + * dictionary whose `en` counterpart cannot be found anywhere is an error, not + * a skip. */ import type { Dirent } from 'node:fs' -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' const root = fileURLToPath(new URL('..', import.meta.url)) -/** Every `locales*.ts` module under a client package's `src/`. */ -function dictionaryModules(): string[] { +/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */ +function relative(file: string): string { + return file.slice(root.length).replaceAll('\\', '/') +} + +/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */ +function sourceFiles(): string[] { const files: string[] = [] - for (const group of ['client', 'extensions']) { - const groupRoot = resolve(root, 'packages', group) - let packages: string[] - try { - packages = readdirSync(groupRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - } catch { - continue - } - for (const pkg of packages) { - const srcRoot = resolve(groupRoot, pkg, 'src') - walk(srcRoot, files) + const packagesRoot = resolve(root, 'packages') + for (const group of directories(packagesRoot)) { + for (const pkg of directories(resolve(packagesRoot, group))) { + walk(resolve(packagesRoot, group, pkg, 'src'), files) } } return files.sort() } -function walk(dir: string, out: string[]): void { +/** Immediate subdirectory names, or none when the path is not a directory. */ +function directories(dir: string): string[] { + if (!existsSync(dir)) return [] let entries: Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { + // Swallows only the race between existsSync and readdirSync (a package + // directory removed mid-sweep); readdirSync is the sole statement in the + // try, so no other failure can reach here. + return [] + } + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +function walk(dir: string, out: string[]): void { + if (!existsSync(dir)) return + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + // Same narrow race as `directories`: readdirSync is the only statement + // guarded, so this cannot mask a parse or assertion failure. return } for (const entry of entries) { const full = resolve(dir, entry.name) - if (entry.isDirectory()) { - walk(full, out) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { - if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) - } + if (entry.isDirectory()) walk(full, out) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) } } +/** One discovered dictionary: which file and export name declared it. */ +interface Dictionary { + /** Repo-relative declaring file. */ + file: string + /** Export name, or the registration site for an inline literal. */ + name: string + /** Declared keys, sorted. */ + keys: string[] +} + /** - * Keys of every top-level `export const ...= { ... }` object literal, - * read from the AST so the gate never executes package code. - * @param file - absolute path of the dictionary module. - * @returns exported dictionary name mapped to its declared keys. + * Keys of every top-level `export const = { ... }` object literal whose + * name identifies a locale dictionary, plus inline `register(ns, locale, {...})` + * literals. Read from the AST so the gate never executes package code. + * @param file - absolute path of a candidate module. + * @returns discovered dictionaries, keyed by locale-bearing name. */ -function exportedDictionaries(file: string): Map { - const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) - const found = new Map() +function dictionariesIn(file: string): Dictionary[] { + const text = readFileSync(file, 'utf8') + // Cheap pre-filter: parsing every package source is wasteful, and a file + // with no locale token cannot declare a dictionary under any shape below. + if (!/\b(zh|en)\b/.test(text)) return [] + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) + const found: Dictionary[] = [] + const rel = relative(file) + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue - const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true - if (!exported) continue + if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue for (const decl of statement.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue - const initializer = unwrap(decl.initializer) - if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue - const keys: string[] = [] - for (const prop of initializer.properties) { - if (!ts.isPropertyAssignment(prop)) continue - if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) - } - found.set(decl.name.text, keys.sort()) + const literal = unwrap(decl.initializer) + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (localeOf(decl.name.text) === undefined) continue + found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) }) } } + + // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a + // registration loop in the plugin body. Both halves key off the enclosing + // array's line so they pair with each other and not across sites. + const visit = (node: ts.Node): void => { + if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { + const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + for (const element of node.elements) { + if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue + const [tag, dict] = element.elements + const literal = unwrap(dict) + if (tag === undefined || !ts.isStringLiteral(tag)) continue + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (tag.text !== 'zh' && tag.text !== 'en') continue + found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) }) + } + } + ts.forEachChild(node, visit) + } + visit(source) return found } +/** Declared property names of an object literal, sorted. */ +function keysOf(literal: ts.ObjectLiteralExpression): string[] { + const keys: string[] = [] + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + return keys.sort() +} + /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { let current = node @@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { return current } -/** Pair a `zh` export with the `en` export covering the same namespace. */ -function counterpart(name: string): string | undefined { - if (name === 'zh') return 'en' - if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` - if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` +/** + * The locale a dictionary name declares, and the namespace-ish remainder that + * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, + * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * @param name - export name or synthetic inline name. + * @returns locale plus pair key, or undefined when the name names no locale. + */ +function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined { + for (const locale of ['zh', 'en'] as const) { + const other = locale === 'zh' ? 'Zh' : 'En' + if (name === locale) return { locale, pair: '' } + if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + return { locale, pair: name.slice(2) } + } + if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } + } return undefined } describe('shipped locale dictionaries', () => { it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { - const modules = dictionaryModules() - // Guard the discovery itself: an empty sweep would pass every assertion - // below while checking nothing. - expect(modules.length).toBeGreaterThan(20) + const files = sourceFiles() + // Guard the discovery itself: an empty or narrowed sweep would pass every + // assertion below while checking nothing. + expect(files.length).toBeGreaterThan(500) - const mismatches: string[] = [] - let comparedPairs = 0 - for (const file of modules) { - const dicts = exportedDictionaries(file) - for (const [name, zhKeys] of dicts) { - const enName = counterpart(name) - if (enName === undefined) continue - const enKeys = dicts.get(enName) - if (enKeys === undefined) continue - comparedPairs++ - const rel = file.slice(root.length) - const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) - const enOnly = enKeys.filter(key => !zhKeys.includes(key)) - if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) - if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + // Pair within a file first; a dictionary whose counterpart is not in the + // same module then pairs with a sibling in the same directory. Both shapes + // ship here: `locales/settings.ts` exports zh+en together, while + // `locales/zh.ts` + `locales/en.ts` split the common pair across files. + const perFile = new Map() + for (const file of files) { + const dicts = dictionariesIn(file) + if (dicts.length > 0) perFile.set(relative(file), dicts) + } + + const groups = new Map>() + const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => { + const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>() + if (slot.has(locale)) { + throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`) + } + slot.set(locale, dict) + groups.set(key, slot) + } + + for (const [rel, dicts] of perFile) { + for (const dict of dicts) { + const parsed = localeOf(dict.name) + if (parsed === undefined) continue + const sameFileCounterpart = dicts.some((other) => { + const otherParsed = localeOf(other.name) + return otherParsed !== undefined + && otherParsed.pair === parsed.pair + && otherParsed.locale !== parsed.locale + }) + // Same-file pairs key by file so two pairs in one directory stay + // distinct; split pairs key by directory so siblings meet. + const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}` + place(key, parsed.locale, dict) } } - expect(comparedPairs).toBeGreaterThan(20) - expect(mismatches).toEqual([]) + const problems: string[] = [] + let comparedPairs = 0 + for (const [key, slot] of [...groups].sort()) { + const zh = slot.get('zh') + const en = slot.get('en') + if (zh === undefined || en === undefined) { + const present = zh ?? en + problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`) + continue + } + comparedPairs++ + const zhOnly = zh.keys.filter(k => !en.keys.includes(k)) + const enOnly = en.keys.filter(k => !zh.keys.includes(k)) + if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) + } + + // The shipped dictionary count only grows; a collapse means discovery or + // pairing broke, which would hide real asymmetry. + expect(comparedPairs).toBeGreaterThan(25) + expect(problems).toEqual([]) }) })