diff --git a/.changeset/30511-console-packs-platform-locale-tags.md b/.changeset/30511-console-packs-platform-locale-tags.md new file mode 100644 index 00000000000..ad5f78beb20 --- /dev/null +++ b/.changeset/30511-console-packs-platform-locale-tags.md @@ -0,0 +1,28 @@ +--- +'@object-ui/i18n': patch +'@object-ui/components': patch +'@object-ui/console': patch +--- + +feat(i18n)!: the Console's language packs are the platform locale table's, keyed by its tags, and Traditional Chinese (`zh-Hant`) ships, generated from Simplified (#30511) + +Clause-②: yes (narrowing) — widens: a `zh-Hant` pack (the `zhHant` export of `@object-ui/i18n/locales`, the `@object-ui/i18n/locales/zh-Hant` module), the `resolveBuiltInLanguage` export of `@object-ui/i18n`, and every stored or browser code the platform locale table reads as one of its tags (`zh-TW`, `zh-HK`, `zh-MO` → `zh-Hant`; `zh`, `zh-CN`, `zh-SG` → `zh-Hans`; `pt` → `pt-BR`). Narrows: the Simplified Chinese and Portuguese packs are keyed `zh-Hans` and `pt-BR`, no longer `zh` and `pt`, in `builtInLocales`, `BUILT_IN_LANGUAGE_CODES`, `getLoadedBuiltInLocales()` and `getAvailableLanguages()`, and their modules `@object-ui/i18n/locales/zh` and `@object-ui/i18n/locales/pt` are `@object-ui/i18n/locales/zh-Hans` and `@object-ui/i18n/locales/pt-BR`. +Metadata-protocol: none +Skills-docs: follow-up owed — `skills/objectui/guides/i18n.md` (its "Built-in locales" list names ten packs, with `zh` and `pt`) + + + +**BREAKING** for code that reads a built-in pack by its old key or module path. + +- **Why.** The Console's packs follow the platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`, ruling F of #29349) instead of a list of their own: one pack per table tag, keyed by the tag, in the table's order. A language the table gains is a pack the parity and drift suites demand on the run that first sees it. +- **Traditional Chinese.** `src/locales/zh-Hant.ts` is generated from `zh-Hans.ts` by `pnpm --filter @object-ui/i18n gen:zh-hant` (OpenCC `s2twp`, pinned, with the phrase rules of the www.objectos.ai converter) and is never edited by hand; a package test fails when it is not what the generator writes. It carries no `console.ai.*` gate-only outbound keys, exactly as the other non-gate packs. +- **Stored codes keep working.** A `sys_user.locale` value, a saved device language or an `Accept-Language` tag written before the table is read through the table's aliases: `zh-CN` still renders Simplified Chinese, `pt` Portuguese (Brazil), and `zh-TW` / `zh-HK` / `zh-MO` now render Traditional Chinese instead of Simplified. `loadBuiltInLocale`, `isBuiltInLanguage` and i18next's fallback chain accept every alias. +- **Studio.** The Studio namespace still ships `en` and `zh` only; a `zh-Hant` reader sees its Simplified catalogue there. + +The fix, for code that names a pack: + +- FROM `builtInLocales.zh` TO `builtInLocales['zh-Hans']` +- FROM `builtInLocales.pt` TO `builtInLocales['pt-BR']` +- FROM `import zh from '@object-ui/i18n/locales/zh'` TO `import zh from '@object-ui/i18n/locales/zh-Hans'` +- FROM `import pt from '@object-ui/i18n/locales/pt'` TO `import pt from '@object-ui/i18n/locales/pt-BR'` +- FROM comparing a code from `BUILT_IN_LANGUAGE_CODES`, `getAvailableLanguages()` or the keys of `getLoadedBuiltInLocales()` with `'zh'` or `'pt'` TO comparing `resolveBuiltInLanguage(code)` with `'zh-Hans'` or `'pt-BR'` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9da1e2dd6f2..9a2836d5fc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4583,6 +4583,9 @@ importers: '@object-ui/core': specifier: workspace:* version: link:../core + '@objectstack/spec': + specifier: workspace:^ + version: link:../../../packages/spec i18next: specifier: ^26.4.0 version: 26.4.0(typescript@6.0.3) @@ -4596,6 +4599,9 @@ importers: '@types/react': specifier: 19.3.0 version: 19.3.0 + opencc-js: + specifier: 1.3.1 + version: 1.3.1 react: specifier: 19.3.0 version: 19.3.0 @@ -12191,6 +12197,9 @@ packages: resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} engines: {node: '>=20'} + opencc-js@1.3.1: + resolution: {integrity: sha512-EyKDnjHNYlxo3Blll0OGH5qcyZBI3YmXpqeDEity/db50A5TFfCdvylrBlTyx1XvlSRUmh2a5AHvSf89h4u87Q==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -20505,6 +20514,8 @@ snapshots: powershell-utils: 0.2.1 wsl-utils: 1.0.0 + opencc-js@1.3.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 1e0a5d94ae9..1c7626fbe40 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -913,6 +913,21 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ + 'serves that day and drop every edit the patch list does not carry, and it needs the network, ' + 'which a merge must not. A conflict in a vendored component stays a human\'s; never routed.', }, + // objectstack#30511 (ruling F of #29349): the Console's Traditional Chinese pack. + { + path: 'ui/packages/i18n/src/locales/zh-Hant.ts', + gen: 'gen:zh-hant', + owner: '@object-ui/i18n', + why: + 'generated WHOLE from `zh-Hans.ts` by `gen:zh-hant`, so "discard both sides and re-run" ' + + 'loses no human decision. Still not routed: a `REGEN_ARTIFACTS` row needs a `check:` script ' + + 'as its currency proof, and ruling F of #29349 adds no gate to this phase. The currency proof ' + + 'is the package test `src/__tests__/zh-hant-generated.test.ts`, which reds a pack that is not ' + + 'byte-for-byte the generator\'s output wherever the ui unit tests run, and the driver is local ' + + 'anyway (never the queue\'s merge). The pack is line-aligned with `zh-Hans.ts`, one line per ' + + 'line, so two branches editing different keys text-merge apart; a conflict is resolved by ' + + 'taking either side and running `pnpm --filter @object-ui/i18n gen:zh-hant`.', + }, ]); /** Marker file (inside the git dir, never the worktree) listing paths the driver deferred. */ diff --git a/ui/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx b/ui/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx index 07120098e35..591a822829a 100644 --- a/ui/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx +++ b/ui/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx @@ -159,7 +159,7 @@ import { App } from '../App'; import { loadLanguage } from '../loadLanguage'; import { loadLocales } from '../loadLocales'; -const zh = builtInLocales.zh; +const zh = builtInLocales['zh-Hans']; const TOKEN = 'tok-12034'; const USER = { id: 'u_12034', email: 'ada@example.com', name: 'Ada' } as unknown as AuthUser; const SESSION = { token: TOKEN, expiresAt: new Date(Date.now() + 3_600_000) }; diff --git a/ui/apps/console/src/components/StudioRoute.accessNotice-12035.test.tsx b/ui/apps/console/src/components/StudioRoute.accessNotice-12035.test.tsx index 435f8b6c1bd..ed5aec4cd4e 100644 --- a/ui/apps/console/src/components/StudioRoute.accessNotice-12035.test.tsx +++ b/ui/apps/console/src/components/StudioRoute.accessNotice-12035.test.tsx @@ -159,7 +159,7 @@ describe('/studio refused: the sentence arrives in the caller language (objectui }); describe('CONTROL: a caller with Studio access enters Studio as today', () => { - it.each(['en', 'zh'] as const)('%s', async (lang) => { + it.each(['en', 'zh-Hans'] as const)('%s', async (lang) => { inLanguage(lang); systemPermissions = [STUDIO_ENTRY_CAPABILITY]; renderStudio('/studio/'); diff --git a/ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx b/ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx index afc9e30d9b9..e2706000229 100644 --- a/ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx +++ b/ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx @@ -165,7 +165,7 @@ describe('SharedRecordPage — a shared non-chat record renders as read-only row }); it('under zh the value words come from the zh pack', async () => { - const { zh, en } = builtInLocales; + const { 'zh-Hans': zh, en } = builtInLocales; // Guard: these are real zh rows, not English copies. expect(zh.common.yes).not.toBe(en.common.yes); expect(zh.sharedRecord.redactedFields).not.toBe(en.sharedRecord.redactedFields); diff --git a/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx b/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx index a818d3b8216..047aff84d62 100644 --- a/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx @@ -345,12 +345,12 @@ describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => { renderRoute({ lang: 'zh' }); await screen.findByTestId('accept-invitation-page'); - const zh = builtInLocales.zh.organization.accept as Record; + const zh = builtInLocales['zh-Hans'].organization.accept as Record; expect(zh.accept).toBe('接受邀请'); expect(screen.getByRole('button', { name: zh.accept })).toBeInTheDocument(); expect(screen.getByText(zh.organization)).toBeInTheDocument(); expect(screen.getByText(zh.expiresAt)).toBeInTheDocument(); // The pack that used to serve this screen's other half is gone entirely. - expect((builtInLocales.zh as Record).acceptInvitation).toBeUndefined(); + expect((builtInLocales['zh-Hans'] as Record).acceptInvitation).toBeUndefined(); }); }); diff --git a/ui/apps/console/src/pages/auth/__tests__/LoginPage.ssoLabels-12762.test.tsx b/ui/apps/console/src/pages/auth/__tests__/LoginPage.ssoLabels-12762.test.tsx index 110736f3aa7..2f7fcf0d872 100644 --- a/ui/apps/console/src/pages/auth/__tests__/LoginPage.ssoLabels-12762.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/LoginPage.ssoLabels-12762.test.tsx @@ -90,7 +90,7 @@ async function expectSsoCopy(expected: Record) { describe('console LoginPage — SSO copy under zh (objectui#12762)', () => { it('draws the zh bundle value of each SSO label, and no English default', async () => { - const zh = builtInLocales.zh.auth.login; + const zh = builtInLocales['zh-Hans'].auth.login; for (const key of SSO_KEYS) { expect(typeof zh[key], `zh auth.login.${key}`).toBe('string'); expect(zh[key], `zh auth.login.${key}`).not.toBe(ENGLISH[key]); diff --git a/ui/apps/console/src/pages/auth/__tests__/loginFormLabels-everyMember-12762.test.tsx b/ui/apps/console/src/pages/auth/__tests__/loginFormLabels-everyMember-12762.test.tsx index b07cb9d7ea1..1a6c3b02c68 100644 --- a/ui/apps/console/src/pages/auth/__tests__/loginFormLabels-everyMember-12762.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/loginFormLabels-everyMember-12762.test.tsx @@ -97,7 +97,7 @@ function labelMembers(): string[] { } const MEMBERS = labelMembers(); -const ZH_LOGIN = builtInLocales.zh.auth.login as Record; +const ZH_LOGIN = builtInLocales['zh-Hans'].auth.login as Record; function createMockClient(): AuthClient { return { diff --git a/ui/apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx b/ui/apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx index d344e93ab86..464117885da 100644 --- a/ui/apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx @@ -65,7 +65,7 @@ function refusingServer(code: string, message: string): typeof fetch { }) as typeof fetch; } -type Lang = 'zh' | 'en'; +type Lang = 'zh-Hans' | 'en'; /** Labels as the page renders them in `lang`, read from that pack. */ function registerLabels(lang: Lang) { @@ -114,7 +114,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -const zhErrors = builtInLocales.zh.auth.register.errors; +const zhErrors = builtInLocales['zh-Hans'].auth.register.errors; const enErrors = builtInLocales.en.auth.register.errors; describe('console RegisterPage — audience refusals under zh-CN (objectui#10998)', () => { @@ -125,7 +125,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998 it('SELF_REGISTRATION_CLOSED shows the localized text, not the server message', async () => { const text = await submitRefused( - 'zh', + 'zh-Hans', 'SELF_REGISTRATION_CLOSED', SERVER_MESSAGE.SELF_REGISTRATION_CLOSED, ); @@ -136,7 +136,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998 it('EMAIL_DOMAIN_NOT_ALLOWED shows the localized text, not the server message', async () => { const text = await submitRefused( - 'zh', + 'zh-Hans', 'EMAIL_DOMAIN_NOT_ALLOWED', SERVER_MESSAGE.EMAIL_DOMAIN_NOT_ALLOWED, ); @@ -146,7 +146,7 @@ describe('console RegisterPage — audience refusals under zh-CN (objectui#10998 it('a code the page does not map still shows the raw server message', async () => { const raw = 'The server refused this sign-up for a reason the console has no text for.'; - const text = await submitRefused('zh', 'UNMAPPED_REFUSAL_FOR_TEST', raw); + const text = await submitRefused('zh-Hans', 'UNMAPPED_REFUSAL_FOR_TEST', raw); expect(text).toBe(raw); }); }); diff --git a/ui/apps/console/src/pages/consolePagesLocale-secondPass-13460.test.tsx b/ui/apps/console/src/pages/consolePagesLocale-secondPass-13460.test.tsx index a92e37eda1b..55440598f47 100644 --- a/ui/apps/console/src/pages/consolePagesLocale-secondPass-13460.test.tsx +++ b/ui/apps/console/src/pages/consolePagesLocale-secondPass-13460.test.tsx @@ -71,7 +71,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -const { zh, en } = builtInLocales; +const { 'zh-Hans': zh, en } = builtInLocales; const zhAudit = zh.console.system.auditLog; const zhFlow = zh.console.developer.flowRuns; const zhAgent = zh.console.developer.agentConnect; diff --git a/ui/apps/console/src/pages/developer/consolePagesLocale-13460.test.tsx b/ui/apps/console/src/pages/developer/consolePagesLocale-13460.test.tsx index 7ac98164308..cb289741074 100644 --- a/ui/apps/console/src/pages/developer/consolePagesLocale-13460.test.tsx +++ b/ui/apps/console/src/pages/developer/consolePagesLocale-13460.test.tsx @@ -66,7 +66,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -const { zh, en } = builtInLocales; +const { 'zh-Hans': zh, en } = builtInLocales; const zhDev = zh.console.developer; const enDev = en.console.developer; const zhSys = zh.console.system; diff --git a/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx b/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx index 01aec33d5b3..bff1c525ec8 100644 --- a/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx +++ b/ui/apps/console/src/pages/developer/flowRunsClientDiagnostic-25916.test.tsx @@ -33,7 +33,7 @@ import { FlowRunsPage } from './FlowRunsPage'; afterEach(() => cleanup()); -const { zh } = builtInLocales; +const { 'zh-Hans': zh } = builtInLocales; const zhFlow = zh.console.developer.flowRuns; /** A client that serves one flow but has no automation surface at all. */ diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsField.chipLocale-12821.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsField.chipLocale-12821.test.tsx index f031ac3e900..f93a8ce1c64 100644 --- a/ui/apps/console/src/pages/settings/__tests__/SettingsField.chipLocale-12821.test.tsx +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsField.chipLocale-12821.test.tsx @@ -81,7 +81,7 @@ function renderRow(language: Lang, spec: Specifier, resolved?: ResolvedSettingVa ); } -const view = builtInLocales.zh.console.settingsView as unknown as Record; +const view = builtInLocales['zh-Hans'].console.settingsView as unknown as Record; const viewEn = builtInLocales.en.console.settingsView as unknown as Record; const LATIN = /\p{Script=Latin}/u; diff --git a/ui/apps/console/vite.config.ts b/ui/apps/console/vite.config.ts index 760050c271b..76faa8bc993 100644 --- a/ui/apps/console/vite.config.ts +++ b/ui/apps/console/vite.config.ts @@ -1188,7 +1188,7 @@ export default defineConfig({ // (lifting `framework` above these two) is still refused, for the // reason it gave. // - // ## Why there are ELEVEN i18n groups and not one (objectui#7479) + // ## Why there is one i18n group PER CATALOGUE and not one (objectui#7479) // // objectui#7399 gave the catalogues one `i18n-locales` chunk so the // `framework` ceiling stopped budgeting them. That chunk held all @@ -1201,8 +1201,10 @@ export default defineConfig({ // would be real in the source and absent in the bundle, which is // the failure mode this whole card exists to avoid. // - // So: one group per catalogue, ten single-module chunks, plus a - // group for the i18n RUNTIME (provider, hooks, formatters) which + // So: one group per catalogue — one per tag of the platform locale + // table (`PLATFORM_LOCALES` in `@objectstack/spec/system`; the file + // is named for its tag, objectstack#30511), each a single-module + // chunk — plus a group for the i18n RUNTIME (provider, hooks, formatters) which // stays eager and is a hundredth of the size. The names are the // gate's handle — `scripts/check-eager-locale-catalogues.mjs` reads // `i18n-locale-` out of the BUILT `eager-closure.json` and @@ -1214,15 +1216,16 @@ export default defineConfig({ // `scripts/__tests__/check-eager-closure-budget.test.ts` that pins // this attribution. { name: 'i18n-locale-en', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]en\.ts$/, priority: 84 }, - { name: 'i18n-locale-zh', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh\.ts$/, priority: 84 }, - { name: 'i18n-locale-ja', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ja\.ts$/, priority: 84 }, - { name: 'i18n-locale-ko', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ko\.ts$/, priority: 84 }, - { name: 'i18n-locale-de', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]de\.ts$/, priority: 84 }, - { name: 'i18n-locale-fr', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]fr\.ts$/, priority: 84 }, + { name: 'i18n-locale-zh-Hans', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh-Hans\.ts$/, priority: 84 }, + { name: 'i18n-locale-zh-Hant', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]zh-Hant\.ts$/, priority: 84 }, { name: 'i18n-locale-es', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]es\.ts$/, priority: 84 }, - { name: 'i18n-locale-pt', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt\.ts$/, priority: 84 }, - { name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 }, { name: 'i18n-locale-ar', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ar\.ts$/, priority: 84 }, + { name: 'i18n-locale-fr', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]fr\.ts$/, priority: 84 }, + { name: 'i18n-locale-pt-BR', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt-BR\.ts$/, priority: 84 }, + { name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 }, + { name: 'i18n-locale-de', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]de\.ts$/, priority: 84 }, + { name: 'i18n-locale-ja', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ja\.ts$/, priority: 84 }, + { name: 'i18n-locale-ko', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ko\.ts$/, priority: 84 }, // // The Studio designer's `studio` namespace (objectstack#25922): the // same one-group-per-catalogue rule, for the same reason. Its `zh` diff --git a/ui/packages/app-shell/src/chrome/LoadingScreen.bootCopy-12187.test.tsx b/ui/packages/app-shell/src/chrome/LoadingScreen.bootCopy-12187.test.tsx index 4f5162d0dd5..ceaefc875f2 100644 --- a/ui/packages/app-shell/src/chrome/LoadingScreen.bootCopy-12187.test.tsx +++ b/ui/packages/app-shell/src/chrome/LoadingScreen.bootCopy-12187.test.tsx @@ -99,7 +99,8 @@ describe('LoadingScreen boot copy (objectui#12187)', () => { // not waiting for a re-render that nothing schedules any more. expect(first[0].text).toBe(`${PRODUCT}${zh.console.initializing}`); // The provider does write it afterwards; the screen no longer depends on that. - expect(document.documentElement.lang).toBe('zh'); + // The table tag a zh-CN browser reads as (objectstack#30511). + expect(document.documentElement.lang).toBe('zh-Hans'); }); it('control: an en browser paints the en line on the first commit', () => { diff --git a/ui/packages/app-shell/src/chrome/LoadingScreen.tsx b/ui/packages/app-shell/src/chrome/LoadingScreen.tsx index 6d630bc9825..335edda5af7 100644 --- a/ui/packages/app-shell/src/chrome/LoadingScreen.tsx +++ b/ui/packages/app-shell/src/chrome/LoadingScreen.tsx @@ -2,7 +2,7 @@ import { Spinner, Button } from '@object-ui/components'; import { Database, Loader2, AlertCircle, RefreshCw } from 'lucide-react'; import { getProductName, getLogoUrl } from '../runtime-config.js'; -import { en as enLocale, getLoadedBuiltInLocales, useObjectTranslation } from '@object-ui/i18n'; +import { en as enLocale, getLoadedBuiltInLocales, resolveBuiltInLanguage, useObjectTranslation } from '@object-ui/i18n'; interface LoadingScreenProps { /** Optional message override */ @@ -75,14 +75,16 @@ interface StartupStrings { } function getStartupStrings(language: string): StartupStrings { - const resident = getLoadedBuiltInLocales(); - // Exact tag first, then its base language — the registry's own order. An - // empty language has resolved nothing, so it has no catalogue and no line. + // The snapshot is keyed by table tag, so the language is read as the tag it + // means first — the registry's own resolution (`zh-CN` and `zh` → `zh-Hans`, + // `zh-TW` → `zh-Hant`, `fr-CA` → `fr`). An empty language has resolved + // nothing, so it has no catalogue and no line. // `as unknown as` because a catalogue is typed by SHAPE here while // `enLocale` carries `en`'s literal VALUES — the same cast `createI18n` makes. - const catalogue = ( - language ? (resident[language] ?? resident[language.toLowerCase().split('-')[0]]) : undefined - ) as unknown as { console?: Partial } | undefined; + const tag = language ? resolveBuiltInLanguage(language) : null; + const catalogue = (tag ? getLoadedBuiltInLocales()[tag] : undefined) as unknown as + | { console?: Partial } + | undefined; const pack = catalogue?.console; return { line: pack?.initializing ?? null, diff --git a/ui/packages/app-shell/src/console/ai/outboundAgentText.ts b/ui/packages/app-shell/src/console/ai/outboundAgentText.ts index 46a13bf1985..eb0d7a1ad19 100644 --- a/ui/packages/app-shell/src/console/ai/outboundAgentText.ts +++ b/ui/packages/app-shell/src/console/ai/outboundAgentText.ts @@ -82,6 +82,13 @@ const GATE_TEXT: Record> = { }, }; +/** + * The pack each gate language reads, by the table tag `getLoadedBuiltInLocales()` + * is keyed by (`@objectstack/spec/system` `PLATFORM_LOCALES`): a Chinese + * conversation's gate text is the Simplified pack's. + */ +const PACK_TAG: Readonly> = { zh: 'zh-Hans', en: 'en' }; + /** Read `console.ai.` out of a locale pack, or `undefined` if absent. */ function packText(pack: unknown, key: OutboundAgentTextKey): string | undefined { const ai = (pack as { console?: { ai?: Record } } | undefined)?.console?.ai; @@ -115,16 +122,17 @@ export function resolveOutboundAgentText( ): string { const lang = conversationIsZh ? 'zh' : 'en'; // The RESIDENT catalogue for that language, not a static import of it - // (objectui#7479): `en` is always resident, and `zh` is resident on exactly - // the boots where the console is running in Chinese. When it is not — an + // (objectui#7479): `en` is always resident, and the Simplified Chinese pack + // (`zh-Hans`, the table tag the snapshot is keyed by) is resident on exactly + // the boots where the console is running in it. When it is not — an // English console answering a Chinese thread — this falls through to // {@link GATE_TEXT}, which `outboundAgentText.test.ts` pins BYTE-IDENTICAL to - // the `zh` pack's outbound values, so the text that leaves is the same either - // way. ⛔ Do not import the `zh` pack back to close that branch: it would put - // a second 156 KB catalogue into every page load to re-derive strings + // the `zh-Hans` pack's outbound values, so the text that leaves is the same + // either way. ⛔ Do not import the pack back to close that branch: it would + // put a second 156 KB catalogue into every page load to re-derive strings // that are already pinned equal. const template = - packText(getLoadedBuiltInLocales()[lang], key) ?? GATE_TEXT[key][lang]; + packText(getLoadedBuiltInLocales()[PACK_TAG[lang]], key) ?? GATE_TEXT[key][lang]; return interpolate(template, vars); } diff --git a/ui/packages/app-shell/src/console/auth/__tests__/defaultLoginSsoPhoneLabels-12762.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/defaultLoginSsoPhoneLabels-12762.test.tsx index ab416b609d1..b0b0292c5c4 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/defaultLoginSsoPhoneLabels-12762.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/defaultLoginSsoPhoneLabels-12762.test.tsx @@ -84,7 +84,7 @@ async function expectSsoCopy(expected: Record) describe('DefaultLoginPage — SSO copy (objectui#12762)', () => { it('draws the zh bundle value of each SSO label under zh, and no English default', async () => { - const zh = builtInLocales.zh.auth.login; + const zh = builtInLocales['zh-Hans'].auth.login; for (const key of SSO_KEYS) { expect(typeof zh[key], `zh auth.login.${key}`).toBe('string'); expect(zh[key], `zh auth.login.${key}`).not.toBe(SSO_ENGLISH[key]); @@ -103,7 +103,7 @@ describe('DefaultLoginPage — SSO copy (objectui#12762)', () => { describe('DefaultLoginPage — phone sign-in copy under zh (objectui#12762)', () => { it('draws the zh bundle value of the phone and verification-code labels', async () => { - const zh = builtInLocales.zh.auth.login; + const zh = builtInLocales['zh-Hans'].auth.login; const en = builtInLocales.en.auth.login; renderLogin('zh', PHONE_CONFIG); diff --git a/ui/packages/app-shell/src/console/auth/__tests__/loginRefusalCodes-11058.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/loginRefusalCodes-11058.test.tsx index bb9cbb51436..22b4b384fa6 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/loginRefusalCodes-11058.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/loginRefusalCodes-11058.test.tsx @@ -72,7 +72,7 @@ async function submitRefused(status: number, code: string, message: string): Pro , ); - const l = builtInLocales.zh.auth.login; + const l = builtInLocales['zh-Hans'].auth.login; await userEvent.type(await screen.findByLabelText(l.emailLabel), 'someone@example.com'); await userEvent.type(screen.getByLabelText(l.passwordLabel), 'hunter2hunter2'); await userEvent.click(screen.getByRole('button', { name: l.submitButton })); @@ -91,7 +91,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -const zhErrors = builtInLocales.zh.auth.login.errors; +const zhErrors = builtInLocales['zh-Hans'].auth.login.errors; const enErrors = builtInLocales.en.auth.login.errors; const EXPECTED: Record = { diff --git a/ui/packages/app-shell/src/console/auth/__tests__/registerRefusalCodes-11030.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/registerRefusalCodes-11030.test.tsx index 946d17d1e7e..39b3e70e2e2 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/registerRefusalCodes-11030.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/registerRefusalCodes-11030.test.tsx @@ -82,7 +82,7 @@ async function submitRefused(code: string, message: string): Promise { , ); - const r = builtInLocales.zh.auth.register; + const r = builtInLocales['zh-Hans'].auth.register; await userEvent.type(await screen.findByLabelText(r.nameLabel), 'Wang Wei'); await userEvent.type(screen.getByLabelText(r.emailLabel), 'someone@example.com'); await userEvent.type(screen.getByLabelText(r.passwordLabel), 'hunter2hunter2'); @@ -103,7 +103,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -const zhErrors = builtInLocales.zh.auth.register.errors; +const zhErrors = builtInLocales['zh-Hans'].auth.register.errors; const enErrors = builtInLocales.en.auth.register.errors; const EXPECTED: Record = { diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx index 6300a1c343c..8c7585cb34b 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx @@ -41,7 +41,7 @@ vi.mock('react-router-dom', () => ({ * One translator for all three surfaces. * * The keyed entry is spelled in zh (verbatim from - * `packages/i18n/src/locales/zh.ts`) on purpose: `capitalizeFirst` could + * `packages/i18n/src/locales/zh-Hans.ts`) on purpose: `capitalizeFirst` could * never produce `报表`, so the keyed control below cannot pass by accidentally * running the fallback. Anything not in this map misses, exactly as i18next * misses an absent key, and the call site's `defaultValue` decides. diff --git a/ui/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts b/ui/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts index 24dcea3890b..249eff0d936 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts +++ b/ui/packages/app-shell/src/console/home/__tests__/appTileOpenedLine-12601.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { builtInLocales } from '@object-ui/i18n/locales'; import { timeAgo } from '../../../utils/relativeTime'; import { TILE_OPENED_TIME_FORMAT } from '../appLastOpened'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; const TILE_PX = 112; const TILE_PADDING_PX = 8; @@ -101,7 +102,7 @@ afterAll(() => { describe('a Home tile’s last-opened line fits its tile (objectui#12601)', () => { it(`every pack's line, seconds to a year, is within ${BUDGET_PX} px at ${FONT_PX} px`, () => { - expect(PACKS).toHaveLength(10); + expect(PACKS).toHaveLength(PLATFORM_LOCALE_TAGS.length); const over: string[] = []; for (const { language, template } of PACKS) { expect(template, language).toContain('{{time}}'); @@ -115,7 +116,7 @@ describe('a Home tile’s last-opened line fits its tile (objectui#12601)', () = it('en and zh read as the card asks, at 6 s, 5 min, 3 h and 2 d', () => { const en = PACKS.find((p) => p.language === 'en')!.template; - const zh = PACKS.find((p) => p.language === 'zh')!.template; + const zh = PACKS.find((p) => p.language === 'zh-Hans')!.template; const points = [6 * SECOND, 5 * MINUTE, 3 * HOUR, 2 * DAY]; expect(points.map((ago) => tileLine(en, 'en', ago))).toEqual([ '6 sec. ago', @@ -123,7 +124,7 @@ describe('a Home tile’s last-opened line fits its tile (objectui#12601)', () = '3 hr. ago', '2 days ago', ]); - expect(points.map((ago) => tileLine(zh, 'zh', ago))).toEqual(['6秒前打开', '5分钟前打开', '3小时前打开', '2天前打开']); + expect(points.map((ago) => tileLine(zh, 'zh-Hans', ago))).toEqual(['6秒前打开', '5分钟前打开', '3小时前打开', '2天前打开']); }); it('CONTROL — the long form the tile used to draw is over the budget', () => { diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.sameOriginHint-11726.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.sameOriginHint-11726.test.tsx index 3c63a295ba0..13d326dcf9d 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.sameOriginHint-11726.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.sameOriginHint-11726.test.tsx @@ -106,7 +106,7 @@ afterEach(() => { const LANGS = { en: { defaultLanguage: 'en', detectBrowserLanguage: false }, - zh: { defaultLanguage: 'zh', detectBrowserLanguage: false }, + 'zh-Hans': { defaultLanguage: 'zh-Hans', detectBrowserLanguage: false }, } as const; type Lang = keyof typeof LANGS; @@ -129,7 +129,7 @@ const packValue = (lang: Lang, key: string): unknown => */ const CLAIMS: Record = { en: { routes: /through this runtime/i, servesItself: /\bserves?\b|\bitself\b/i }, - zh: { routes: /通过本运行时/, servesItself: /自己提供|自身提供|本身提供/ }, + 'zh-Hans': { routes: /通过本运行时/, servesItself: /自己提供|自身提供|本身提供/ }, }; /** Any host or URL, or an unfilled `{{url}}` hole. */ @@ -203,9 +203,9 @@ describe('a configured plane keeps its hint, naming that plane (objectui#11726)' ); }); - it('zh', async () => { + it('zh-Hans', async () => { await bootOn('https://cloud.acme.internal'); - renderIn('zh'); + renderIn('zh-Hans'); expect(await screen.findByTestId('marketplace-load-hint')).toHaveTextContent( '本运行时通过 https://cloud.acme.internal 上的控制面访问应用市场。请检查该地址是否在线、能否从本运行时访问。', ); diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx index 0a3b2e335d2..72b8c7b4e29 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplaceRowActions.installedAppsPins.test.tsx @@ -41,7 +41,7 @@ import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtim type Pack = typeof en; const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; -const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const ZH = { defaultLanguage: 'zh-Hans', detectBrowserLanguage: false } as const; /** `{{name}}` interpolation, as i18next applies it to these keys (`escapeValue: false`). */ const fill = (template: string, values: Record) => @@ -159,7 +159,7 @@ afterEach(() => { describe.each([ ['en', EN, en as Pack], - ['zh', ZH, zh as unknown as Pack], + ['zh-Hans', ZH, zh as unknown as Pack], ])('Packages — a refused marketplace install reads not loaded, with its reason (objectui#11645) [%s]', (_lang, config, pack) => { const badge = pack.marketplace.notLoaded.badge; const reason = fill(pack.marketplace.notLoaded.protocolIncompatible, { requiredRange: '^16' }); @@ -259,7 +259,7 @@ describe('Packages — Uninstall from a marketplace row (objectui#11645)', () => describe.each([ ['en', EN, en as Pack], - ['zh', ZH, zh as unknown as Pack], + ['zh-Hans', ZH, zh as unknown as Pack], ])('Packages — the kernel note is the marketplace Uninstall tooltip (objectui#12610) [%s]', (_lang, config, pack) => { const note = pack.marketplace.installedAdditiveNote; @@ -301,9 +301,9 @@ describe.each([ describe('every pack\'s kernel note is plain text (objectui#12610)', () => { const packs = Object.entries(builtInLocales) as Array<[string, Pack]>; - it('the walk below reads all ten packs', () => { + it('the walk below reads every pack', () => { expect(packs.map(([lang]) => lang).sort()).toEqual( - ['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh'], + ['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'ru', 'zh-Hans', 'zh-Hant'], ); }); @@ -327,13 +327,16 @@ describe('every pack\'s kernel note is plain text (objectui#12610)', () => { */ const RETIRED_RESTART_CLAIM: Record = { en: /until you restart|until the next restart|keeps the app registered|remain loaded|restart the runtime|additive only/i, - zh: /直到下次重启|仍会保留该应用|仅支持新增|重启运行时/, + 'zh-Hans': /直到下次重启|仍会保留该应用|仅支持新增|重启运行时/, + // The zh-Hans claims, converted the way the generated pack is (`运行时` reads + // 執行時 or 執行環境 by context, so both). + 'zh-Hant': /直到下次重啟|仍會保留該應用|僅支援新增|重啟執行環境|重啟執行時/, ja: /再起動するまで|次の再起動まで|再起動して|追加のみ/, ko: /재시작될 때까지|재시작하세요|추가 전용/, de: /bis zum Neustart|Laufzeitsystem neu|nur additiv/, es: /hasta reiniciar|Reinicie el runtime|solo aditiva/, fr: /jusqu'au redémarrage|Redémarrez le runtime|uniquement additive/, - pt: /até reiniciar|Reinicie o runtime|apenas aditiva/, + 'pt-BR': /até reiniciar|Reinicie o runtime|apenas aditiva/, ru: /до перезапуска|Перезапустите рантайм|только аддитивный/, ar: /حتى إعادة التشغيل|أعد تشغيل|إضافية فقط/, }; @@ -349,7 +352,7 @@ const UNINSTALL_TEXTS: Array<[string, (pack: Pack) => string]> = [ /** Withdrawn at once; not loaded at the next start; records kept. */ const MEASURED_CLAIMS: Record = { en: [/\bat once\b/, /\bdoes not load at the next start\b/, /\bstay in the database\b/], - zh: [/立即/, /下次启动时不再加载/, /保留在数据库中/], + 'zh-Hans': [/立即/, /下次启动时不再加载/, /保留在数据库中/], }; /** The confirm (before the DELETE) and the result line (after it), per language. */ @@ -358,7 +361,7 @@ const MEASURED_UNINSTALL_CLAIMS: Record { it('a loaded row\'s Uninstall describes the live withdrawal, and no restart', async () => { const user = userEvent.setup(); diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx b/ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx index f8b89ddb0c2..9f514c10185 100644 --- a/ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx +++ b/ui/packages/app-shell/src/console/organizations/__tests__/CreateWorkspaceDialog.test.tsx @@ -30,7 +30,7 @@ import { provisionProductionEnvironment } from '../provisionEnvironment'; import { builtInLocales } from '@object-ui/i18n/locales'; /** The pack `t` reads from; `null` answers the call site's `defaultValue`. */ -let packLang: 'en' | 'zh' | null = null; +let packLang: 'en' | 'zh-Hans' | null = null; function translate(key: string, options?: Record): string { const fromPack = packLang ? key.split('.').reduce((n, k) => (n as Record | undefined)?.[k], builtInLocales[packLang]) @@ -296,7 +296,7 @@ describe('prefills a first organization from the e-mail (objectstack-ai/cloud#35 it.each([ ['en', 'My Organization', 'my-organization'], - ['zh', '我的组织', null], + ['zh-Hans', '我的组织', null], ] as const)('%s: a public-mailbox user sees the generic default and one click creates it', async (lang, expected, slug) => { packLang = lang; authState = { user: GMAIL_USER, organizations: [], isOrganizationsLoading: false }; @@ -316,7 +316,7 @@ describe('prefills a first organization from the e-mail (objectstack-ai/cloud#35 }); it('a public mailbox subdomain (vip.qq.com) and a punycode work label take the generic default', () => { - packLang = 'zh'; + packLang = 'zh-Hans'; for (const email of ['li@vip.qq.com', 'li@xn--fiqs8s.com']) { authState = { user: { ...GMAIL_USER, email }, organizations: [], isOrganizationsLoading: false }; const { unmount } = render( {}} />); @@ -326,7 +326,7 @@ describe('prefills a first organization from the e-mail (objectstack-ai/cloud#35 }); it('a work-domain user sees the company name in both locales', async () => { - for (const lang of ['en', 'zh'] as const) { + for (const lang of ['en', 'zh-Hans'] as const) { packLang = lang; authState = { user: WORK_USER, organizations: [], isOrganizationsLoading: false }; const { unmount } = render( {}} />); diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx b/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx index f634967b89e..d42d919a384 100644 --- a/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx +++ b/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx @@ -96,7 +96,7 @@ describe('AcceptInvitationPage — names the inviter (#25886)', () => { renderIn('zh'); await screen.findByTestId('accept-invitation-inviter'); - expect(screen.getByText(builtInLocales.zh.organization.accept.invitedBy)).toBeInTheDocument(); + expect(screen.getByText(builtInLocales['zh-Hans'].organization.accept.invitedBy)).toBeInTheDocument(); }); it('draws no inviter row when the answer carries no inviter address', async () => { diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/members-role-gating-4475.test.tsx b/ui/packages/app-shell/src/console/organizations/__tests__/members-role-gating-4475.test.tsx index 416683cbdbe..044ed138648 100644 --- a/ui/packages/app-shell/src/console/organizations/__tests__/members-role-gating-4475.test.tsx +++ b/ui/packages/app-shell/src/console/organizations/__tests__/members-role-gating-4475.test.tsx @@ -185,6 +185,7 @@ vi.mock('lucide-react', async (importOriginal) => { import { builtInLocales } from '@object-ui/i18n/locales'; import { MembersPage } from '../manage/MembersPage'; import { InvitationsPage } from '../manage/InvitationsPage'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; // ── Harness ────────────────────────────────────────────────────────────────── @@ -394,13 +395,13 @@ describe('#4475 — the invitations tab gates copy-link and cancel', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// #4474's channel: one new string, present in all ten packs +// #4474's channel: one new string, present in every pack // ───────────────────────────────────────────────────────────────────────────── describe('#4475 — the new note is translatable in every pack', () => { - it('all ten packs define organization.members.inviteRestrictedNote', () => { + it('every pack defines organization.members.inviteRestrictedNote', () => { const langs = Object.keys(builtInLocales) as Array; - expect(langs.length).toBe(10); + expect(langs.length).toBe(PLATFORM_LOCALE_TAGS.length); for (const lang of langs) { const value = (builtInLocales[lang] as any).organization?.members?.inviteRestrictedNote; expect(typeof value, `${lang}.organization.members.inviteRestrictedNote`).toBe('string'); diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts b/ui/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts index 1e3a2136b3f..ceae7a9c71d 100644 --- a/ui/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts +++ b/ui/packages/app-shell/src/console/organizations/__tests__/orgErrorMessage-4474.test.ts @@ -38,7 +38,7 @@ import { } from '../orgErrorMessage'; import { builtInLocales } from '@object-ui/i18n/locales'; -const zh = builtInLocales.zh; +const zh = builtInLocales['zh-Hans']; /** * A translator with the real zh pack behind it — the mapping helper's contract diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiFlowRefusal-27487.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiFlowRefusal-27487.test.tsx index 93d6284d555..b0cd5c7fca5 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiFlowRefusal-27487.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.apiFlowRefusal-27487.test.tsx @@ -130,7 +130,7 @@ describe('objectstack#27487 — console runtime: a flow-backed api action reads const out = await runRecalc(); expect(authFetchSpy).toHaveBeenCalledTimes(1); - expect(out).toEqual({ success: false, error: builtInLocales.zh.form.noPermissionToSave }); + expect(out).toEqual({ success: false, error: builtInLocales['zh-Hans'].form.noPermissionToSave }); }); it('CONTROL: a 4xx that is not FLOW_FAILED reads its own message', async () => { diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx index 6df56217fb3..75b53fd1700 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx @@ -42,7 +42,7 @@ vi.mock('@object-ui/i18n', async (importOriginal) => { const zhT = (key: string, options?: { defaultValue?: string }) => { const hit = key.split('.').reduce( (node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), - builtInLocales.zh, + builtInLocales['zh-Hans'], ); return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key); }; diff --git a/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx index b5cdee126f1..9e3fa9d105c 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteCascade-28472.test.tsx @@ -37,7 +37,7 @@ type Pack = { console: { objectView: { bulkDeleteConfirm_other: string } }; }; const EN = builtInLocales.en as unknown as Pack; -const ZH = builtInLocales.zh as unknown as Pack; +const ZH = builtInLocales['zh-Hans'] as unknown as Pack; const fill = (template: string, vars: Record) => template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(vars[k])); diff --git a/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx index a33f07267ce..e5c8e211b14 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/useObjectActions.deleteDialog-11695.test.tsx @@ -62,7 +62,7 @@ type Pack = { }; const PACKS = { en: builtInLocales.en as unknown as Pack, - zh: builtInLocales.zh as unknown as Pack, + zh: builtInLocales['zh-Hans'] as unknown as Pack, }; const fill = (template: string, vars: Record) => diff --git a/ui/packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx index e88f4af5c49..d627f7a2e49 100644 --- a/ui/packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx +++ b/ui/packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx @@ -66,7 +66,7 @@ vi.mock('lucide-react', async (importOriginal) => ({ Globe: () => , })); -import { LocaleSwitcher } from '../../layout/LocaleSwitcher'; +import { LocaleSwitcher, localeLabel } from '../../layout/LocaleSwitcher'; import { useSignedInUserLocale, pickRenderableLocale } from '../useUserLocale'; /** @@ -215,13 +215,14 @@ describe('the globe writes the column', () => { mount(); - fireEvent.click(screen.getByText('中文')); + // The menu offers the platform locale table's tags (objectstack#30511). + fireEvent.click(screen.getByText(localeLabel('zh-Hans'))); - await waitFor(() => expect(shownLanguage()).toBe('zh')); + await waitFor(() => expect(shownLanguage()).toBe('zh-Hans')); expect(adapter.update).not.toHaveBeenCalled(); expect(adapter.findOne).not.toHaveBeenCalled(); // The device slot is this visitor's own setting, exactly as before. - expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh'); + expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-Hans'); }); }); @@ -251,6 +252,41 @@ describe('pickRenderableLocale', () => { * (exact → case → base language → variant), so a legacy mis-cased row and a * tag the deployment offers only as a variant no longer render English. */ +/** + * objectstack#30511: before the base-language step, the platform locale + * table's reading. A row stored as `zh-TW` is Traditional, and the first `zh` + * variant a list offers is not necessarily so. + */ +describe('pickRenderableLocale reads a stored tag as the platform locale table does', () => { + // The renderer's own list when no deployment list is wired: the table's tags. + const builtIn = ['en', 'zh-Hans', 'zh-Hant', 'es', 'ar', 'fr', 'pt-BR', 'ru', 'de', 'ja', 'ko']; + + it('renders a Traditional row Traditional, whichever spelling it was stored in', () => { + for (const stored of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant', 'zh-Hant-TW', 'ZH-tw']) { + expect(pickRenderableLocale(stored, builtIn), stored).toBe('zh-Hant'); + } + }); + + it('renders a Simplified row Simplified', () => { + for (const stored of ['zh', 'zh-CN', 'zh-SG', 'zh-Hans', 'zh-Hans-CN']) { + expect(pickRenderableLocale(stored, builtIn), stored).toBe('zh-Hans'); + } + }); + + it('reads the old `pt` code as the pt-BR pack', () => { + expect(pickRenderableLocale('pt', builtIn)).toBe('pt-BR'); + }); + + it('finds the same language under a deployment\'s own spelling of it', () => { + expect(pickRenderableLocale('zh-HK', ['en', 'zh-CN', 'zh-TW'])).toBe('zh-TW'); + expect(pickRenderableLocale('zh', ['en', 'zh-TW', 'zh-CN'])).toBe('zh-CN'); + }); + + it('CONTROL: a deployment that offers no Traditional spelling falls through to the base language order', () => { + expect(pickRenderableLocale('zh-TW', ['en', 'zh-CN'])).toBe('zh-CN'); + }); +}); + describe('pickRenderableLocale follows the server resolveLocale order', () => { // What a deployment's locale endpoint answers: full BCP-47 tags. const served = ['en', 'zh-CN', 'ja-JP']; @@ -279,7 +315,7 @@ describe('pickRenderableLocale follows the server resolveLocale order', () => { }); it('takes the first offered variant when several share a base', () => { - expect(pickRenderableLocale('zh', ['en', 'zh-TW', 'zh-CN'])).toBe('zh-TW'); + expect(pickRenderableLocale('es', ['en', 'es-MX', 'es-ES'])).toBe('es-MX'); }); it('still matches nothing for a language nobody offers', () => { diff --git a/ui/packages/app-shell/src/hooks/useUserLocale.ts b/ui/packages/app-shell/src/hooks/useUserLocale.ts index 67e31d981ad..4cc6932538a 100644 --- a/ui/packages/app-shell/src/hooks/useUserLocale.ts +++ b/ui/packages/app-shell/src/hooks/useUserLocale.ts @@ -76,6 +76,7 @@ import { LOCALE_STORAGE_KEY, readStoredLanguage, resolveBootstrapLanguage, + resolveBuiltInLanguage, useObjectTranslation, } from '@object-ui/i18n'; import { useAdapter, useDataInvalidation, extractWriteErrorMessage } from '@object-ui/react'; @@ -285,8 +286,15 @@ const LOCALE_FIELD = 'locale'; * 1. exact tag (`pt-BR` → `pt-BR`; a real `pt-BR` still beats `pt`); * 2. the same tag in any case (`zh-cn` → `zh-CN`) — a row written before the * server door folded case is served as stored, never migrated; - * 3. the base language (`zh-CN` → `zh`, `en-GB` → `en`), case-insensitively; - * 4. the first offered variant of that base language (`zh` → `zh-CN`, + * 3. the first offered code the platform locale table reads as the same + * language (`zh-TW` → `zh-Hant`, `zh-HK` → `zh-TW`, `zh` → `zh-CN`, + * `pt` → `pt-BR`; objectstack#30511). The table's read aliases are how a + * row stored before the table keeps its meaning, and the one step the + * server's order lacks: without it a base-language match hands a + * Traditional row (`zh-TW`) the first `zh` variant offered, which is the + * Simplified pack; + * 4. the base language (`zh-CN` → `zh`, `en-GB` → `en`), case-insensitively; + * 5. the first offered variant of that base language (`es` → `es-MX`, * `zh-Hans-CN` → `zh-CN`), in the order the list offers them. * * A match answers the OFFERED spelling, never the stored one: the list is what @@ -307,12 +315,18 @@ export function pickRenderableLocale( const lower = tag.toLowerCase(); const caseMatch = offerable.find((code) => code.toLowerCase() === lower); if (caseMatch) return caseMatch; - // 3. Base language. + // 3. The same language, as the platform locale table reads both tags. + const pack = resolveBuiltInLanguage(tag); + if (pack !== null) { + const sameLanguage = offerable.find((code) => resolveBuiltInLanguage(code) === pack); + if (sameLanguage) return sameLanguage; + } + // 4. Base language. const base = lower.split('-')[0]; if (!base) return null; const baseMatch = offerable.find((code) => code.toLowerCase() === base); if (baseMatch) return baseMatch; - // 4. Variant of the base language — first offered wins. + // 5. Variant of the base language — first offered wins. const variantMatch = offerable.find((code) => code.split('-')[0].toLowerCase() === base); return variantMatch ?? null; } diff --git a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx index e79c653d1ef..0bb5bdc1dce 100644 --- a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx @@ -37,10 +37,20 @@ vi.mock('lucide-react', async (importOriginal) => ({ Globe: () => , })); -import { LocaleSwitcher } from '../LocaleSwitcher'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; +import { LocaleSwitcher, localeLabel } from '../LocaleSwitcher'; -/** The eight built-in native names an app shipping only `en`+`zh` must NOT show. */ -const PHANTOM_LABELS = ['日本語', '한국어', 'Deutsch', 'Français', 'Español', 'Português', 'Русский', 'العربية']; +/** + * The eight built-in native names an app shipping only `en`+`zh` must NOT show: + * every platform locale table language but English and Chinese, named as the + * menu names them (`pt-BR` by `Intl.DisplayNames`, objectstack#30511). + */ +const PHANTOM_LABELS = PLATFORM_LOCALE_TAGS + .filter((tag) => tag !== 'en' && !tag.startsWith('zh-')) + .map(localeLabel); + +/** Every built-in pack's menu name: one per platform locale table tag. */ +const BUILT_IN_PACK_LABELS = PLATFORM_LOCALE_TAGS.map(localeLabel); /** The console's real wiring: app packs arrive through the dynamic loader. */ function mountConsole(opts: { @@ -105,19 +115,18 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { } }); - it('falls back to the built-in ten when the endpoint fails', async () => { + it('falls back to the built-in packs when the endpoint fails', async () => { mountConsole({ locales: async () => { throw new Error('ECONNREFUSED'); } }); // No backend is not "this app ships nothing" — the switcher must stay // usable, offering everything this renderer can produce on its own. await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument()); - for (const label of PHANTOM_LABELS) { + for (const label of BUILT_IN_PACK_LABELS) { expect(screen.getByText(label)).toBeInTheDocument(); } - expect(screen.getByText('中文')).toBeInTheDocument(); }); - it('falls back to the built-in ten when the app answers with nothing usable', async () => { + it('falls back to the built-in packs when the app answers with nothing usable', async () => { mountConsole({ locales: async () => [] }); await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument()); @@ -151,7 +160,7 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { expect(screen.queryByText('日本語')).not.toBeInTheDocument(); }); - it('offers the built-in ten when no locale endpoint is wired at all', async () => { + it('offers the built-in packs when no locale endpoint is wired at all', async () => { render( @@ -161,7 +170,7 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { // An app that never wires `loadLocales` keeps exactly today's menu, with // no blank frame in front of it. expect(screen.getByText('English')).toBeInTheDocument(); - for (const label of PHANTOM_LABELS) { + for (const label of BUILT_IN_PACK_LABELS) { expect(screen.getByText(label)).toBeInTheDocument(); } }); diff --git a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.persistence.test.tsx b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.persistence.test.tsx index d77642a0364..f14845ba7d1 100644 --- a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.persistence.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.persistence.test.tsx @@ -32,7 +32,7 @@ vi.mock('lucide-react', async (importOriginal) => ({ Globe: () => , })); -import { LocaleSwitcher } from '../LocaleSwitcher'; +import { LocaleSwitcher, localeLabel } from '../LocaleSwitcher'; /** A page load: a fresh provider building a fresh i18next instance. */ function load() { @@ -53,10 +53,11 @@ describe('LocaleSwitcher persistence', () => { // English before the switch — the switcher's own sr-only label localizes. expect(screen.getByText('Language')).toBeInTheDocument(); - fireEvent.click(screen.getByText('中文')); + // The Simplified Chinese pack, named as the menu names `zh-Hans` (objectstack#30511). + fireEvent.click(screen.getByText(localeLabel('zh-Hans'))); await waitFor(() => expect(screen.getByText('语言')).toBeInTheDocument()); - expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh'); + expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-Hans'); first.unmount(); diff --git a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.unsavedEditsAsk-13254.test.tsx b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.unsavedEditsAsk-13254.test.tsx index 334d6e931eb..fbc0c608f9a 100644 --- a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.unsavedEditsAsk-13254.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.unsavedEditsAsk-13254.test.tsx @@ -43,7 +43,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ useAdapter: () => session.adapter, })); -import { LocaleSwitcher } from '../LocaleSwitcher'; +import { LocaleSwitcher, localeLabel } from '../LocaleSwitcher'; import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits'; const QUESTION = 'You have unsaved changes. Are you sure you want to leave?'; @@ -95,6 +95,9 @@ const language = () => screen.getByTestId('language').textContent; const draft = () => (screen.getByLabelText('Date format') as HTMLInputElement).value; const edit = () => fireEvent.change(screen.getByLabelText('Date format'), { target: { value: EDITED } }); +/** The Simplified Chinese entry, as the menu names the `zh-Hans` pack (objectstack#30511). */ +const SIMPLIFIED = localeLabel('zh-Hans'); + /** Open the globe menu the way a pointer does, then pick an entry by its native name. */ function pick(name: string) { fireEvent.pointerDown(screen.getByRole('button', { name: /^(Language|语言)$/ }), { button: 0, pointerType: 'mouse' }); @@ -126,7 +129,7 @@ describe('the language menu asks before a switch drops unsaved edits (objectui#1 mount(); edit(); - pick('中文'); + pick(SIMPLIFIED); expect(confirmSpy).toHaveBeenCalledTimes(1); expect(confirmSpy).toHaveBeenCalledWith(QUESTION); // Settle anything a switch would have started before reading what stayed. @@ -143,10 +146,10 @@ describe('the language menu asks before a switch drops unsaved edits (objectui#1 edit(); confirmSpy.mockReturnValue(true); - pick('中文'); + pick(SIMPLIFIED); expect(confirmSpy).toHaveBeenCalledWith(QUESTION); - await waitFor(() => expect(language()).toBe('zh')); - await waitFor(() => expect(adapter.row.locale).toBe('zh')); + await waitFor(() => expect(language()).toBe('zh-Hans')); + await waitFor(() => expect(adapter.row.locale).toBe('zh-Hans')); expect(adapter.update).toHaveBeenCalledTimes(1); expect(screen.queryByRole('menu')).not.toBeInTheDocument(); }); @@ -165,8 +168,8 @@ describe('the language menu asks before a switch drops unsaved edits (objectui#1 edit(); confirmSpy.mockReturnValue(true); - pick('中文'); - await waitFor(() => expect(language()).toBe('zh')); + pick(SIMPLIFIED); + await waitFor(() => expect(language()).toBe('zh-Hans')); expect(unloadHeldDuringSwitch).toBe(false); expect(confirmSpy).toHaveBeenCalledTimes(1); }); @@ -194,9 +197,9 @@ describe('CONTROL: nothing registered, nothing asked (objectui#13254)', () => { it('a clean page switches at once', async () => { mount(); - pick('中文'); + pick(SIMPLIFIED); expect(confirmSpy).not.toHaveBeenCalled(); - await waitFor(() => expect(language()).toBe('zh')); - await waitFor(() => expect(adapter.row.locale).toBe('zh')); + await waitFor(() => expect(language()).toBe('zh-Hans')); + await waitFor(() => expect(adapter.row.locale).toBe('zh-Hans')); }); }); diff --git a/ui/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx b/ui/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx index 1c898ee7ff1..9b8b0477a86 100644 --- a/ui/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx @@ -90,7 +90,7 @@ describe.each(LAUNCHERS)('$name — the plan-awaiting-approval marker (objectui# renderIn('zh', launcher.element); publish('conv-a', true); - const zh = (builtInLocales.zh as unknown as { console: { ai: { dock: { planAwaitingApproval: string } } } }) + const zh = (builtInLocales['zh-Hans'] as unknown as { console: { ai: { dock: { planAwaitingApproval: string } } } }) .console.ai.dock.planAwaitingApproval; expect(zh).not.toBe(EN_MARKER); await vi.waitFor(() => diff --git a/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx index 4ebd1343cb4..e42a26b3f1b 100644 --- a/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx +++ b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx @@ -500,7 +500,7 @@ describe('the newTabUrl fast path refuses in the session language (objectui#1339 const zhT = (key: string, englishDefault: string) => { const hit = key.split('.').reduce( (node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), - builtInLocales.zh, + builtInLocales['zh-Hans'], ); return typeof hit === 'string' ? hit : englishDefault; }; diff --git a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts index bd45b95b47d..474d1e4032a 100644 --- a/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/flowResponse.test.ts @@ -680,7 +680,7 @@ describe('interpretFlowResponse — a refusal met inside the run, by the record- expect(en).toMatchObject({ error: builtInLocales.en.form.noPermissionToSave }); const zh = failed400(flowFailed({ innerRefusal: DENIED }), 'zh-CN'); - expect(zh).toMatchObject({ error: builtInLocales.zh.form.noPermissionToSave }); + expect(zh).toMatchObject({ error: builtInLocales['zh-Hans'].form.noPermissionToSave }); expect((zh as { error: string }).error).not.toContain('crm_account'); }); @@ -760,7 +760,7 @@ describe('apiActionFailureMessage — a flow-backed api action reads the run ref it("a one-level permission refusal reads the record form's line, in the host's language", () => { expect(on400(endpointFailed(ONE_LEVEL, { innerRefusal: DENIED }))).toBe(builtInLocales.en.form.noPermissionToSave); - expect(on400(endpointFailed(ONE_LEVEL, { innerRefusal: DENIED }), 'zh-CN')).toBe(builtInLocales.zh.form.noPermissionToSave); + expect(on400(endpointFailed(ONE_LEVEL, { innerRefusal: DENIED }), 'zh-CN')).toBe(builtInLocales['zh-Hans'].form.noPermissionToSave); }); it('the nested map → subflow chain reads the same line once the inner refusal is carried', () => { diff --git a/ui/packages/app-shell/src/utils/flowResponse.ts b/ui/packages/app-shell/src/utils/flowResponse.ts index 2192b15d0f6..da62de26828 100644 --- a/ui/packages/app-shell/src/utils/flowResponse.ts +++ b/ui/packages/app-shell/src/utils/flowResponse.ts @@ -213,7 +213,7 @@ import { actionCopySource, actionErrorDetail, refusalFieldErrors } from '@object-ui/core'; import type { ActionResult } from '@object-ui/core'; -import { getLoadedBuiltInLocales } from '@object-ui/i18n'; +import { getLoadedBuiltInLocales, resolveBuiltInLanguage } from '@object-ui/i18n'; import { declaredUserMessage, delegationScopeMessage, isPermissionError } from '@object-ui/react'; import { errorCodeIs } from '@object-ui/types'; import type { AutomationResult } from '@objectstack/spec/contracts'; @@ -303,8 +303,9 @@ type PermissionLinePack = { form?: { noPermissionToSave?: unknown } } | undefine /** * The record form's permission line (`form.noPermissionToSave`) in `locale`, - * read from the RESIDENT built-in catalogue — exact tag first, then its base - * language, the registry's own order (the same read `LoadingScreen` makes). + * read from the RESIDENT built-in catalogue of the table tag `locale` means — + * `resolveBuiltInLanguage`, the registry's own resolution (`zh-CN` → `zh-Hans`, + * `zh-TW` → `zh-Hant`, `fr-CA` → `fr`; the same read `LoadingScreen` makes). * The active language's catalogue is resident whenever the console runs in * it; anything else answers the resident `en` catalogue, which always is. * The literal is the last resort only for a registry that answers neither. @@ -318,10 +319,9 @@ type PermissionLinePack = { form?: { noPermissionToSave?: unknown } } | undefine function permissionDeniedLine(locale: string | undefined): string { const usable = (line: unknown): string | undefined => typeof line === 'string' && line.trim() ? line : undefined; - const base = locale ? locale.toLowerCase().split('-')[0] : undefined; + const tag = locale ? resolveBuiltInLanguage(locale) : null; return ( - (locale ? usable((getLoadedBuiltInLocales()[locale] as PermissionLinePack)?.form?.noPermissionToSave) : undefined) - ?? (base ? usable((getLoadedBuiltInLocales()[base] as PermissionLinePack)?.form?.noPermissionToSave) : undefined) + (tag ? usable((getLoadedBuiltInLocales()[tag] as PermissionLinePack)?.form?.noPermissionToSave) : undefined) ?? usable((getLoadedBuiltInLocales().en as PermissionLinePack)?.form?.noPermissionToSave) ?? "You don't have permission to save this record." ); diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx index fbcf108b079..7c222ffc75a 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.filterChipI18n-9159.test.tsx @@ -187,14 +187,14 @@ afterEach(() => { describe('the is-null filter chip is translated (objectui#9159)', () => { it('renders the zh pack copy, not an English literal', () => { - renderAt('zh', EMPTY_BUCKET_SEARCH); + renderAt('zh-Hans', EMPTY_BUCKET_SEARCH); const rendered = chipOperatorText(); // The rendered copy IS the pack copy. Read from the pack rather than // written out here: this card owns whether the chip is TRANSLATED, while // the wording of the operator label belongs to the catalogue and to the // filter builder that shares this key. - expect(rendered).toBe(packLabel('zh')); + expect(rendered).toBe(packLabel('zh-Hans')); // A key that failed to resolve renders as itself — the exact user-visible // symptom the filter-builder operator family's locale-parity pin names. expect(rendered).not.toContain('filterBuilder.operators'); @@ -209,14 +209,14 @@ describe('the is-null filter chip is translated (objectui#9159)', () => { // `t` at all, if the packs happened to agree. renderAt('en', EMPTY_BUCKET_SEARCH); expect(chipOperatorText()).toBe(packLabel('en')); - expect(packLabel('zh')).not.toBe(packLabel('en')); + expect(packLabel('zh-Hans')).not.toBe(packLabel('en')); }); it('CONTROL: an equality chip is NOT translated, because its text is the user\'s own comparand', () => { // The other half of the split: a comparand has no catalogue entry and must // reach the DOM verbatim in every locale. A change that routed every chip // through `t` would turn this red. - renderAt('zh', `filter[${FIELD}]=alice`); + renderAt('zh-Hans', `filter[${FIELD}]=alice`); expect(chipOperatorText()).toBe('= alice'); }); }); @@ -239,9 +239,9 @@ describe('the is-empty filter chips are translated (objectui#11547)', () => { [EMPTY_FILTER.flag, EMPTY_FILTER.labelKey], [EMPTY_FILTER.notFlag, EMPTY_FILTER.notLabelKey], ])('`[empty]=%s` renders the zh pack copy of `%s`', (flag, key) => { - renderAt('zh', `filter[${FIELD}][${EMPTY_FILTER.param}]=${flag}`); + renderAt('zh-Hans', `filter[${FIELD}][${EMPTY_FILTER.param}]=${flag}`); const rendered = chipOperatorText(); - expect(rendered).toBe(packLabelOf('zh', key)); + expect(rendered).toBe(packLabelOf('zh-Hans', key)); expect(rendered).not.toContain('filterBuilder.operators'); expect(rendered).not.toContain('true'); expect(rendered).not.toContain('false'); @@ -250,9 +250,9 @@ describe('the is-empty filter chips are translated (objectui#11547)', () => { it('CONTROL: the en pack renders too, the packs differ, and the two directions differ', () => { renderAt('en', `filter[${FIELD}][${EMPTY_FILTER.param}]=${EMPTY_FILTER.flag}`); expect(chipOperatorText()).toBe(packLabelOf('en', EMPTY_FILTER.labelKey)); - expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('en', EMPTY_FILTER.labelKey)); - expect(packLabelOf('zh', EMPTY_FILTER.notLabelKey)).not.toBe(packLabelOf('zh', EMPTY_FILTER.labelKey)); + expect(packLabelOf('zh-Hans', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('en', EMPTY_FILTER.labelKey)); + expect(packLabelOf('zh-Hans', EMPTY_FILTER.notLabelKey)).not.toBe(packLabelOf('zh-Hans', EMPTY_FILTER.labelKey)); // And neither is the is-null label: "is empty" is a different question. - expect(packLabelOf('zh', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh', NULL_FILTER.labelKey)); + expect(packLabelOf('zh-Hans', EMPTY_FILTER.labelKey)).not.toBe(packLabelOf('zh-Hans', NULL_FILTER.labelKey)); }); }); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx index 54944f7b2a6..a81124739b5 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.deleteConfirmDialog-11001.test.tsx @@ -101,7 +101,7 @@ type Pack = { }; /** The packs, read by key — see the header on why nothing is written out. */ const EN = builtInLocales.en as unknown as Pack; -const ZH = builtInLocales.zh as unknown as Pack; +const ZH = builtInLocales['zh-Hans'] as unknown as Pack; const fill = (template: string, vars: Record) => template.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => vars[k]); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx index 0a298cc3221..825654ba6bf 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx @@ -316,7 +316,7 @@ describe('a failed reaction write does not stay shown as applied (objectui#10899 const i18n = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false, - resources: { zh: builtInLocales.zh as unknown as Record }, + resources: { zh: builtInLocales['zh-Hans'] as unknown as Record }, }); expect(i18n.language).toBe('zh'); const addLabel = i18n.t('detail.addReaction'); @@ -340,7 +340,7 @@ describe('a failed reaction write does not stay shown as applied (objectui#10899 expect(toast.error).toHaveBeenCalledTimes(1); const [message] = vi.mocked(toast.error).mock.calls[0]; expect(message).toBe(i18n.t('detail.reactionFailed')); - expect(message).toBe((builtInLocales.zh as any).detail.reactionFailed); + expect(message).toBe((builtInLocales['zh-Hans'] as any).detail.reactionFailed); expect(message).not.toBe((builtInLocales.en as any).detail.reactionFailed); }); }); diff --git a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx index 92375001f21..4c5ef7a44a0 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx @@ -46,6 +46,7 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { I18nProvider } from '@object-ui/i18n'; import { builtInLocales } from '@object-ui/i18n/locales'; import { RecordFormPage } from './RecordFormPage'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; const h = React.createElement; @@ -180,10 +181,10 @@ describe('RecordFormPage title resolves from the locale packs (#3469)', () => { // The strongest available proof that the PACK drives the title: under `zh` // the title is Chinese. No inline English `defaultValue` can satisfy this // assertion, so it fails loudly if the packs ever stop being consulted. - renderPage('create', { language: 'zh' }); + renderPage('create', { language: 'zh-Hans' }); // The pack's `新建{{object}}`, with the zh spacing between Han text and a // Latin label that `createI18n` applies to this key. - expect(String(builtInLocales.zh.form.createTitle)).toBe('新建{{object}}'); + expect(String(builtInLocales['zh-Hans'].form.createTitle)).toBe('新建{{object}}'); expect(title()).toBe('新建 Contacts'); }); }); @@ -246,8 +247,8 @@ describe('the keys RecordFormPage passes bare are defined in every pack (#3469)' expect(missing, `${key} missing from: ${missing.join(', ')}`).toEqual([]); }); - it('the comparison really covers ten packs', () => { - expect(Object.keys(builtInLocales)).toHaveLength(10); + it('the comparison really covers every pack', () => { + expect(Object.keys(builtInLocales)).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); }); @@ -283,9 +284,9 @@ describe('the ADR-0105 write-target badge resolves from the locale packs (#3517) // `Creates in ${org}`, so it could never satisfy this assertion — if the // packs ever stop driving this badge, it fails loudly instead of quietly // reverting to English. - const badge = await badgeIn('zh'); + const badge = await badgeIn('zh-Hans'); expect(badge).toHaveTextContent( - String(builtInLocales.zh.form.createTargetOrg).replace('{{org}}', 'Acme Inc'), + String(builtInLocales['zh-Hans'].form.createTargetOrg).replace('{{org}}', 'Acme Inc'), ); expect(badge).toHaveTextContent('创建到Acme Inc'); }); diff --git a/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.deleteConfirm-12248.test.tsx b/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.deleteConfirm-12248.test.tsx index 67b7ff3895d..c94d01a7543 100644 --- a/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.deleteConfirm-12248.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.deleteConfirm-12248.test.tsx @@ -41,7 +41,7 @@ type Pack = { /** The packs, read by key — see the header on why nothing is written out. */ const PACKS = { en: builtInLocales.en as unknown as Pack, - zh: builtInLocales.zh as unknown as Pack, + zh: builtInLocales['zh-Hans'] as unknown as Pack, }; const fill = (template: string, vars: Record) => diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.decidedSnapshot-27502.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.decidedSnapshot-27502.test.tsx index 0845afe6c2c..1690ced17b0 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.decidedSnapshot-27502.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.decidedSnapshot-27502.test.tsx @@ -35,6 +35,7 @@ import { createI18n, I18nProvider } from '@object-ui/i18n'; import { builtInLocales } from '@object-ui/i18n/locales'; import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel'; import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); @@ -147,9 +148,9 @@ describe('ApprovalDecisionPanel — the summary is labelled as the submitted val expect(within(panel).queryByText(HEADING)).toBeNull(); }); - it('the heading copy is a key in all ten language packs', () => { + it('the heading copy is a key in every language pack', () => { const languages = Object.keys(builtInLocales); - expect(languages).toHaveLength(10); + expect(languages).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const language of languages) { const value = packHeading(language); expect(typeof value, `${language} defines ${HEADING_KEY}`).toBe('string'); diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx index fa06ef1a415..6bc1c02684e 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx @@ -146,7 +146,7 @@ describe('ApprovalDecisionPanel — a principal holding several slots: the chip }); it('the zh pack draws its own words for the chip, beside its own eligible count', async () => { - const zh = builtInLocales.zh.approvalsInbox as Record; + const zh = builtInLocales['zh-Hans'].approvalsInbox as Record; const en = builtInLocales.en.approvalsInbox as Record; // zh selects `other` at every count. const zhWords = zh.approverSlots_other.replace('{{count}}', '3'); diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.flowOrigin-12491.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.flowOrigin-12491.test.tsx index c6a9ba252c3..b9073c4af50 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.flowOrigin-12491.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.flowOrigin-12491.test.tsx @@ -100,12 +100,12 @@ describe('ApprovalDecisionPanel — the flow origin of a request no person submi it('draws the active language: the zh pack row under a zh provider', () => { const panel = mount(FLOW_ROW, (node) => ( - + {node} )); - expect(marker(panel)?.textContent).toBe(flowOrigin('zh')); - expect(flowOrigin('zh')).not.toBe(flowOrigin('en')); + expect(marker(panel)?.textContent).toBe(flowOrigin('zh-Hans')); + expect(flowOrigin('zh-Hans')).not.toBe(flowOrigin('en')); }); it('CONTROL: a request a person submitted carries no marker', () => { diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx index 3e71f6c70c4..9191178997e 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx @@ -58,6 +58,7 @@ import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory'; // The package's registration leaf, imported for its side effect as the // package entry imports it (objectui#12342). import '../record-approval-decision-renderer'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; afterEach(() => { cleanup(); @@ -173,9 +174,9 @@ describe('off an approval request page, a localized notice instead of nothing (o expect(wrapper.getAttribute('data-obj-type')).toBe('record:approval_decision'); }); - it('the notice copy is a key in all ten language packs', () => { + it('the notice copy is a key in every language pack', () => { const languages = Object.keys(builtInLocales); - expect(languages).toHaveLength(10); + expect(languages).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const language of languages) { const value = packNotice(language); expect(typeof value, `${language} defines ${NOTICE_KEY}`).toBe('string'); @@ -185,13 +186,13 @@ describe('off an approval request page, a localized notice instead of nothing (o it('renders the active language: the zh pack value under a zh provider', () => { render( - + , ); - expect(noticeText()).toBe(packNotice('zh')); + expect(noticeText()).toBe(packNotice('zh-Hans')); }); it('CONTROL: the en provider renders the en value, and the two packs differ', () => { @@ -203,7 +204,7 @@ describe('off an approval request page, a localized notice instead of nothing (o , ); expect(noticeText()).toBe(packNotice('en')); - expect(packNotice('zh')).not.toBe(packNotice('en')); + expect(packNotice('zh-Hans')).not.toBe(packNotice('en')); }); }); diff --git a/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx index f75c43e4d89..022c27c5f62 100644 --- a/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx +++ b/ui/packages/components/src/__tests__/page-tabs-builtin-label-i18n-4645.test.tsx @@ -133,10 +133,10 @@ describe('page:tabs — built-in record-detail labels speak the session locale ( }); /** - * `zh-TW` has no pack of its own — i18next resolves it to the Simplified - * `zh` resource — so the exact-locale dict is the ONLY source of Traditional - * forms for these tokens. It therefore stays AHEAD of the pack lookup, and - * this is the assertion that says so. + * The exact-locale dict stays AHEAD of the pack lookup. It was written while + * `zh-TW` had no pack and i18next served it the Simplified one; the + * generated `zh-Hant` pack (objectstack#30511) now carries the same words for + * these tokens, so this pins that the Traditional rows still win. */ it('keeps the zh-TW Traditional forms the dict is the only source of', () => { const { container } = renderSynthesizedStrip('zh-TW'); @@ -151,6 +151,54 @@ describe('page:tabs — built-in record-detail labels speak the session locale ( expect(texts).not.toContain('动态'); }); + /** + * objectstack#30511: a session in the table's Traditional tag (or a region + * the table reads as it) reads the Traditional rows, never the Simplified + * ones its base language `zh` used to fall back to. `Notes` and `Overview` + * are dict-only tokens: no pack carries them, so only the dict can answer. + */ + it.each(['zh-Hant', 'zh-HK', 'zh-MO'])('a %s session reads the Traditional rows, dict-only tokens included', (language) => { + const { container } = render( + + ({ + label, + value: label.toLowerCase(), + children: textChild(label), + })), + }, + }} + /> + , + ); + expect(tabTexts(container)).toEqual(['詳情', '備註', '概覽']); + }); + + it.each(['zh-Hans', 'zh-SG'])('CONTROL: a %s session reads the Simplified rows', (language) => { + const { container } = render( + + ({ + label, + value: label.toLowerCase(), + children: textChild(label), + })), + }, + }} + /> + , + ); + expect(tabTexts(container)).toEqual(['详情', '备注', '概览']); + }); + /** * An author-supplied label is not a well-known token and must survive * untouched — the strip localizes the synthesizer's English, never the diff --git a/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx index 665f6af2115..e71243106dd 100644 --- a/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx +++ b/ui/packages/components/src/__tests__/renderer-copy-locale-25916.test.tsx @@ -36,7 +36,7 @@ import type { PageDocumentNode } from '@object-ui/types'; // object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; -const { zh, en } = builtInLocales; +const { 'zh-Hans': zh, en } = builtInLocales; /** No `{{slot}}` and no private-use slot mark survives into the rendered text. */ const LEAK = /\{\{|[\u{E000}-\u{F8FF}]/u; diff --git a/ui/packages/components/src/__tests__/share-dialog-locale-13460.test.tsx b/ui/packages/components/src/__tests__/share-dialog-locale-13460.test.tsx index b4708bea843..456d85f06a8 100644 --- a/ui/packages/components/src/__tests__/share-dialog-locale-13460.test.tsx +++ b/ui/packages/components/src/__tests__/share-dialog-locale-13460.test.tsx @@ -33,7 +33,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -const { zh, en } = builtInLocales; +const { 'zh-Hans': zh, en } = builtInLocales; const zhShare = zh.shareDialog; function dialog(language: 'zh' | null, recordLabel?: string) { diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index 2996d3336eb..5234141821b 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -66,7 +66,7 @@ import { PageHeaderPhoneContext } from './page-header-phone-context'; import { renderBlockChildren } from './page-block-grid'; import { isFileIdToken } from '@objectstack/spec/data'; import { readActionEntryParamValues } from '../action/static-params'; -import { useObjectLabel, useSafeFieldLabel, useObjectTranslation, useSafeTranslate, createSafeTranslation, pickLocalized, useDisplayLocale } from '@object-ui/i18n'; +import { useObjectLabel, useSafeFieldLabel, useObjectTranslation, useSafeTranslate, createSafeTranslation, pickLocalized, useDisplayLocale, resolveBuiltInLanguage } from '@object-ui/i18n'; import { MoreHorizontal, RefreshCw } from 'lucide-react'; /** @@ -167,10 +167,13 @@ const labelText = (label: any): string => { * localized chrome. * * What stays here is what the packs cannot answer: - * - `zh-TW`. There is no `zh-TW` pack; i18next resolves it to the - * SIMPLIFIED `zh` resource, so this map is the only source of the - * Traditional forms. It must therefore win over the pack lookup, which is - * why the dict is consulted first rather than second. + * - the Traditional rows. They were written while `zh-TW` had no pack and + * i18next served it the Simplified one, so this map was the only source + * of Traditional forms and is consulted first. The generated `zh-Hant` + * pack (objectstack#30511) now answers the pack-covered tokens with the + * same words; a `zh-Hant` session (and `zh-HK` / `zh-MO`, which the + * platform locale table reads as it) reads these rows too, never the + * Simplified ones. * - the tokens no pack carries (`Notes`, `Files`, `Tasks`, `Events`, * `Overview`, and the object-name rows) — dropping them would take * coverage AWAY from zh. @@ -292,10 +295,15 @@ const translateLabel = ( tt: (keyOrKeys: string | string[], fallback: string) => string, ): string => { if (!text) return text; - // Match `zh-CN`, `zh-TW`, then base `zh` → `zh-CN`. + // Match `zh-CN`, `zh-TW`, then any other `zh` by the script its platform + // locale tag names: `zh-Hant` (`zh-HK`, `zh-MO`) → `zh-TW`, the rest → + // `zh-CN` (objectstack#30511). const exact = KNOWN_LABEL_DICT[locale]; const base = locale.split('-')[0]; - const fallback = base === 'zh' ? KNOWN_LABEL_DICT['zh-CN'] : undefined; + const fallback = + base === 'zh' + ? KNOWN_LABEL_DICT[resolveBuiltInLanguage(locale) === 'zh-Hant' ? 'zh-TW' : 'zh-CN'] + : undefined; const dict = exact || fallback; /** * One token, in lookup order: the exact-locale dict, then the pack. diff --git a/ui/packages/fields/src/__tests__/LocationField.placeholderI18n-8149.test.tsx b/ui/packages/fields/src/__tests__/LocationField.placeholderI18n-8149.test.tsx index 731dd803f6a..4e334ca6234 100644 --- a/ui/packages/fields/src/__tests__/LocationField.placeholderI18n-8149.test.tsx +++ b/ui/packages/fields/src/__tests__/LocationField.placeholderI18n-8149.test.tsx @@ -194,7 +194,7 @@ describe('every pack\'s hint is its two nouns in the parser\'s order (objectui#8 it('covers more than one pack (guards the population itself)', () => { expect(LANGS.length).toBeGreaterThan(1); - expect(LANGS).toEqual(expect.arrayContaining(['en', 'zh'])); + expect(LANGS).toEqual(expect.arrayContaining(['en', 'zh-Hans', 'zh-Hant'])); }); it.each(LANGS)('reads as [latitude, longitude] under the parser\'s split in %s', (language) => { diff --git a/ui/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx b/ui/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx index f727c81372a..eae42822d3d 100644 --- a/ui/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx +++ b/ui/packages/fields/src/__tests__/LocationField.residueI18n-6888.test.tsx @@ -73,6 +73,7 @@ import { builtInLocales } from '@object-ui/i18n/locales'; import { LocationField } from '../widgets/LocationField'; import { AddressField } from '../widgets/AddressField'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; const locationField = { name: 'site', label: 'Site', type: 'location' } as any; const addressField = { name: 'billing_address', type: 'address' } as any; @@ -141,7 +142,7 @@ describe('the locale channel is live in this run (control for objectui#6888)', ( // Without this, a blank or English diagnostic below would be a dead // provider rather than a missing key, and every negative assertion in this // file would pass for the wrong reason. - renderIn('zh', ); + renderIn('zh-Hans', ); expect(screen.getByLabelText('街道地址')).toBeInTheDocument(); expect(screen.getByLabelText('城市')).toBeInTheDocument(); }); @@ -182,7 +183,7 @@ describe('the English default is byte-identical after keying (objectui#6888)', ( describe('the residue refusal speaks the reader\'s language (objectui#6888)', () => { it.each([ - ['zh', '未保存:纬度“12abc”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], + ['zh-Hans', '未保存:纬度“12abc”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], ['ja', '保存されていません: 緯度「12abc」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。'], ['ar', 'لم يتم الحفظ: خط العرض «12abc» ليس رقمًا. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).'], ])('says the ONE-half refusal in %s', (language, expected) => { @@ -190,7 +191,7 @@ describe('the residue refusal speaks the reader\'s language (objectui#6888)', () }); it.each([ - ['zh', '未保存:纬度“12abc”和经度“34xyz”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], + ['zh-Hans', '未保存:纬度“12abc”和经度“34xyz”不是数字。请输入普通小数(例如 30.2741, 120.1551)。'], ['ja', '保存されていません: 緯度「12abc」と経度「34xyz」は数値ではありません。通常の小数で入力してください(例: 30.2741, 120.1551)。'], ['ar', 'لم يتم الحفظ: خط العرض «12abc» وخط الطول «34xyz» ليسا رقمين. أدخل أرقامًا عشرية عادية (مثال: 30.2741, 120.1551).'], ])('says the TWO-half refusal in %s', (language, expected) => { @@ -198,8 +199,8 @@ describe('the residue refusal speaks the reader\'s language (objectui#6888)', () }); it.each([ - ['zh', ONE_HALF], - ['zh', BOTH_HALVES], + ['zh-Hans', ONE_HALF], + ['zh-Hans', BOTH_HALVES], ['ja', ONE_HALF], ['ja', BOTH_HALVES], ['ar', ONE_HALF], @@ -224,7 +225,8 @@ describe('the residue refusal speaks the reader\'s language (objectui#6888)', () /* -------------------------------------------------------------------------- */ describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { - const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'es', 'fr', 'pt', 'ru', 'ar'] as const; + // Every pack: the platform locale table's tags (objectstack#30511). + const LANGS = PLATFORM_LOCALE_TAGS; /** * One pack's `fields.location` block, READ THROUGH ITS REAL TYPE. @@ -292,7 +294,7 @@ describe('arity lives in the pack VALUE, not in a hole (objectui#6888)', () => { describe('the refusal itself is unchanged by keying it (objectui#6888)', () => { it('still refuses the value it announced about, under a translated provider', () => { const onChange = vi.fn(); - const { container } = renderIn('zh', ); + const { container } = renderIn('zh-Hans', ); typeInto(container, BOTH_HALVES); // objectui#6715's rule, inherited rather than re-decided here. expect(onChange).not.toHaveBeenCalled(); @@ -304,7 +306,7 @@ describe('the refusal itself is unchanged by keying it (objectui#6888)', () => { it('shares ONE diagnostic element with the other two arms', () => { // The reason this card exists: a second `

` would have made the mixed // languages two separate lines rather than one, and far less confusing. - const { container } = renderIn('zh', ); + const { container } = renderIn('zh-Hans', ); typeInto(container, 'not a coordinate'); expect(container.querySelectorAll('p')).toHaveLength(1); typeInto(container, BOTH_HALVES); diff --git a/ui/packages/fields/src/__tests__/booleanValueLabel.locale-11689.test.tsx b/ui/packages/fields/src/__tests__/booleanValueLabel.locale-11689.test.tsx index 5247fe08f0c..daf194d6812 100644 --- a/ui/packages/fields/src/__tests__/booleanValueLabel.locale-11689.test.tsx +++ b/ui/packages/fields/src/__tests__/booleanValueLabel.locale-11689.test.tsx @@ -77,9 +77,9 @@ describe('read-only boolean words come from the locale (objectui#11689)', () => for (const [name, draw] of Object.entries(DRAWERS)) { describe(name, () => { it('zh renders 是 / 否', () => { - expect(inLocale('zh', draw(true)).container.textContent).toBe('是'); + expect(inLocale('zh-Hans', draw(true)).container.textContent).toBe('是'); cleanup(); - expect(inLocale('zh', draw(false)).container.textContent).toBe('否'); + expect(inLocale('zh-Hans', draw(false)).container.textContent).toBe('否'); }); it('en is unchanged: Yes / No', () => { @@ -132,7 +132,7 @@ describe('BooleanCellRenderer words come from the locale (objectui#11689)', () = it('zh: a status column holding false is an unchecked box named by the column label, with no pack word', () => { const { container } = inLocale( - 'zh', + 'zh-Hans', , ); expect(badgeOf(container)).toBeNull(); @@ -143,9 +143,9 @@ describe('BooleanCellRenderer words come from the locale (objectui#11689)', () = it('zh: the completion indicator is named in the pack', () => { const field = { name: 'completed', type: 'boolean', label: 'Completed' } as never; - expect(indicatorName(inLocale('zh', ).container)).toBe('已完成'); + expect(indicatorName(inLocale('zh-Hans', ).container)).toBe('已完成'); cleanup(); - expect(indicatorName(inLocale('zh', ).container)).toBe('未完成'); + expect(indicatorName(inLocale('zh-Hans', ).container)).toBe('未完成'); }); it('en: an Active box, Completed, Not completed', () => { diff --git a/ui/packages/fields/src/__tests__/numberBadInput.i18n-8148.test.tsx b/ui/packages/fields/src/__tests__/numberBadInput.i18n-8148.test.tsx index d1625c97ba4..34f4e5c3938 100644 --- a/ui/packages/fields/src/__tests__/numberBadInput.i18n-8148.test.tsx +++ b/ui/packages/fields/src/__tests__/numberBadInput.i18n-8148.test.tsx @@ -66,6 +66,7 @@ import { GeolocationField } from '../widgets/GeolocationField'; import { AddressField } from '../widgets/AddressField'; import { badInputMessage } from '../widgets/numberBadInput'; import { BAD_INPUT_AGREED } from './numberInputBrowserReadings.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; /** * Every widget of this class takes the same three runtime props, but their @@ -198,7 +199,7 @@ describe('the locale channel is live in this run (control for objectui#8148)', ( // Without this, a blank or English diagnostic below would be a dead // provider rather than a missing key, and every negative assertion in this // file would pass for the wrong reason. - renderIn('zh', React.createElement(asWidget(AddressField), { value: {}, onChange: vi.fn(), field: addressField })); + renderIn('zh-Hans', React.createElement(asWidget(AddressField), { value: {}, onChange: vi.fn(), field: addressField })); expect(screen.getByLabelText('街道地址')).toBeInTheDocument(); expect(screen.getByLabelText('城市')).toBeInTheDocument(); }); @@ -236,7 +237,7 @@ describe('all five examples still reach the rendered sentence (objectui#8148)', expect(EXAMPLES).toEqual(['1234', '1234.56', '12.5', '30.2741', '120.1551']); }); - it.each(['en', 'zh', 'ja', 'ar'])('every box still quotes its own example in %s', language => { + it.each(['en', 'zh-Hans', 'ja', 'ar'])('every box still quotes its own example in %s', language => { // Before AND after: the numeral is the widget's, not the locale's, so a // translated render must still carry it verbatim. BOXES.forEach((b, index) => { @@ -249,7 +250,7 @@ describe('all five examples still reach the rendered sentence (objectui#8148)', // The failure a single hard-coded example would produce: four boxes quoting // a fifth box's numeral. `1234` is a substring of nothing else here, and // `12.5`/`120.1551` are checked by exact-position containment below. - for (const language of ['en', 'zh'] as const) { + for (const language of ['en', 'zh-Hans'] as const) { BOXES.forEach((b, index) => { const message = refusalFrom(language, index) ?? ''; for (const other of EXAMPLES) { @@ -267,7 +268,7 @@ describe('all five examples still reach the rendered sentence (objectui#8148)', describe("the bad-input refusal speaks the reader's language (objectui#8148)", () => { it.each([ - ['zh', '未保存:此输入框中的文本不是数字。请输入普通小数(例如 1234)。'], + ['zh-Hans', '未保存:此输入框中的文本不是数字。请输入普通小数(例如 1234)。'], ['ja', '保存されていません: このボックスのテキストは数値ではありません。通常の小数で入力してください(例: 1234)。'], ['ar', 'لم يتم الحفظ: النص في هذا المربع ليس رقمًا. أدخل رقمًا عشريًا عاديًا (مثال: 1234).'], ])('says NumberField\'s refusal in %s', (language, expected) => { @@ -275,8 +276,8 @@ describe("the bad-input refusal speaks the reader's language (objectui#8148)", ( }); it.each([ - ['zh', 0], - ['zh', 4], + ['zh-Hans', 0], + ['zh-Hans', 4], ['ja', 2], ['ar', 3], ['ru', 1], @@ -295,8 +296,8 @@ describe("the bad-input refusal speaks the reader's language (objectui#8148)", ( it('the two GEOLOCATION boxes both speak it, which is the card\'s sharpest case', () => { // `GeolocationField` sits beside `LocationField`, already keyed. Before this // card the two coordinate widgets refused in two different languages. - expect(refusalFrom('zh', 3)).toContain('未保存:'); - expect(refusalFrom('zh', 4)).toContain('未保存:'); + expect(refusalFrom('zh-Hans', 3)).toContain('未保存:'); + expect(refusalFrom('zh-Hans', 4)).toContain('未保存:'); }); }); @@ -305,7 +306,8 @@ describe("the bad-input refusal speaks the reader's language (objectui#8148)", ( /* -------------------------------------------------------------------------- */ describe('the example is a HOLE, not five per-widget keys (objectui#8148)', () => { - const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'es', 'fr', 'pt', 'ru', 'ar'] as const; + // Every pack: the platform locale table's tags (objectstack#30511). + const LANGS = PLATFORM_LOCALE_TAGS; /** * One pack's `fields.number` block, READ THROUGH ITS REAL TYPE. @@ -374,7 +376,7 @@ describe('the sentence is composed at exactly one place (objectui#8148)', () => const onChange = vi.fn(); const NumberWidget = asWidget(NumberField); const { container } = renderIn( - 'zh', + 'zh-Hans', , ); const box = container.querySelector('input[type=number]') as HTMLInputElement; diff --git a/ui/packages/i18n/README.md b/ui/packages/i18n/README.md index f604a144957..ba4e506de87 100644 --- a/ui/packages/i18n/README.md +++ b/ui/packages/i18n/README.md @@ -1,16 +1,17 @@ # @object-ui/i18n -Internationalization for Object UI — 10 built-in locales, RTL support, and date/currency formatting. +Internationalization for Object UI — 11 built-in locales, RTL support, and date/currency formatting. ## Features -- 🌍 **10 Built-in Locales** - English, Chinese, Japanese, Korean, German, French, Spanish, Portuguese, Russian, Arabic +- 🌍 **11 Built-in Locales** - English, Simplified Chinese, Traditional Chinese, Spanish, Arabic, French, Portuguese (Brazil), Russian, German, Japanese, Korean - 🔄 **RTL Support** - Automatic right-to-left layout for Arabic and other RTL languages - 📅 **Date Formatting** - Locale-aware date, datetime, and relative time formatting - 💰 **Currency & Number Formatting** - Locale-aware currency and number formatting - 🎣 **React Hooks** - `useObjectTranslation` for translations, language switching, and direction - 🏗️ **I18nProvider** - Context provider for internationalized applications -- 🪶 **Lazy catalogues** - only the active locale's catalogue is fetched; the other nine are separate chunks +- 🗂️ **One pack per platform locale** - the set is the platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`), keyed by its tags (`zh-Hans`, `zh-Hant`, `pt-BR`); the Traditional Chinese pack is generated from the Simplified one +- 🪶 **Lazy catalogues** - only the active locale's catalogue is fetched; every other one is a separate chunk - 🔌 **Extensible** - Add custom locales and translation keys - 🎯 **Type-Safe** - Full TypeScript support with exported types @@ -42,7 +43,7 @@ function MyComponent() { return (

{t('common.save')}

- +
); @@ -79,9 +80,11 @@ An explicit choice outranks browser detection — otherwise a user who picked on a `ja` browser would be handed `ja` back on every reload. A stored value the app no longer offers (not a built-in pack, not in `config.resources`) is ignored *and purged*, so a stale entry can never lock the UI to a locale with no -translations. A region-tagged choice such as `zh-CN` is served by its base -language's built-in pack (`zh`), so it is kept, and kept as written: the -provider's `language`, `` and the stored value all stay `zh-CN`. +translations. A stored code is read as the platform locale tag it means — +`zh-CN` and a legacy `zh` are served by the `zh-Hans` pack, `zh-TW` and `zh-HK` +by `zh-Hant`, a legacy `pt` by `pt-BR`, `fr-CA` by `fr` — so it is kept, and kept +as written: for a `zh-TW` choice the provider's `language`, `` and +the stored value all stay `zh-TW`. ```tsx import type { ReactNode } from 'react'; @@ -113,7 +116,7 @@ function LanguageBar() { return (
{t('common.save')} — {language} - +
); } @@ -143,8 +146,8 @@ never choose the key yourself (`count === 1 ? 'xOne' : 'x'`): two keys give a language two forms, and Russian needs three, Arabic five. A `createSafeTranslation` defaults table answers the same way on a provider-less host: it reads a family's `_one` / `_other` row for a numeric `count`, then the -base row. `count-families-11445.test.ts` holds every `{{count}}` value in the -ten packs to this rule. +base row. `count-families-11445.test.ts` holds every `{{count}}` value in +every pack to this rule. ### createI18n @@ -261,12 +264,12 @@ function WeekHeader() { A tag `Intl` refuses (`en_US`) throws the same `RangeError` that formatting a date with it would. -### Built-in locales — one is resident, nine are fetched on demand +### Built-in locales — one is resident, the rest are fetched on demand The package entry re-exports **`en` only**. It is `fallbackLng`, it is the source of the `TranslationKeys` type, and it is the synchronous dictionary the app-shell splash renders from before i18n is usable, so it has to be there -without awaiting anything. The other nine catalogues are separate chunks your +without awaiting anything. Every other catalogue is a separate chunk your bundler emits once each and the browser fetches only when that locale is actually used — roughly 400 KB gzipped that a page load no longer pays (objectui#7479). @@ -274,25 +277,40 @@ actually used — roughly 400 KB gzipped that a page load no longer pays ```tsx import { en, // resident - BUILT_IN_LANGUAGE_CODES, // all ten codes, no catalogue payload + BUILT_IN_LANGUAGE_CODES, // every platform locale tag, no catalogue payload isBuiltInLanguage, + resolveBuiltInLanguage, // the tag a stored or browser code reads as loadBuiltInLocale, // fetch one: Promise getLoadedBuiltInLocales, // synchronous snapshot of what is resident } from '@object-ui/i18n'; -BUILT_IN_LANGUAGE_CODES; // ['en','zh','ja','ko','de','fr','es','pt','ru','ar'] -await loadBuiltInLocale('zh'); // the zh catalogue, fetched once and memoised -await loadBuiltInLocale('zh-CN'); // the same zh catalogue — a region tag is served by its base +BUILT_IN_LANGUAGE_CODES; // ['en','zh-Hans','zh-Hant','es','ar','fr','pt-BR','ru','de','ja','ko'] +await loadBuiltInLocale('zh-Hans'); // the Simplified catalogue, fetched once and memoised +await loadBuiltInLocale('zh-CN'); // the same catalogue — `zh-CN` reads as `zh-Hans` +await loadBuiltInLocale('zh-TW'); // the Traditional catalogue — `zh-TW` reads as `zh-Hant` await loadBuiltInLocale('tlh'); // null — not a code this package ships -isBuiltInLanguage('zh-CN'); // true -isBuiltInLanguage('xx-YY'); // false — no catalogue for the tag or its base +resolveBuiltInLanguage('zh'); // 'zh-Hans' — a legacy stored code still resolves +resolveBuiltInLanguage('fr-CA'); // 'fr' — no alias, so its prefix +isBuiltInLanguage('xx-YY'); // false — no catalogue for the tag or its prefix ``` -A code is looked up **exact tag first, then its base language**: `zh-CN` is -served by the `zh` catalogue, while a catalogue keyed by a full tag would win -over its base. `isBuiltInLanguage` and `isBuiltInLocaleLoaded` answer by the same -rule as `loadBuiltInLocale`, and a catalogue is memoised under its own code, so -`zh` and `zh-CN` share one fetch. +The catalogues are keyed by the tags of the platform locale table +(`PLATFORM_LOCALES` in `@objectstack/spec/system`), and a code is read **through +the table's read aliases first, then each shorter prefix** +(`platformLocaleTagOf`): `zh`, `zh-CN` and `zh-SG` are `zh-Hans`; `zh-TW`, +`zh-HK` and `zh-MO` are `zh-Hant`; `pt` is `pt-BR`; `zh-Hant-TW` is `zh-Hant`; +`fr-CA` is `fr`. The base language alone is never enough — `zh` cannot tell a +Taiwan reader from a mainland one. `isBuiltInLanguage` and +`isBuiltInLocaleLoaded` answer by the same rule as `loadBuiltInLocale`, and a +catalogue is memoised under its tag, so `zh`, `zh-CN` and `zh-Hans` share one +fetch. `createI18n`'s `fallbackLng` reads the same aliases, so an instance whose +language is a stored code (`zh-TW`) resolves its keys out of the tag's catalogue. + +The `zh-Hant` pack is not hand-written: it is generated from `zh-Hans.ts` by +`scripts/gen-zh-hant.mjs` (OpenCC `s2twp` with the overrides the +www.objectos.ai site converts with). Edit `zh-Hans.ts`, then run +`pnpm --filter @object-ui/i18n gen:zh-hant` and commit both files; +`src/__tests__/zh-hant-generated.test.ts` fails when they disagree. `I18nProvider` does this for you: it fetches the catalogue for whatever language it boots into, and `changeLanguage()` awaits the new catalogue before switching, @@ -330,7 +348,7 @@ resolveBootstrapLanguage({ hasLoader: true }); // 'zh' — the same answer, no f not take the boot down, and the provider retries on mount. `apps/console/src/main.tsx` is the worked example. -#### When you really do want all ten +#### When you really do want them all The parity suites do, and so does an app that ships every language resident. The door is a separate specifier, so the cost is visible at the import site: @@ -381,7 +399,7 @@ overwrite nor delete English. The metadata-admin / Studio surface — the metadata directory, the inspectors, package management, the flow designer and their refusal messages — does not -resolve its strings through the ten packs. Its keys (`engine.*`, `designer.*`, +resolve its strings through the locale packs. Its keys (`engine.*`, `designer.*`, `perm.*`) are a **two-locale** set, `en` and `zh`, read through app-shell's `t()` / `tFormat()` / `tOptional()` with the designer's own `{token}` placeholders. @@ -398,14 +416,16 @@ package's **`studio` namespace**, - **One lazy namespace, two catalogues.** `en` is resident (it is the fallback); `zh` is its own chunk, fetched the first time a Chinese lookup asks for it. - **Every other language falls back to `en`.** On an `ar` / `de` / `es` / `fr` / - `ja` / `ko` / `pt` / `ru` console these strings render English. No + `ja` / `ko` / `pt-BR` / `ru` console these strings render English. A + Traditional Chinese (`zh-Hant`, `zh-TW`) console reads the `zh` catalogue by + base language, so its Studio strings are Simplified. No translation is invented for them; a language gains Studio coverage by adding a catalogue to the namespace, not by copying keys into its pack. - **Not in the eager console catalogue.** `locales/en.ts` gains no key: the namespace's `en` loads with the designer code that reads it. - **Outside the pack gates by construction.** Key parity ([`src/__tests__/all-locales-key-parity.test.ts`](./src/__tests__/all-locales-key-parity.test.ts)) - iterates `builtInLocales`, the ten packs; the namespace's catalogues are not + iterates `builtInLocales`, the locale packs; the namespace's catalogues are not packs and not members of it, so the namespace is outside that test's subject rather than an exception to it. Its own parity — every `en` key has a `zh` row with the same `{placeholders}` — is held by diff --git a/ui/packages/i18n/package.json b/ui/packages/i18n/package.json index 641ccc97f2f..b2742eac3ac 100644 --- a/ui/packages/i18n/package.json +++ b/ui/packages/i18n/package.json @@ -4,7 +4,7 @@ "type": "module", "sideEffects": false, "license": "MIT", - "description": "Internationalization (i18n) support for ObjectUI with 10+ language packs, RTL layout, and date/currency formatting.", + "description": "Internationalization (i18n) support for ObjectUI with the platform locale table's language packs, RTL layout, and date/currency formatting.", "homepage": "https://objectstack.ai/docs/frontend", "repository": { "type": "git", @@ -43,10 +43,12 @@ "build": "tsc && node ../../scripts/check-dist-completeness.mjs", "test": "vitest run --root ../.. packages/i18n/", "type-check": "tsc --noEmit && tsc -p tsconfig.test.json", - "lint": "eslint ." + "lint": "eslint .", + "gen:zh-hant": "node scripts/gen-zh-hant.mjs" }, "dependencies": { "@object-ui/core": "workspace:*", + "@objectstack/spec": "workspace:^", "i18next": "^26.4.0", "react-i18next": "^17.0.11" }, @@ -56,6 +58,7 @@ "devDependencies": { "@object-ui/test-support": "workspace:*", "@types/react": "19.3.0", + "opencc-js": "1.3.1", "react": "19.3.0", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/ui/packages/i18n/scripts/gen-zh-hant.mjs b/ui/packages/i18n/scripts/gen-zh-hant.mjs new file mode 100644 index 00000000000..24808be9550 --- /dev/null +++ b/ui/packages/i18n/scripts/gen-zh-hant.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Regenerate `src/locales/zh-Hant.ts` from `src/locales/zh-Hans.ts`. + * + * pnpm --filter @object-ui/i18n gen:zh-hant + * + * The zh-Hant pack is not hand-written: it is the zh-Hans pack converted by + * `./zh-hant.mjs`, so every key, placeholder and plural slot follows zh-Hans + * exactly. An edit to zh-Hans.ts, or to a conversion rule, is followed by this + * command in the same change; `src/__tests__/zh-hant-generated.test.ts` fails + * when the committed pack is not what this command writes. + * + * Refuses to write (exit 1) when the conversion no longer answers its pinned + * cases. Prints every 里 the locative rules did not claim, so a reviewer can + * tell a genuine 里 from a straddled locative. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateZhHantPack, unresolvedLocatives } from './zh-hant.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SOURCE = join(HERE, '../src/locales/zh-Hans.ts'); +const TARGET = join(HERE, '../src/locales/zh-Hant.ts'); + +let generated; +try { + generated = generateZhHantPack(readFileSync(SOURCE, 'utf8')); +} catch (error) { + console.error(`✗ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + +let current = null; +try { + current = readFileSync(TARGET, 'utf8'); +} catch { + // First generation: there is no pack yet. +} + +if (current === generated) { + console.log('✓ zh-Hant: src/locales/zh-Hant.ts is already current.'); +} else { + writeFileSync(TARGET, generated, 'utf8'); + console.log('✓ zh-Hant: wrote src/locales/zh-Hant.ts from src/locales/zh-Hans.ts.'); +} + +const locatives = unresolvedLocatives(generated); +if (locatives.length > 0) { + console.warn( + `⚠ zh-Hant: ${locatives.length} 里 the locative rules did not claim. Each is a genuine 里 ` + + '(add it to GENUINE_LI to quiet this line) or a straddled locative that belongs in ' + + 'LOCATIVE_LI, both in scripts/zh-hant.mjs:', + ); + for (const context of locatives) console.warn(` …${context}…`); +} diff --git a/ui/packages/i18n/scripts/zh-hant.d.mts b/ui/packages/i18n/scripts/zh-hant.d.mts new file mode 100644 index 00000000000..e1e9841e23f --- /dev/null +++ b/ui/packages/i18n/scripts/zh-hant.d.mts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Declaration twin of `zh-hant.mjs`, so the package's TypeScript tests (compiled +// with `allowJs: false`) import the converter typed, with no `@ts-expect-error` +// and no local annotation. `src/__tests__/zh-hant-generated.test.ts` pins that +// the names declared here are exactly the names the module exports. + +/** `s2twp` phrase rules shadowed at the head of its TWPhrases group. */ +export declare const TWP_PHRASE_OVERRIDES: ReadonlyArray; + +/** Simplified phrases OpenCC's segmentation cuts across a word boundary, and their conversion. */ +export declare const STRADDLE_OVERRIDES: ReadonlyArray; + +/** The locative 里 → 裡 repairs, behind a listed word only. */ +export declare const LOCATIVE_LI: ReadonlyArray; + +/** 里 that is genuinely 里. */ +export declare const GENUINE_LI: RegExp; + +/** Simplified (mainland) → Traditional (Taiwan, phrase conversion). */ +export declare function s2t(text: string): string; + +/** Every 里 in converted text that the locative rules did not claim. */ +export declare function unresolvedLocatives(text: string): string[]; + +/** The conversion's pinned cases: input → expected output. */ +export declare const CONVERSION_CASES: ReadonlyArray; + +/** The pinned cases the converter does not answer as pinned. */ +export declare function conversionFailures(): Array<{ input: string; expected: string; actual: string }>; + +/** The `console.ai.*` outbound leaves the generated pack does not carry. */ +export declare const GATE_ONLY_KEYS: ReadonlyArray; + +/** The header the generated pack opens with. */ +export declare const GENERATED_HEADER: string; + +/** The zh-Hant pack source, generated from the zh-Hans pack source. */ +export declare function generateZhHantPack(zhHansSource: string): string; diff --git a/ui/packages/i18n/scripts/zh-hant.mjs b/ui/packages/i18n/scripts/zh-hant.mjs new file mode 100644 index 00000000000..0fc05499cd7 --- /dev/null +++ b/ui/packages/i18n/scripts/zh-hant.mjs @@ -0,0 +1,366 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * zh-Hans → zh-Hant: the ONE conversion the Console's Traditional Chinese pack + * is generated with (ruling F of objectstack#29349, phase 2 of #29428, card + * #30511). + * + * ## Where the rules come from + * + * Ported from `objectstack-ai/www.objectos.ai` `src/lib/zhconvert.ts` at + * 9a8afd21d09a0d6005fec53045981dfd9637dd5f, the marketing site's converter, + * which derives the site's Traditional UI strings, glossary, pages and blog + * from their Simplified sources. The rules are copied, not re-derived, so the + * Console and the site write one product in one Traditional vocabulary: the + * site measured what two converters cost (an article saying 權限 under a nav + * item saying 許可權), and a Console that converts differently from the site + * would be the same defect across two surfaces. A change to the rules belongs + * in both places. + * + * `opencc-js` is pinned to the version the site converts with (1.3.1): the + * overrides below were derived against that release's phrase dictionaries, and + * an upgrade that reshuffles them is exactly what {@link CONVERSION_CASES} + * exists to catch. + * + * ## The conversion + * + * OpenCC's `s2twp` preset (Simplified → Traditional, Taiwan, with phrase + * conversion): its phrase layer is what turns 数据 into 資料, 对象 into 物件, + * 字段 into 欄位, 用户 into 使用者 and 默认 into 預設. A handful of its phrase + * entries are wrong for software writing, so {@link TWP_PHRASE_OVERRIDES} + * shadows them at the head of the phrase group (the stage that introduces the + * defect), and {@link LOCATIVE_LI} repairs the locative 里 where a straddling + * phrase match pinned it. OpenCC only rewrites Han characters, so keys, + * placeholders, punctuation and code in the pack source stay byte-identical. + * + * ⛔ Not a post-pass rewrite of the output: rewriting 許可權 back to 權限 after + * the fact would also rewrite a genuine 许可权 that the preset converted + * correctly. The shadow only stops 权限 from being rewritten. + */ +import * as OpenCC from 'opencc-js'; +import { ConverterBuilder } from 'opencc-js/core'; + +/** + * `s2twp` phrase rules shadowed at the head of its TWPhrases group. Keyed on + * the post-s2t form, which is what that group sees. An entry whose two sides + * are equal disables the stock rule; a different right side substitutes for it. + * + * @type {ReadonlyArray} + */ +export const TWP_PHRASE_OVERRIDES = Object.freeze([ + // 权限 → 許可權 is a Microsoft-glossary rendering; 權限 is the ordinary term. + ['權限', '權限'], + // 实例 → 例項 is not standard Taiwanese usage in any register. + ['實例', '實例'], + // Mechanical over-substitutions: a fragment appended or swapped. + // 全局 → 全域性 appends a 性 that turns the noun into an adjective. + ['全局', '全域'], + // 扩展 → 擴充套件 appends 套件 ("package"); the verb is 擴充. + ['擴展', '擴充'], + // 适配器 → 介面卡 is a network interface card; the adapter is 配接器. + ['適配器', '配接器'], + // 控件 → 控制元件 appends 元件; the UI-control term is 控制項. + ['控件', '控制項'], + // 镜像 → 映象 swaps the second character; an image is 映像. + ['鏡像', '映像'], + // 脚本 → 指令碼 is the Microsoft-glossary rendering; 腳本 is the ordinary word. + ['腳本', '腳本'], + // Developer vocabulary, Taiwan usage (the site's maintainer ruling of 2026-11-04). + // The runtime as a component is 執行環境; the two longer keys keep the phase sense. + ['運行時', '執行環境'], + ['在運行時', '在執行時'], + ['運行時校驗', '執行階段校驗'], + // 平台 → 平臺 is the Ministry of Education form; 平台 is what Taiwanese writing uses. + ['平臺', '平台'], + // 元数据 → 後設資料 is the library-science term; zh-TW software writes 中繼資料. + ['元數據', '中繼資料'], + // The Console's own addition (objectstack#30511), not yet in the site's + // converter: 操作运行时 ("when the action runs") is the verb, not the runtime + // noun the 運行時 rule above renders as 執行環境. + ['操作運行時', '操作執行時'], +].map((pair) => Object.freeze(pair))); + +/** + * Phrases whose Simplified form OpenCC's segmentation splits across a word + * boundary, keyed on the SIMPLIFIED text: `s2twp` first cuts the input into + * STPhrases matches, longest first, and converts each piece on its own, so a + * two-character entry that straddles a boundary takes the wrong reading and no + * later group can see across the cut. Each entry is added to the segmentation + * dictionary (so the whole phrase is one piece) and to the head of group 0 (so + * that piece converts as written). The Console's own additions + * (objectstack#30511), found reading the generated pack; not yet in the site's + * converter. None is a genuine word in its wrong reading. + * + * @type {ReadonlyArray} + */ +export const STRADDLE_OVERRIDES = Object.freeze([ + // 并发布 ("and publish") cut as 并发 (concurrency, 併發) + 布. + ['并发布', '並發布'], + // 头发送 ("header, sent") cut as 头发 (hair, 頭髮) + 送. + ['头发送', '頭發送'], + // 是只读 / 只影响 cut as 是只 / 只影, reading 只 as the measure word 隻. + ['是只读', '是只讀'], + ['只影响', '只影響'], +].map((pair) => Object.freeze(pair))); + +/** + * The locative 里 → 裡, only behind a listed word: a two-character STPhrases + * entry that pins 里 (a place name, a measure word) can match across the word + * boundary and take the locative with it. A whitelist by construction, because + * a wrong 裡 is worse than a wrong 里. + * + * @type {ReadonlyArray} + */ +export const LOCATIVE_LI = Object.freeze([ + [/(指令碼|腳本|架構|系統|週報|檔案|定義|函數|函式)里(?!程)/g, '$1裡'], + [/(知道|報道|頻道|通道)里(?!程)/g, '$1裡'], +].map((pair) => Object.freeze(pair))); + +/** 里 that is genuinely 里: only used to keep the generator's report quiet. */ +export const GENUINE_LI = + /公里|英里|海里|華里|里程|里長|里民|里弄|鄰里|故里|鄉里|萬里|千里|百里|里根|里加|里拉|阿里|巴里|德里|克里|居里/g; + +/** + * `s2twp` with {@link TWP_PHRASE_OVERRIDES} at the head of its phrase group and + * {@link STRADDLE_OVERRIDES} in its segmentation and at the head of group 0. + */ +function buildVocabularyConverter() { + const stock = OpenCC.Locale.configs?.s2twp; + if (!stock || stock.conversionChain?.length !== 3) { + throw new Error( + 'opencc-js no longer exposes an s2twp config with three conversion groups, so the ' + + 'phrase overrides in ui/packages/i18n/scripts/zh-hant.mjs have nowhere to sit. ' + + 'Re-derive them against the new shape before taking the upgrade.', + ); + } + const preset = { + from: OpenCC.Locale.from, + to: OpenCC.Locale.to, + configs: { + ...OpenCC.Locale.configs, + s2twp: { + // The straddle repairs are pieces of their own, ahead of every other + // phrase: a longer key wins the longest-match cut. + segmentation: [ + stock.segmentation, + STRADDLE_OVERRIDES.map(([simplified, traditional]) => `${simplified} ${traditional}`).join('|'), + ].join('|'), + // Group 0 is [STPhrases, STCharacters] and group 1 is [TWPhrases]; each + // override list goes in front of its group, so it wins. + conversionChain: stock.conversionChain.map((group, i) => + i === 0 ? [STRADDLE_OVERRIDES, ...group] : i === 1 ? [TWP_PHRASE_OVERRIDES, ...group] : group, + ), + }, + }, + }; + return ConverterBuilder(preset)({ from: 'cn', to: 'twp' }); +} + +/** @type {((text: string) => string) | undefined} */ +let convertVocabulary; + +/** + * Simplified (mainland) → Traditional (Taiwan, phrase conversion), with the + * shadowed phrase entries and the straddled locative repaired. + * + * @param {string} text + * @returns {string} + */ +export function s2t(text) { + convertVocabulary ??= buildVocabularyConverter(); + let out = convertVocabulary(text); + for (const [pattern, replacement] of LOCATIVE_LI) out = out.replace(pattern, replacement); + return out; +} + +/** + * Every 里 in converted text that {@link LOCATIVE_LI} did not claim, with a + * little context: each is either a genuine 里 or a straddled locative. + * + * @param {string} text + * @returns {string[]} + */ +export function unresolvedLocatives(text) { + const found = []; + for (const match of text.replace(GENUINE_LI, '').matchAll(/.{0,8}里.{0,8}/g)) { + found.push(match[0].replace(/\s+/g, ' ').trim()); + } + return found; +} + +/** + * The conversion, pinned: both halves of every override (the wrong string is + * gone and the ordinary term took its place), the genuine words the overrides + * must never disturb, the rest of the preset still applying, and the Console's + * own vocabulary. {@link generateZhHantPack} refuses to write when one fails. + * + * @type {ReadonlyArray} + */ +export const CONVERSION_CASES = Object.freeze([ + ['权限', '權限'], + ['字段级权限', '欄位級權限'], + ['实例', '實例'], + ['许可权', '許可權'], + ['例项', '例項'], + ['全局', '全域'], + ['扩展', '擴充'], + ['适配器', '配接器'], + ['介面卡', '介面卡'], + ['控件', '控制項'], + ['控制项', '控制項'], + ['镜像', '映像'], + ['脚本', '腳本'], + ['平台', '平台'], + ['元数据', '中繼資料'], + ['数据 程序 对象 接口 服务器', '資料 程式 物件 介面 伺服器'], + ['软件 信息 缓存 用户 默认 缺省', '軟體 資訊 快取 使用者 預設 預設'], + ['流程隐藏在脚本里', '流程隱藏在腳本裡'], + ['五公里', '五公里'], + ['系统里程碑', '系統里程碑'], + // The Console's own words, as its packs write them. + ['保存', '儲存'], + ['搜索', '搜尋'], + ['设置', '設定'], + ['登录', '登入'], + ['导入', '匯入'], + ['导出', '匯出'], + ['菜单', '選單'], + ['您正在以 {{user}} 的身份操作', '您正在以 {{user}} 的身份操作'], + // The straddled phrase matches the Console's additions repair, and the + // genuine words on either side of each that must keep converting. + ['审阅并发布', '審閱並發布'], + ['并发请求', '併發請求'], + // 頭發送, then TWPhrases' 發送 → 傳送 (Taiwan's "send"). + ['请求头发送', '請求頭傳送'], + ['头发', '頭髮'], + ['这张表是只读的', '這張表是只讀的'], + ['只影响当前视图', '只影響當前檢視'], + ['一只猫', '一隻貓'], + ['当源记录上的操作运行时', '當源記錄上的操作執行時'], + ['本运行时未配置', '本執行環境未配置'], +].map((pair) => Object.freeze(pair))); + +/** + * The cases {@link CONVERSION_CASES} pins that the converter does not answer as + * pinned. Empty means the conversion is the one the rules describe. + * + * @returns {Array<{ input: string, expected: string, actual: string }>} + */ +export function conversionFailures() { + const failures = []; + for (const [input, expected] of CONVERSION_CASES) { + const actual = s2t(input); + if (actual !== expected) failures.push({ input, expected, actual }); + } + return failures; +} + +/** + * The header the generated pack opens with, in place of the source's own doc + * comment (which names the Simplified pack). The marker lines are pure ASCII, + * so a reader finds them with a plain search. + */ +export const GENERATED_HEADER = [ + '// @generated by ui/packages/i18n/scripts/gen-zh-hant.mjs from ./zh-Hans.ts.', + '// Do not edit this file: edit zh-Hans.ts (or a rule in scripts/zh-hant.mjs),', + '// then run `pnpm --filter @object-ui/i18n gen:zh-hant` and commit both.', + '/**', + ' * 繁體中文 (zh-Hant) - Traditional Chinese language pack for Object UI,', + ' * converted from the zh-Hans pack (OpenCC s2twp with the overrides in scripts/zh-hant.mjs).', + ' */', + '', +].join('\n'); + +/** + * Where the one occurrence of `anchor` in `text` starts, or throw: a generator + * that silently matched nothing would ship a pack still named for its source. + * + * @param {string} text + * @param {string} anchor + * @returns {number} + */ +function indexOnce(text, anchor) { + const at = text.indexOf(anchor); + if (at < 0 || text.indexOf(anchor, at + anchor.length) >= 0) { + throw new Error( + `gen-zh-hant: expected exactly one ${JSON.stringify(anchor)} in zh-Hans.ts. ` + + 'The pack source changed shape; update the anchors in scripts/zh-hant.mjs.', + ); + } + return at; +} + +/** + * The `console.ai.*` leaves the generated pack does NOT carry: text the console + * SENDS to the agent rather than shows (objectui#3896). They are read from the + * `en` and `zh-Hans` packs only, by the conversation's language, and the cloud + * confirm gate recognises exactly those two spellings, so a Traditional copy + * would be unreachable text that reads as a third gate language. + * `src/__tests__/outbound-agent-messages.test.ts` owns that invariant over + * every pack; this list is how the generated pack keeps it. Each is a + * one-line property in zh-Hans.ts, replaced line for line by a comment, so the + * two packs' line numbers stay aligned. + */ +export const GATE_ONLY_KEYS = Object.freeze([ + 'planApproveMessage', + 'planApproveDefaultsMessage', + 'planAnswerMessage', + 'planExtendExistingMessage', + 'planBuildNewAppMessage', + 'changesConfirmMessage', +]); + +/** + * The zh-Hans pack body with each {@link GATE_ONLY_KEYS} property line replaced + * by a comment. Throws unless each key is exactly one single-line property. + * + * @param {string} body + * @returns {string} + */ +function withoutGateOnlyKeys(body) { + let out = body; + for (const name of GATE_ONLY_KEYS) { + const line = new RegExp(`^([ \\t]+)${name}: '[^'\\n]*',$`, 'gm'); + const hits = out.match(line) ?? []; + if (hits.length !== 1 || out.split(`${name}:`).length !== 2) { + throw new Error( + `gen-zh-hant: expected ${name} exactly once in zh-Hans.ts, as a one-line property. ` + + 'The pack source changed shape; update GATE_ONLY_KEYS in scripts/zh-hant.mjs.', + ); + } + out = out.replace(line, `$1// ${name}: gate-only, read from zh-Hans (GATE_ONLY_KEYS, scripts/zh-hant.mjs).`); + } + return out; +} + +/** The pack's own name in the source, and the name the generated pack takes. */ +const SOURCE_DECLARATION = 'const zh = {'; +const SOURCE_EXPORT = 'export default zh;'; + +/** + * The zh-Hant pack source, generated from the zh-Hans pack source. + * + * Text in, text out, so the generated module keeps the source's shape exactly: + * the same keys in the same order, the same comments (converted), the same + * `as const`. Only the pack's doc comment and its own name change (`zh` → + * `zhHant`), the {@link GATE_ONLY_KEYS} lines become comments, and the header + * says the file is generated. + * + * @param {string} zhHansSource the text of `src/locales/zh-Hans.ts` + * @returns {string} the text of `src/locales/zh-Hant.ts` + */ +export function generateZhHantPack(zhHansSource) { + const failures = conversionFailures(); + if (failures.length > 0) { + const lines = failures.map( + ({ input, expected, actual }) => + ` ${JSON.stringify(input)}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + throw new Error(`gen-zh-hant: the conversion no longer answers its pinned cases:\n${lines.join('\n')}`); + } + const start = indexOnce(zhHansSource, SOURCE_DECLARATION); + const exportAt = indexOnce(zhHansSource, SOURCE_EXPORT); + const body = s2t(withoutGateOnlyKeys(zhHansSource.slice(start + SOURCE_DECLARATION.length, exportAt))); + const tail = zhHansSource.slice(exportAt + SOURCE_EXPORT.length); + return `${GENERATED_HEADER}const zhHant = {${body}export default zhHant;${tail}`; +} diff --git a/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts b/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts index 2d517aca68f..ef147fe0dcd 100644 --- a/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts @@ -1,5 +1,7 @@ /** - * Full key parity across all ten locale packs (objectui#2872 P3). + * Full key parity across every locale pack (objectui#2872 P3) — one per tag of + * the platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`), + * which the population below is read from (objectstack#30511). * * This replaces the four-namespace ratchet added in objectui#2905. That one was * deliberately scoped because the eight non-Chinese packs were still ~277 keys @@ -72,7 +74,7 @@ * `resolvesLeaf` would also make it report a complete-but-baseless family as * `missing-key`, whose remediation text reads "The key exists in no locale pack" — * false for a family nine packs define. - * - Here, the rule is pack-intrinsic: it walks all ten packs' own key sets, needs no + * - Here, the rule is pack-intrinsic: it walks every pack's own key set, needs no * call site to exist, and fails in `pnpm test` at PR time. * * ## The third invariant: every family carries every category its language selects (objectui#11432) @@ -101,12 +103,13 @@ * real instance over the shipped packs — is `plural-categories-11432.test.ts`. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; /** * Text the console SENDS to the agent rather than displays. These are absent - * from the eight non-gate packs ON PURPOSE: the console resolves them from the - * `en`/`zh` packs by the CONVERSATION's language (objectui#3896), so a value in + * from every non-gate pack ON PURPOSE: the console resolves them from the + * `en`/`zh-Hans` packs by the CONVERSATION's language (objectui#3896), so a value in * any other pack is unreachable, and the cloud confirm gate only recognises * those two languages anyway — see `outbound-agent-messages.test.ts`, which owns * that invariant and asserts it in both directions. Excluded here so the two @@ -196,7 +199,11 @@ describe('all locale packs are at full key parity with en (objectui#2872)', () = // If a refactor breaks `keyPaths`, every diff below becomes trivially empty // and the suite would pass while asserting nothing. expect(EN.size).toBeGreaterThan(2000); - expect(OTHER_LOCALES).toHaveLength(9); + // The population is the platform locale table, tag for tag and in its + // order: a language the table gains with no pack here is a red line, not a + // pack this suite quietly never compares (objectstack#30511). + expect(Object.keys(builtInLocales)).toEqual([...PLATFORM_LOCALE_TAGS]); + expect(OTHER_LOCALES).toHaveLength(PLATFORM_LOCALE_TAGS.length - 1); }); it.each(OTHER_LOCALES)('%s defines every en key', (lang) => { @@ -263,12 +270,12 @@ describe('every plural family carries a base key (objectui#3863)', () => { it('the walk finds the families it is meant to judge — not an empty assertion', () => { // Without this, deleting every plural family (or breaking `familiesOf`) would // make the rule below trivially green. The count is `en`'s and parity carries it - // to the other nine; it is a floor, not a pin, so a new family does not have to + // to every other pack; it is a floor, not a pin, so a new family does not have to // edit this line — only a family DISAPPEARING has to be explained. expect(familiesOf(builtInLocales.en).size).toBeGreaterThanOrEqual(5); - expect(ALL_LOCALES).toHaveLength(10); + expect(ALL_LOCALES).toHaveLength(PLATFORM_LOCALE_TAGS.length); // The suffix list is i18next's, which takes it from `Intl.PluralRules`. Compared - // as sets against the union of all ten packs' languages so a CLDR category this + // as sets against the union of every pack's language so a CLDR category this // repo can actually meet cannot be missing from the list above. const categories = new Set( ALL_LOCALES.flatMap((l) => new Intl.PluralRules(l).resolvedOptions().pluralCategories), @@ -294,9 +301,9 @@ describe('every plural family carries a base key (objectui#3863)', () => { ); }); - it('the rule bites — five of the ten packs select categories beyond one/other', () => { + it('the rule bites — five of the packs select categories beyond one/other', () => { // Why the per-language slots are not cosmetic, stated as data rather than prose. - // `en`/`de` and `zh`/`ja`/`ko` are fully served by `_one`/`_other`; these five + // `en`/`de` and `zh-Hans`/`zh-Hant`/`ja`/`ko` are fully served by `_one`/`_other`; these five // are the packs that need the extra slots the section below requires, and that // the base key alone served — in-language but in one form — before objectui#11432. const reachable = ALL_LOCALES.filter((l) => @@ -304,10 +311,10 @@ describe('every plural family carries a base key (objectui#3863)', () => { .resolvedOptions() .pluralCategories.some((c) => c !== 'one' && c !== 'other'), ); - expect(reachable.sort()).toEqual(['ar', 'es', 'fr', 'pt', 'ru']); + expect(reachable.sort()).toEqual(['ar', 'es', 'fr', 'pt-BR', 'ru']); // …and `ru`/`ar` meet them at everyday counts, which is what made the // one-form base a user-visible defect rather than a theoretical one: - // `fr`/`es`/`pt` only use `many` at exact millions. + // `fr`/`es`/`pt-BR` only use `many` at exact millions. expect(new Intl.PluralRules('ru').select(3)).toBe('few'); expect(new Intl.PluralRules('ru').select(7)).toBe('many'); expect(new Intl.PluralRules('ar').select(2)).toBe('two'); @@ -326,7 +333,7 @@ describe('every plural family carries every CLDR category its language selects ( // Floors, not pins: a new family raises them for free; only a family or a pack // DISAPPEARING has to be explained. expect(EN_FAMILIES.size).toBeGreaterThanOrEqual(5); - expect(ALL_LOCALES).toHaveLength(10); + expect(ALL_LOCALES).toHaveLength(PLATFORM_LOCALE_TAGS.length); const judged = ALL_LOCALES.reduce( (n, l) => n + familiesFor(l).size * requiredSuffixes(l).length, 0, @@ -336,7 +343,8 @@ describe('every plural family carries every CLDR category its language selects ( // than `en`, `zh` fewer. A `requiredSuffixes` that returned a constant would make // this file a hand-kept list in disguise. expect(requiredSuffixes('en')).toEqual(['_one', '_other']); - expect(requiredSuffixes('zh')).toEqual(['_other']); + expect(requiredSuffixes('zh-Hans')).toEqual(['_other']); + expect(requiredSuffixes('zh-Hant')).toEqual(['_other']); expect(requiredSuffixes('ru').sort()).toEqual(['_few', '_many', '_one', '_other']); expect(requiredSuffixes('ar')).toHaveLength(6); // i18next ordinal families (`_ordinal_one`, …) follow `{ type: 'ordinal' }` rules, diff --git a/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-formula-retired-6310.test.ts b/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-formula-retired-6310.test.ts index bd95a782483..a52a5330e51 100644 --- a/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-formula-retired-6310.test.ts +++ b/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-formula-retired-6310.test.ts @@ -73,6 +73,7 @@ * when that member was retired. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -123,7 +124,7 @@ describe('`appDesigner.fieldDesigner.formula` is retired from the ten packs (obj // Guards the premise the rest of the file rests on: a pin that iterates an // empty pack list, or asserts absence inside a namespace that itself // vanished, is green for the wrong reason. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const lang of LANGS) { const root = at(builtInLocales[lang], 'appDesigner.fieldDesigner'); expect(root, `${lang} lost the appDesigner.fieldDesigner root`).toBeDefined(); diff --git a/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-validation-rules-retired-11434.test.ts b/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-validation-rules-retired-11434.test.ts index fcb4a19310b..3336bf8fea2 100644 --- a/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-validation-rules-retired-11434.test.ts +++ b/ui/packages/i18n/src/__tests__/appDesigner-fieldDesigner-validation-rules-retired-11434.test.ts @@ -27,6 +27,7 @@ * alone fails that gate, and re-adding a pack key fails this one. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -51,7 +52,7 @@ const SURVIVING = [ describe('the Field Designer validation-rule labels are retired from the ten packs (objectui#11434)', () => { it('no pack defines either retired label', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); const revived: string[] = []; for (const lang of LANGS) { for (const key of [RETIRED_VALIDATION_RULES_LABEL, RETIRED_ADD_RULE_LABEL]) { diff --git a/ui/packages/i18n/src/__tests__/approvalOverride-zh-wording-13402.test.ts b/ui/packages/i18n/src/__tests__/approvalOverride-zh-wording-13402.test.ts index c3aa8d77479..d53c271944b 100644 --- a/ui/packages/i18n/src/__tests__/approvalOverride-zh-wording-13402.test.ts +++ b/ui/packages/i18n/src/__tests__/approvalOverride-zh-wording-13402.test.ts @@ -31,7 +31,7 @@ const OVERRIDE_KEYS = [ const VIOLATION_WORD = /越权/u; type Pack = Record; -const zh = (builtInLocales.zh as unknown as { approvalsInbox: Pack }).approvalsInbox; +const zh = (builtInLocales['zh-Hans'] as unknown as { approvalsInbox: Pack }).approvalsInbox; const en = (builtInLocales.en as unknown as { approvalsInbox: Pack }).approvalsInbox; const placeholders = (value: string) => [...value.matchAll(/\{\{\s*(\w+)\s*\}\}/gu)].map((m) => m[1]).sort(); diff --git a/ui/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx index f9b55f92eb1..00fcf044374 100644 --- a/ui/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx @@ -51,6 +51,7 @@ * pack must keep the token — asserted below in both directions. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -203,12 +204,14 @@ const UNTRANSLATED_TOKENS = [ 'ja :: auth.login.phonePlaceholder', 'ko :: auth.forgotPassword.phonePlaceholder', 'ko :: auth.login.phonePlaceholder', - 'pt :: auth.forgotPassword.phonePlaceholder', - 'pt :: auth.login.phonePlaceholder', + 'pt-BR :: auth.forgotPassword.phonePlaceholder', + 'pt-BR :: auth.login.phonePlaceholder', 'ru :: auth.forgotPassword.phonePlaceholder', 'ru :: auth.login.phonePlaceholder', - 'zh :: auth.forgotPassword.phonePlaceholder', - 'zh :: auth.login.phonePlaceholder', + 'zh-Hans :: auth.forgotPassword.phonePlaceholder', + 'zh-Hant :: auth.forgotPassword.phonePlaceholder', + 'zh-Hans :: auth.login.phonePlaceholder', + 'zh-Hant :: auth.login.phonePlaceholder', ].sort(); const at = (pack: unknown, path: string): unknown => @@ -231,7 +234,7 @@ beforeEach(() => { describe('objectui#3546 slice three — the auth / oauth namespaces', () => { it('covers all ten packs and all forty-two live keys (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(42); expect(new Set(KEYS).size).toBe(42); // The measured split, so a later slice cannot quietly absorb keys from this @@ -266,11 +269,11 @@ describe('objectui#3546 slice three — the auth / oauth namespaces', () => { } } expect(identical.sort()).toEqual(UNTRANSLATED_TOKENS); - // 18 permitted pairs out of 378 — i.e. 40 of the 42 live keys are translated + // 20 permitted pairs out of 420 — i.e. 40 of the 42 live keys are translated // in every single pack. The set equality above is vacuous if the packs were // empty, so pin the two facts it rests on separately: the exempt value is // really the shared token, and a neighbour that embeds it is really not. - expect(UNTRANSLATED_TOKENS).toHaveLength(18); + expect(UNTRANSLATED_TOKENS).toHaveLength(20); expect(at(builtInLocales.ja, 'auth.login.phonePlaceholder')).toBe('+1 555 000 0000'); expect(at(builtInLocales.ja, 'auth.login.emailOrPhonePlaceholder')).not.toBe( at(builtInLocales.en, 'auth.login.emailOrPhonePlaceholder'), @@ -346,7 +349,7 @@ describe('objectui#3546 slice three — the auth / oauth namespaces', () => { ['oauth.consent.authorize', 'OAuthConsentPage'], ]; - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); diff --git a/ui/packages/i18n/src/__tests__/builtinAggregateLabels-locale-parity-7258.test.ts b/ui/packages/i18n/src/__tests__/builtinAggregateLabels-locale-parity-7258.test.ts index 350ac2b89ad..e5b94ba663b 100644 --- a/ui/packages/i18n/src/__tests__/builtinAggregateLabels-locale-parity-7258.test.ts +++ b/ui/packages/i18n/src/__tests__/builtinAggregateLabels-locale-parity-7258.test.ts @@ -25,6 +25,7 @@ * so a provider-less host renders what an `en` session renders. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { BUILTIN_AGGREGATES, type BuiltinAggregate } from '@object-ui/core'; import { builtInLocales } from '../locales'; import { builtinAggregateLabels } from '../builtinAggregateLabels'; @@ -61,7 +62,7 @@ const ttFrom = (lang: LocaleCode) => (key: string, fallback: string): string => describe('report.aggregate.* covers the built-in aggregate vocabulary in every pack (objectui#7258)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines a non-empty label for every built-in aggregate', (lang) => { @@ -89,7 +90,7 @@ describe('builtinAggregateLabels — the seam between the wire spelling and the }); it('zh: the card`s own strings — a built-in count reads 计数', () => { - expect(builtinAggregateLabels(ttFrom('zh'))).toEqual({ + expect(builtinAggregateLabels(ttFrom('zh-Hans'))).toEqual({ count: '计数', count_distinct: '去重计数', sum: '求和', diff --git a/ui/packages/i18n/src/__tests__/common-search-retired-4392.test.ts b/ui/packages/i18n/src/__tests__/common-search-retired-4392.test.ts index b15b16de308..c19827133ea 100644 --- a/ui/packages/i18n/src/__tests__/common-search-retired-4392.test.ts +++ b/ui/packages/i18n/src/__tests__/common-search-retired-4392.test.ts @@ -48,6 +48,7 @@ * one commit earlier by objectui#4386 / PR #4397 and a different key entirely. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -108,7 +109,7 @@ describe('`common.search` is retired from the ten packs (objectui#4392)', () => // Guards the premise the rest of the file rests on. A pin that iterates an // empty pack list, or asserts absence inside a namespace that itself // vanished, is green for the wrong reason. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const lang of LANGS) { const common = at(builtInLocales[lang], 'common'); expect(common, `${lang} lost the common root`).toBeDefined(); @@ -148,11 +149,11 @@ describe('`common.search` is retired from the ten packs (objectui#4392)', () => // A sample across writing systems, so "all ten packs kept the keys" cannot // be satisfied by ten copies of the English string. expect(at(builtInLocales.en, 'common.select')).toBe('Select…'); - expect(at(builtInLocales.zh, 'common.select')).toBe('选择…'); + expect(at(builtInLocales['zh-Hans'], 'common.select')).toBe('选择…'); expect(at(builtInLocales.ru, 'common.select')).toBe('Выбрать…'); expect(at(builtInLocales.ar, 'common.select')).toBe('اختر…'); expect(at(builtInLocales.en, 'common.filter')).toBe('Filter'); - expect(at(builtInLocales.zh, 'common.filter')).toBe('筛选'); + expect(at(builtInLocales['zh-Hans'], 'common.filter')).toBe('筛选'); }); it('no package or app reads `common.search`, and no fallback table re-declares it', () => { diff --git a/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts b/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts index 54e1adcf8fa..9c035340d0c 100644 --- a/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts +++ b/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts @@ -36,6 +36,7 @@ * `plural-categories-11432.test.ts`'s; both read this family by construction. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -47,12 +48,13 @@ const CLAIM: Record = { de: /alle live oder keiner|geht live, oder nichts ändert sich/u, es: /o se publican todos, o ninguno|se publica, o no cambia nada/u, fr: /tous sont mis en ligne, ou aucun|il est mis en ligne, ou rien ne change/u, - pt: /ou todos entram no ar, ou nenhum|ele entra no ar, ou nada muda/u, + 'pt-BR': /ou todos entram no ar, ou nenhum|ele entra no ar, ou nada muda/u, ru: /либо публикуются все, либо ни один|он публикуется, либо ничего не меняется/u, ar: /إما أن تُنشر/u, ja: /どちらかです/u, ko: /하나도 게시되지 않습니다|아무것도 바뀌지 않습니다/u, - zh: /原子|一次性/u, + 'zh-Hans': /原子|一次性/u, + 'zh-Hant': /原子|一次性/u, }; /** The word for "package" in each pack's package sentence — controlled below. */ @@ -61,12 +63,13 @@ const PACKAGE_WORD: Record = { de: /Paket/u, es: /paquete/u, fr: /paquet/u, - pt: /pacote/u, + 'pt-BR': /pacote/u, ru: /пакет/u, ar: /الحزمة/u, ja: /パッケージ/u, ko: /패키지/u, - zh: /包/u, + 'zh-Hans': /包/u, + 'zh-Hant': /包/u, }; const changesOf = (lang: LocaleCode): Record => @@ -82,7 +85,7 @@ function family(lang: LocaleCode, base: string): Array<[string, string]> { describe('the package-less confirm note claims no atomicity in any pack (objectui#11591)', () => { it('names a claim spelling for every pack — no pack left unjudged', () => { expect(Object.keys(CLAIM).sort()).toEqual([...LANGS].sort()); - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s: the package sentence carries the claim (control)', (lang) => { diff --git a/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts b/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts index 7d6519e88da..6f9cca81cf5 100644 --- a/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts +++ b/ui/packages/i18n/src/__tests__/console-copy-system-noun-12956.test.ts @@ -51,7 +51,7 @@ function strings(node: unknown, path: string, out: Array<[string, string]> = []) } describe('console copy names the system the user is in, not the cloud noun (objectui#12956)', () => { - const zh = builtInLocales.zh as unknown; + const zh = builtInLocales['zh-Hans'] as unknown; it.each(FAMILIES)('zh %s carries no 此环境 / 本环境 / 该环境', (family) => { const entries = strings(resolve(zh, family), family); diff --git a/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx index 5d2a24bfa9f..bfe75ebec10 100644 --- a/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/console-namespace-3546.test.tsx @@ -44,6 +44,7 @@ * thing answering and the test would describe a binding the console never uses. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -193,7 +194,7 @@ beforeEach(() => { describe('objectui#3546 slice four — the console namespace', () => { it('covers all ten packs and all forty-six keys (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(46); expect(new Set(KEYS).size).toBe(46); // 41 measured missing keys + 5 that came from the prefix family. Splitting the @@ -309,7 +310,7 @@ describe('objectui#3546 slice four — the console namespace', () => { } }); - it.each(['en', 'zh'] as const)('%s renders every bucket through the real template call', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s renders every bucket through the real template call', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const key of ['today', 'yesterday', 'previous7Days', 'previous30Days', 'older']) { const full = `console.ai.group.${key}`; @@ -388,7 +389,7 @@ describe('objectui#3546 slice four — the console namespace', () => { ).not.toMatch(/t\('console\.ai\.\w*Message'/); // And zh still spells the badge the way the deleted literal did, so the fix // changed WHICH source answers a zh-UI reader, not what they read. - expect(at(builtInLocales.zh, 'console.ai.planBuilding')).toBe('正在搭建…'); + expect(at(builtInLocales['zh-Hans'], 'console.ai.planBuilding')).toBe('正在搭建…'); }); it('the ratchet actually shrank — no console key is still baselined', () => { @@ -430,7 +431,7 @@ describe('objectui#3546 slice four — the console namespace', () => { ['console.shortcuts.newChat', 'KeyboardShortcutsDialog'], ]; - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); diff --git a/ui/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts b/ui/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts index 863e73997d8..13c950c8d5f 100644 --- a/ui/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts +++ b/ui/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts @@ -21,7 +21,7 @@ const noRecordIdOf = (pack: unknown) => describe('objectui#13397 — the newTabUrl "no record id" refusal is translated', () => { it('zh-CN reads it in Chinese', () => { - expect(noRecordIdOf(builtInLocales.zh)).toBe('此操作针对单条记录,但没有可用的记录 ID。'); + expect(noRecordIdOf(builtInLocales['zh-Hans'])).toBe('此操作针对单条记录,但没有可用的记录 ID。'); }); it('en holds the English source the wrapper falls back to', () => { diff --git a/ui/packages/i18n/src/__tests__/count-families-11445.test.ts b/ui/packages/i18n/src/__tests__/count-families-11445.test.ts index 223635b071e..9cb36930251 100644 --- a/ui/packages/i18n/src/__tests__/count-families-11445.test.ts +++ b/ui/packages/i18n/src/__tests__/count-families-11445.test.ts @@ -50,6 +50,7 @@ * type is read at the call sites this card converted, not pinned. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -184,7 +185,7 @@ const NEUTRAL_KEYS: string[] = Object.values(COUNT_NEUTRAL).flat(); describe('a count label is an i18next count family, or names why it needs none (objectui#11445)', () => { it('the walk is real — packs, families and count leaves are all non-empty', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); // objectui#11432's 14 families plus objectui#11445's 68 — a floor, so a // later family raises it for free and only a lost family is a failure. expect(FAMILIES.size).toBeGreaterThanOrEqual(82); diff --git a/ui/packages/i18n/src/__tests__/dashboard-emptyState-keys-retired-7125.test.ts b/ui/packages/i18n/src/__tests__/dashboard-emptyState-keys-retired-7125.test.ts index 6aa71c2d078..38a3567b84d 100644 --- a/ui/packages/i18n/src/__tests__/dashboard-emptyState-keys-retired-7125.test.ts +++ b/ui/packages/i18n/src/__tests__/dashboard-emptyState-keys-retired-7125.test.ts @@ -65,6 +65,7 @@ * exists so the neighbourhood cannot be deleted for a green. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -108,7 +109,7 @@ describe('the dashboard empty-state placeholders are retired from the ten packs // Guards the premise the rest of the file rests on: a pin that iterates an // empty pack list, or asserts absence inside a namespace that itself // vanished, is green for the wrong reason. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const lang of LANGS) { const root = at(builtInLocales[lang], 'dashboard'); expect(root, `${lang} lost the dashboard root`).toBeDefined(); diff --git a/ui/packages/i18n/src/__tests__/dead-key-batch-retired-4730.test.ts b/ui/packages/i18n/src/__tests__/dead-key-batch-retired-4730.test.ts index 739351a94d5..06e54bd4fcd 100644 --- a/ui/packages/i18n/src/__tests__/dead-key-batch-retired-4730.test.ts +++ b/ui/packages/i18n/src/__tests__/dead-key-batch-retired-4730.test.ts @@ -49,6 +49,7 @@ * which is the most load-bearing assertion in this file. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -180,7 +181,7 @@ describe('objectui#4730 dead-key batch is retired from the ten packs', () => { it('covers all ten packs', () => { // Guards the premise the rest of the file rests on: a pin that iterates an // empty pack list is green for the wrong reason. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it('no pack defines any retired key', () => { diff --git a/ui/packages/i18n/src/__tests__/dead-pack-keys-retired-8754.test.ts b/ui/packages/i18n/src/__tests__/dead-pack-keys-retired-8754.test.ts index 2379a898367..71c34ce73d3 100644 --- a/ui/packages/i18n/src/__tests__/dead-pack-keys-retired-8754.test.ts +++ b/ui/packages/i18n/src/__tests__/dead-pack-keys-retired-8754.test.ts @@ -91,6 +91,7 @@ * checking its tier first. A key name in a comment is a textual hit. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -174,7 +175,7 @@ describe('objectui#8754 — the measured-dead pack keys stay retired', () => { // empty pack list, or asserts absence inside a pack that itself vanished, is // green for the wrong reason — which is the single failure mode a negative // pin cannot otherwise distinguish from success. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(RETIRED).toHaveLength(23); expect(new Set(RETIRED).size).toBe(23); for (const lang of LANGS) { @@ -242,7 +243,7 @@ describe('objectui#8754 — the measured-dead pack keys stay retired', () => { // satisfied by the English string ten times — the same complement // `objectView-config-keys-retired-4730.test.ts` carries next door. expect(at(builtInLocales.en, 'sidebar.helpTooltip')).toBe('Help & Documentation'); - for (const lang of ['zh', 'ru', 'ar', 'ja'] as const) { + for (const lang of ['zh-Hans', 'zh-Hant', 'ru', 'ar', 'ja'] as const) { const value = at(builtInLocales[lang], 'sidebar.helpTooltip'); expect(typeof value, `${lang} sidebar.helpTooltip`).toBe('string'); expect(value, `${lang} sidebar.helpTooltip is the English value`).not.toBe( diff --git a/ui/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts b/ui/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts index ad25c47c98e..2a7b3af5e44 100644 --- a/ui/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts +++ b/ui/packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts @@ -69,7 +69,7 @@ const ELLIPSIS = '…'; /** The typewriter spelling this pass retired: three ASCII full stops. */ const ASCII_ELLIPSIS = '...'; -const LANGS = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar'] as const; +const LANGS = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'de', 'fr', 'es', 'pt-BR', 'ru', 'ar'] as const; type Lang = (typeof LANGS)[number]; /** Every string leaf of a pack, as `[dotted.path, value]`. */ diff --git a/ui/packages/i18n/src/__tests__/en-zh-key-parity.test.ts b/ui/packages/i18n/src/__tests__/en-zh-key-parity.test.ts index fd932baa5ab..9fa1aebe4c1 100644 --- a/ui/packages/i18n/src/__tests__/en-zh-key-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/en-zh-key-parity.test.ts @@ -31,7 +31,7 @@ function keyPaths(node: unknown, prefix = ''): string[] { describe('en ↔ zh key parity', () => { const en = keyPaths(builtInLocales.en); - const zh = keyPaths(builtInLocales.zh); + const zh = keyPaths(builtInLocales['zh-Hans']); it('flattens a realistic number of keys', () => { // Guard against the walker silently returning nothing and the two diff --git a/ui/packages/i18n/src/__tests__/entry-locale-shape-7479.test.ts b/ui/packages/i18n/src/__tests__/entry-locale-shape-7479.test.ts index 347f2415914..82da80bca75 100644 --- a/ui/packages/i18n/src/__tests__/entry-locale-shape-7479.test.ts +++ b/ui/packages/i18n/src/__tests__/entry-locale-shape-7479.test.ts @@ -34,6 +34,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as entry from '../index.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { BUILT_IN_LANGUAGE_CODES, DEFAULT_BUILT_IN_LANGUAGE } from '../locales/registry.js'; // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; @@ -106,6 +107,7 @@ describe('the entry re-exports ONE catalogue, not ten (objectui#7479)', () => { 'loadBuiltInLocale', 'getLoadedBuiltInLocales', 'isBuiltInLanguage', + 'resolveBuiltInLanguage', 'BUILT_IN_LANGUAGE_CODES', 'preloadBootstrapLocale', 'resolveBootstrapLanguage', @@ -114,11 +116,13 @@ describe('the entry re-exports ONE catalogue, not ten (objectui#7479)', () => { } }); - it('still enumerates all ten languages — at zero catalogue payload', () => { - expect(entry.BUILT_IN_LANGUAGE_CODES).toHaveLength(10); - expect(entry.BUILT_IN_LANGUAGE_CODES).toContain('zh'); + it('still enumerates every platform locale — at zero catalogue payload', () => { + // The platform locale table's tags, in its order (objectstack#30511). + expect([...entry.BUILT_IN_LANGUAGE_CODES]).toEqual([...PLATFORM_LOCALE_TAGS]); + expect(entry.BUILT_IN_LANGUAGE_CODES).toContain('zh-Hans'); + expect(entry.BUILT_IN_LANGUAGE_CODES).toContain('zh-Hant'); // Enumerable, but not resident: naming a language must not fetch it. - expect(entry.isBuiltInLocaleLoaded('zh')).toBe(entry.isBuiltInLocaleLoaded('zh')); + expect(entry.isBuiltInLocaleLoaded('zh-Hant')).toBe(entry.isBuiltInLocaleLoaded('zh-Hant')); }); }); @@ -126,9 +130,14 @@ describe('the all-ten door is still open, and still says what it costs', () => { it('`locales/index.ts` exports every catalogue and `builtInLocales`', async () => { const barrel = await import('../locales/index.js'); expect(Object.keys(barrel.builtInLocales)).toEqual([...BUILT_IN_LANGUAGE_CODES]); + // Each catalogue is also a named export. The names are identifiers, not + // tags (`zh` is `zh-Hans`, `zhHant` is `zh-Hant`, `pt` is `pt-BR`), so the + // check is by identity: every map value is one of the barrel's exports. + const named = new Set(Object.values(barrel)); for (const code of BUILT_IN_LANGUAGE_CODES) { - expect(Object.keys(barrel)).toContain(code); + expect(named.has(barrel.builtInLocales[code as keyof typeof barrel.builtInLocales]), code).toBe(true); } + expect(barrel.zhHant).toBe(barrel.builtInLocales['zh-Hant']); }); it('is published under a specifier a reader can see the cost of', () => { diff --git a/ui/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts b/ui/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts index 2da44ac163e..60281d778cf 100644 --- a/ui/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts +++ b/ui/packages/i18n/src/__tests__/fallback-placeholder-spelling-3512.test.ts @@ -170,6 +170,7 @@ */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -268,7 +269,7 @@ describe('the fallback only ever meets placeholders it can resolve (objectui#351 it('the walk reaches real copy — not an empty assertion', () => { // Non-vacuity, #4118 family standard. Every assertion below is "no value // violates", which a broken walk satisfies trivially. Floors, not pins. - expect(LOCALE_CODES).toHaveLength(10); + expect(LOCALE_CODES).toHaveLength(PLATFORM_LOCALE_TAGS.length); const total = [...PACK_LEAVES.values()].reduce((n, list) => n + list.length, 0); expect(total).toBeGreaterThan(20_000); for (const code of LOCALE_CODES) { diff --git a/ui/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts b/ui/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts index 8419cc216b2..a9a891135da 100644 --- a/ui/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts +++ b/ui/packages/i18n/src/__tests__/gantt-count-interpolation-4157.test.ts @@ -32,6 +32,7 @@ * `GanttView.tsx`, which is how the conflict key drifted in the first place. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; /** Dotted paths under `gantt.` whose call site passes `{ count }` to `t()`. */ @@ -55,7 +56,7 @@ const at = (lang: string, dotted: string): string | undefined => describe('gantt count-interpolation spelling (objectui#4157)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s spells every count placeholder as i18next {{count}}', (lang) => { diff --git a/ui/packages/i18n/src/__tests__/gantt-quickfilter-locale-parity.test.ts b/ui/packages/i18n/src/__tests__/gantt-quickfilter-locale-parity.test.ts index fa3b6257c64..38ee602084f 100644 --- a/ui/packages/i18n/src/__tests__/gantt-quickfilter-locale-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/gantt-quickfilter-locale-parity.test.ts @@ -17,6 +17,7 @@ * once — this asserts the absolute form. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; const KEYS = ['all', 'clear', 'empty', 'resultSummary'] as const; @@ -34,7 +35,7 @@ const quickFilterOf = (lang: LocaleCode) => describe('gantt.quickFilter.* locale coverage (objectstack#5427)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines every quick-filter label as a non-empty string', (lang) => { @@ -72,7 +73,7 @@ describe('gantt.quickFilter.* locale coverage (objectstack#5427)', () => { // exact literals that used to be hardcoded in ObjectGantt.tsx instead. const REMOVED = ['全部', '清除筛选', '无可选项', '项任务']; const leaked = LANGS.filter( - (l) => l !== 'zh' && KEYS.some((k) => REMOVED.some((s) => quickFilterOf(l)![k].includes(s))), + (l) => l !== 'zh-Hans' && l !== 'zh-Hant' && KEYS.some((k) => REMOVED.some((s) => quickFilterOf(l)![k].includes(s))), ); expect(leaked).toEqual([]); }); diff --git a/ui/packages/i18n/src/__tests__/gridImportSavedMapping-i18n-3920.test.ts b/ui/packages/i18n/src/__tests__/gridImportSavedMapping-i18n-3920.test.ts index 7ff299cc627..3eedc1b25c4 100644 --- a/ui/packages/i18n/src/__tests__/gridImportSavedMapping-i18n-3920.test.ts +++ b/ui/packages/i18n/src/__tests__/gridImportSavedMapping-i18n-3920.test.ts @@ -98,7 +98,7 @@ const KEYS = [ ] as const; /** The seven packs that served `en` for all five keys. */ -const PACKS = ['ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar'] as const; +const PACKS = ['ko', 'de', 'fr', 'es', 'pt-BR', 'ru', 'ar'] as const; /** The two multi-clause sentences; the only two of the five that quote a name. */ const SENTENCES = ['savedMappingHint', 'savedMappingPreviewNote'] as const; @@ -109,7 +109,7 @@ const QUOTED_NAME: Record<(typeof PACKS)[number], string> = { de: '„{{name}}“', fr: '« {{name}} »', es: '"{{name}}"', - pt: '"{{name}}"', + 'pt-BR': '"{{name}}"', ru: '«{{name}}»', ar: '"{{name}}"', }; @@ -131,7 +131,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all it('the ten packs all define the five keys (anti-vacuous guard)', () => { // Every assertion below reads these values. A renamed key would otherwise // let `not.toBe(en)` pass on `undefined` versus a string and read as green. - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { for (const key of KEYS) { const v = value(lang, key); expect(typeof v, `${lang} ${NS}.${key}`).toBe('string'); @@ -158,12 +158,12 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all it('zh and ja are untouched — they were the two packs that were already right', () => { // The control set the card used to rule out "intentionally untranslated". // Pinned so a later sweep cannot quietly re-English them. - expect(value('zh', 'savedMapping')).toBe('已保存的映射:'); - expect(value('zh', 'manualMapping')).toBe('— 手动映射列 —'); + expect(value('zh-Hans', 'savedMapping')).toBe('已保存的映射:'); + expect(value('zh-Hans', 'manualMapping')).toBe('— 手动映射列 —'); expect(value('ja', 'savedMapping')).toBe('保存済みマッピング:'); expect(value('ja', 'manualMapping')).toBe('— 列を手動でマッピング —'); for (const key of KEYS) { - expect(value('zh', key), `zh ${key}`).not.toBe(value('en', key)); + expect(value('zh-Hans', key), `zh ${key}`).not.toBe(value('en', key)); expect(value('ja', key), `ja ${key}`).not.toBe(value('en', key)); } }); @@ -191,7 +191,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all // not a substitute for it — it is the local proof that the two values these // translations rewrote most heavily did not drop the hole the call site // fills (`ImportWizard.tsx` passes `{ name: mapping.label || mapping.name }`). - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { for (const key of SENTENCES) { expect(value(lang, key), `${lang} ${key} lost {{name}}`).toContain('{{name}}'); } @@ -242,7 +242,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all // same wizard, and `en` gives both the `— … —` shape. zh and ja kept it when // they translated; the seven packs must too, or the option stops reading as // "not a real mapping". - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { const v = value(lang, 'manualMapping'); expect(v.startsWith('— '), `${lang} manualMapping opening em dash`).toBe(true); expect(v.endsWith(' —'), `${lang} manualMapping closing em dash`).toBe(true); @@ -257,7 +257,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all // and `en` ends both with ":". CJK packs use the fullwidth ":", fr puts a // space before the ASCII colon (59 of 61 colons in the pack) — so the check // is "same terminator as your own sibling", not "ends with U+003A". - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { const sibling = at(builtInLocales[lang], `${NS}.mappingTemplate`) as string; // The terminator is the colon plus whatever whitespace precedes it — NOT // the sibling's last two characters, which in `ko` is the tail of the word @@ -271,13 +271,13 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all ).toBe(true); } expect(value('fr', 'savedMapping')).toBe('Correspondance enregistrée :'); - expect(value('zh', 'savedMapping').endsWith(':')).toBe(true); + expect(value('zh-Hans', 'savedMapping').endsWith(':')).toBe(true); }); it('chooseSavedMapping keeps the ellipsis of its sibling chooser', () => { // U+2026, not three dots — `chooseTemplate` is the sibling and `en` uses the // single character in both. - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { expect(value(lang, 'chooseSavedMapping').endsWith('…'), `${lang} ellipsis`).toBe(true); expect(value(lang, 'chooseSavedMapping').includes('...'), `${lang} used three dots`).toBe(false); } @@ -303,8 +303,8 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all ['es', 'savedMappingPreviewNote', ['vista previa', 'columnas de origen', 'en el servidor']], // pt: `stepMapping` "Mapeamento", `legacyFallbackNotice` "conversão de tipos", // `view.readonlyTooltip` "somente leitura", `validateHint` "no servidor". - ['pt', 'savedMappingHint', ['mapeamento', 'conversão de tipos', 'somente leitura', 'no servidor']], - ['pt', 'savedMappingPreviewNote', ['pré-visualização', 'colunas de origem', 'no servidor']], + ['pt-BR', 'savedMappingHint', ['mapeamento', 'conversão de tipos', 'somente leitura', 'no servidor']], + ['pt-BR', 'savedMappingPreviewNote', ['pré-visualização', 'colunas de origem', 'no servidor']], // ru: `stepMapping` "Сопоставление", `legacyFallbackNotice` "приведение типов", // `view.readonlyTooltip` "только для чтения", `validateHint` "на сервере". ['ru', 'savedMappingHint', ['опоставление', 'приведение типов', 'только для чтения', 'на сервере']], @@ -366,7 +366,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all const v = value(lang, 'savedMappingHint'); expect(v.split(' + ').length - 1, `${lang} hint should join three operations with " + "`).toBe(2); } - expect(value('zh', 'savedMappingHint')).toContain(' + '); + expect(value('zh-Hans', 'savedMappingHint')).toContain(' + '); expect(value('ja', 'savedMappingHint')).toContain('+'); }); @@ -386,7 +386,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all // Anti-vacuous guard first, as in this file's opening `it`: `not.toBe(en)` // would pass on `undefined` versus a string if the key were ever renamed. - for (const lang of [...PACKS, 'en', 'zh', 'ja'] as const) { + for (const lang of [...PACKS, 'en', 'zh-Hans', 'zh-Hant', 'ja'] as const) { const v = value(lang, 'transform'); expect(typeof v, `${lang} ${NS}.transform`).toBe('string'); expect(v.trim().length, `${lang} ${NS}.transform is empty`).toBeGreaterThan(0); @@ -400,7 +400,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all // en is untouched and zh/ja remain the control set that ruled out the // "deliberately untranslated term" reading for this key as well. expect(english).toBe('Transform'); - expect(value('zh', 'transform')).toBe('转换'); + expect(value('zh-Hans', 'transform')).toBe('转换'); expect(value('ja', 'transform')).toBe('変換'); // objectui#3938's criterion is about the ROW, not the key: all three @@ -432,7 +432,7 @@ describe('objectui#3920 — grid.import saved-mapping keys are translated in all de: { header: 'Transformation', stem: 'transformation', coercion: 'Typkonvertierung' }, fr: { header: 'Transformation', stem: 'transformation', coercion: 'conversion de type' }, es: { header: 'Transformación', stem: 'transformaci', coercion: 'conversión de tipos' }, - pt: { header: 'Transformação', stem: 'transforma', coercion: 'conversão de tipos' }, + 'pt-BR': { header: 'Transformação', stem: 'transforma', coercion: 'conversão de tipos' }, ru: { header: 'Преобразование', stem: 'преобразовани', coercion: 'приведение типов' }, ar: { header: 'التحويل', stem: 'التحويل', coercion: 'تحويل الأنواع' }, }; diff --git a/ui/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts b/ui/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts index d56299b1422..dbff61b4fa3 100644 --- a/ui/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts +++ b/ui/packages/i18n/src/__tests__/groupingPartialRetired-10881.test.ts @@ -37,7 +37,7 @@ const PACKS = Object.entries(builtInLocales); describe('the grouped grid marker is retired from every locale pack (objectui#10881)', () => { it('reads all ten packs', () => { - expect(PACKS.map(([code]) => code).sort()).toEqual(['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt', 'ru', 'zh']); + expect(PACKS.map(([code]) => code).sort()).toEqual(['ar', 'de', 'en', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'ru', 'zh-Hans', 'zh-Hant']); }); it.each(PACKS)('%s carries no grid.grouping.partial* key', (_code, pack) => { diff --git a/ui/packages/i18n/src/__tests__/i18n.test.ts b/ui/packages/i18n/src/__tests__/i18n.test.ts index 84f79d79967..5c2aa49d549 100644 --- a/ui/packages/i18n/src/__tests__/i18n.test.ts +++ b/ui/packages/i18n/src/__tests__/i18n.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { I18N_PROBE_FLAG, CJK_LATIN_SPACED_KEYS, spaceLatinValueFromCjk } from '../i18n'; import { createI18n, @@ -47,16 +48,17 @@ describe('@object-ui/i18n', () => { const i18n = createI18n({ detectBrowserLanguage: false }); const langs = getAvailableLanguages(i18n); expect(langs).toContain('en'); - expect(langs).toContain('zh'); + expect(langs).toContain('zh-Hans'); + expect(langs).toContain('zh-Hant'); expect(langs).toContain('ja'); expect(langs).toContain('ko'); expect(langs).toContain('de'); expect(langs).toContain('fr'); expect(langs).toContain('es'); - expect(langs).toContain('pt'); + expect(langs).toContain('pt-BR'); expect(langs).toContain('ru'); expect(langs).toContain('ar'); - expect(langs.length).toBeGreaterThanOrEqual(10); + expect(langs.length).toBeGreaterThanOrEqual(11); }); it('translates common keys in English', () => { @@ -192,7 +194,7 @@ describe('@object-ui/i18n', () => { // prompt it sends into the thread after the reject. A translation the // gate read as approval would turn the operator's rejection into a // go-ahead in that thread. - for (const lang of ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']) { + for (const lang of ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'de', 'fr', 'es', 'pt-BR', 'ru', 'ar']) { const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); const msg = i18n.t('console.ai.toolDenyReason'); expect(msg, `${lang} toolDenyReason must not fall through to its raw key`).not.toBe( @@ -411,8 +413,8 @@ describe('@object-ui/i18n', () => { }); describe('builtInLocales', () => { - it('has 10 built-in language packs', () => { - expect(Object.keys(builtInLocales).length).toBe(10); + it('has one built-in language pack per platform locale tag', () => { + expect(Object.keys(builtInLocales)).toEqual([...PLATFORM_LOCALE_TAGS]); }); it('all locales have the same top-level keys', () => { @@ -565,6 +567,15 @@ describe('zh titles space a Latin object name from the Han text around it (#3012 expect(zh().t('form.createTitle', { object: '维修工单' })).toBe('新建维修工单'); }); + // objectstack#30511: both Chinese packs, booted by the platform locale + // table's tags rather than the `zh` alias above. + it.each(['zh-Hans', 'zh-Hant'] as const)('the %s pack spaces a Latin label the same way', (tag) => { + const title = String(builtInLocales[tag].form.createTitle); + expect(title.endsWith('{{object}}')).toBe(true); + const i18n = createI18n({ defaultLanguage: tag, detectBrowserLanguage: false }); + expect(i18n.t('form.createTitle', { object: 'Repair Ticket' })).toBe(title.replace('{{object}}', ' Repair Ticket')); + }); + it('is opt-in by key: a key outside the table renders as authored', () => { expect(CJK_LATIN_SPACED_KEYS['form.createSuccess']).toBeUndefined(); expect(zh().t('form.createSuccess', { object: 'Repair Ticket' })).toBe('Repair Ticket创建成功'); diff --git a/ui/packages/i18n/src/__tests__/invitation-screens-27521.test.ts b/ui/packages/i18n/src/__tests__/invitation-screens-27521.test.ts index 1d72d31cd2c..817b738ee77 100644 --- a/ui/packages/i18n/src/__tests__/invitation-screens-27521.test.ts +++ b/ui/packages/i18n/src/__tests__/invitation-screens-27521.test.ts @@ -13,6 +13,7 @@ * each key, translated, with the English placeholders kept. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; const KEYS = [ @@ -31,7 +32,7 @@ const LANGS = Object.keys(builtInLocales) as Array; describe('objectstack#27521 — the Invitations screens keys', () => { it('reads all ten packs (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s carries every key as a non-empty string', (lang) => { diff --git a/ui/packages/i18n/src/__tests__/makerEditChips-v1-scope-7709.test.ts b/ui/packages/i18n/src/__tests__/makerEditChips-v1-scope-7709.test.ts index 7a19cb19b8e..1fa884da450 100644 --- a/ui/packages/i18n/src/__tests__/makerEditChips-v1-scope-7709.test.ts +++ b/ui/packages/i18n/src/__tests__/makerEditChips-v1-scope-7709.test.ts @@ -42,6 +42,7 @@ * is where that pack's automation sentence is kept. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -75,11 +76,12 @@ const chipsOf = (lang: LocaleCode) => */ const BANNED: Record = { en: ['alert', 'remind', 'notif', 'automat', 'workflow', 'trigger', 'schedule', 'approv'], - zh: ['提醒', '预警', '警报', '自动', '流转', '通知', '触发', '定时', '审批'], + 'zh-Hans': ['提醒', '预警', '警报', '自动', '流转', '通知', '触发', '定时', '审批'], + 'zh-Hant': ['提醒', '預警', '警報', '自動', '流轉', '通知', '觸發', '定時', '審批'], de: ['automat', 'erinner', 'warnung', 'alarm', 'benachrichtig', 'workflow', 'auslös', 'ablauf', 'freigabe', 'genehmig'], fr: ['automat', 'alerte', 'rappel', 'notifi', 'workflow', 'flux', 'déclench', 'approbation', 'approuv'], es: ['automat', 'alerta', 'recordar', 'recordatorio', 'notifi', 'flujo', 'disparador', 'aprob'], - pt: ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho', 'aprova'], + 'pt-BR': ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho', 'aprova'], ru: ['автомат', 'оповещ', 'уведомл', 'напомин', 'триггер', 'процесс', 'поток', 'согласован', 'утвержд'], ja: ['自動', '通知', 'アラート', 'リマイン', 'ワークフロー', 'フロー', 'トリガー', '承認'], ko: ['자동', '알림', '알람', '워크플로', '흐름', '트리거', '승인'], @@ -88,7 +90,7 @@ const BANNED: Record = { /** Every banned term this text hits, lower-cased for the case-insensitive scripts. */ function bannedHits(lang: LocaleCode, text: string): string[] { - const haystack = text.toLocaleLowerCase(lang === 'zh' ? 'zh' : undefined); + const haystack = text.toLocaleLowerCase(lang === 'zh-Hans' || lang === 'zh-Hant' ? lang : undefined); return BANNED[lang].filter((term) => haystack.includes(term.toLocaleLowerCase())); } @@ -99,11 +101,12 @@ function bannedHits(lang: LocaleCode, text: string): string[] { */ const RETIRED: Record = { en: 'Add an automation — an approval, a status flow, or a notification.', - zh: '加一个自动化 —— 审批、状态流转或通知。', + 'zh-Hans': '加一个自动化 —— 审批、状态流转或通知。', + 'zh-Hant': '加一個自動化 —— 審批、狀態流轉或通知。', de: 'Füge eine Automatisierung hinzu — eine Freigabe, einen Statusablauf oder eine Benachrichtigung.', fr: 'Ajoute une automatisation — une approbation, un flux de statuts ou une notification.', es: 'Añade una automatización — una aprobación, un flujo de estados o una notificación.', - pt: 'Adicione uma automação — uma aprovação, um fluxo de status ou uma notificação.', + 'pt-BR': 'Adicione uma automação — uma aprovação, um fluxo de status ou uma notificação.', ru: 'Добавь автоматизацию — согласование, процесс статусов или уведомление.', ja: '自動化を追加してください — 承認、ステータスフロー、または通知。', ko: '자동화를 추가해 주세요 — 승인, 상태 흐름 또는 알림.', @@ -112,7 +115,7 @@ const RETIRED: Record = { describe('maker edit-mode chips — every shipped pack carries all four (objectui#7709)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines a non-empty string for every chip', (lang) => { @@ -152,6 +155,6 @@ describe('maker edit-mode chips — none promises autonomous behaviour (objectui it('the replaced chip asks for DATA, in the two languages the card quoted', () => { expect(chipsOf('en')!.addSampleData).toContain('sample records'); - expect(chipsOf('zh')!.addSampleData).toContain('示例数据'); + expect(chipsOf('zh-Hans')!.addSampleData).toContain('示例数据'); }); }); diff --git a/ui/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts b/ui/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts index c7bb3add78b..b5b4e7eb842 100644 --- a/ui/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts +++ b/ui/packages/i18n/src/__tests__/makerEmptyState-v1-scope-8329.test.ts @@ -53,6 +53,7 @@ * pack's automation sentence is kept. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -86,11 +87,12 @@ const descriptionOf = (lang: LocaleCode, v: Variant) => emptyOf(lang)?.[v]?.desc */ const BANNED: Record = { en: ['alert', 'remind', 'notif', 'automat', 'workflow', 'trigger', 'schedule', 'approv'], - zh: ['提醒', '预警', '警报', '自动', '流转', '流程', '工作流', '通知', '触发', '定时', '审批'], + 'zh-Hans': ['提醒', '预警', '警报', '自动', '流转', '流程', '工作流', '通知', '触发', '定时', '审批'], + 'zh-Hant': ['提醒', '預警', '警報', '自動', '流轉', '流程', '工作流', '通知', '觸發', '定時', '審批'], de: ['automat', 'erinner', 'warnung', 'alarm', 'benachrichtig', 'workflow', 'auslös', 'ablauf', 'freigabe', 'genehmig'], fr: ['automat', 'alerte', 'rappel', 'notifi', 'workflow', 'flux', 'déclench', 'approbation', 'approuv'], es: ['automat', 'alerta', 'recordar', 'recordatorio', 'notifi', 'flujo', 'disparador', 'aprob'], - pt: ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho', 'aprova'], + 'pt-BR': ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho', 'aprova'], ru: ['автомат', 'оповещ', 'уведомл', 'напомин', 'триггер', 'процесс', 'поток', 'согласован', 'утвержд'], ja: ['自動', '通知', 'アラート', 'リマイン', 'ワークフロー', 'フロー', 'トリガー', '承認'], ko: ['자동', '알림', '알람', '워크플로', '흐름', '트리거', '승인'], @@ -99,7 +101,7 @@ const BANNED: Record = { /** Every banned term this text hits, lower-cased for the case-insensitive scripts. */ function bannedHits(lang: LocaleCode, text: string): string[] { - const haystack = text.toLocaleLowerCase(lang === 'zh' ? 'zh' : undefined); + const haystack = text.toLocaleLowerCase(lang === 'zh-Hans' || lang === 'zh-Hant' ? lang : undefined); return BANNED[lang].filter((term) => haystack.includes(term.toLocaleLowerCase())); } @@ -113,10 +115,14 @@ const RETIRED: Record> = { build: 'Describe an app or workflow in plain language — I draft the objects, screens and automations, then you review and publish.', editApp: 'What would you like to change? I’ll modify this app in place — add a field, object, view or automation, or adjust what’s already there.', }, - zh: { + 'zh-Hans': { build: '用自然语言描述一个应用或流程 —— 我会起草对象、界面和自动化,随后你审阅并发布。', editApp: '想改点什么?我会就地修改这个应用 —— 加字段、对象、视图或自动化,或调整已有内容。', }, + 'zh-Hant': { + build: '用自然語言描述一個應用或流程 —— 我會起草物件、介面和自動化,隨後你審閱並發布。', + editApp: '想改點什麼?我會就地修改這個應用 —— 加欄位、物件、檢視或自動化,或調整已有內容。', + }, de: { build: 'Beschreiben Sie eine App oder einen Workflow in einfachen Worten — ich entwerfe die Objekte, Bildschirme und Automatisierungen, dann prüfen und veröffentlichen Sie.', editApp: 'Was möchten Sie ändern? Ich passe diese App direkt an — ein Feld, Objekt, eine Ansicht oder Automatisierung hinzufügen oder Vorhandenes anpassen.', @@ -129,7 +135,7 @@ const RETIRED: Record> = { build: 'Describa una aplicación o un flujo de trabajo en lenguaje natural — yo redacto los objetos, las pantallas y las automatizaciones, y luego usted revisa y publica.', editApp: '¿Qué desea cambiar? Modifico esta aplicación sobre la marcha — añadir un campo, un objeto, una vista o una automatización, o ajustar lo que ya existe.', }, - pt: { + 'pt-BR': { build: 'Descreva um aplicativo ou fluxo de trabalho em linguagem simples — eu rascunho os objetos, telas e automações, e então você revisa e publica.', editApp: 'O que você quer mudar? Eu altero este aplicativo no lugar — adicionar um campo, objeto, visão ou automação, ou ajustar o que já existe.', }, @@ -153,7 +159,7 @@ const RETIRED: Record> = { describe('maker empty state — every shipped pack carries both descriptions (objectui#8329)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines a non-empty description for both variants', (lang) => { @@ -210,14 +216,14 @@ describe('maker empty state — the replacement names v1 capabilities (objectui# expect(en).toMatch(/describe an app/i); expect(en).toContain('sample data'); - const zh = descriptionOf('zh', 'build')!; + const zh = descriptionOf('zh-Hans', 'build')!; expect(zh).toContain('示例数据'); expect(zh).not.toContain('或流程'); }); it('en/zh editApp: offers a dashboard in the slot automation used to hold', () => { expect(descriptionOf('en', 'editApp')!).toContain('dashboard'); - expect(descriptionOf('zh', 'editApp')!).toContain('仪表盘'); + expect(descriptionOf('zh-Hans', 'editApp')!).toContain('仪表盘'); }); it('en editApp keeps the in-place framing its own unit test asserts', () => { diff --git a/ui/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts b/ui/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts index a0e04167a47..3d4fda5b79c 100644 --- a/ui/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts +++ b/ui/packages/i18n/src/__tests__/makerStartChips-v1-scope-1984.test.ts @@ -36,6 +36,7 @@ * actions — see the note beside the keys in every pack. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; type LocaleCode = keyof typeof builtInLocales; @@ -64,11 +65,12 @@ const chipsOf = (lang: LocaleCode) => */ const BANNED: Record = { en: ['alert', 'remind', 'notif', 'automat', 'workflow', 'trigger', 'schedule'], - zh: ['提醒', '预警', '警报', '自动', '流转', '通知', '触发', '定时'], + 'zh-Hans': ['提醒', '预警', '警报', '自动', '流转', '通知', '触发', '定时'], + 'zh-Hant': ['提醒', '預警', '警報', '自動', '流轉', '通知', '觸發', '定時'], de: ['automat', 'erinner', 'warnung', 'alarm', 'benachrichtig', 'workflow', 'auslös', 'ablauf'], fr: ['automat', 'alerte', 'rappel', 'notifi', 'workflow', 'flux', 'déclench'], es: ['automat', 'alerta', 'recordar', 'recordatorio', 'notifi', 'flujo', 'disparador'], - pt: ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho'], + 'pt-BR': ['automa', 'alerta', 'lembr', 'notifi', 'fluxo', 'gatilho'], ru: ['автомат', 'оповещ', 'уведомл', 'напомин', 'триггер', 'процесс', 'поток'], ja: ['自動', '通知', 'アラート', 'リマイン', 'ワークフロー', 'フロー', 'トリガー'], ko: ['자동', '알림', '알람', '워크플로', '흐름', '트리거'], @@ -77,7 +79,7 @@ const BANNED: Record = { /** Every banned term this text hits, lower-cased for the case-insensitive scripts. */ function bannedHits(lang: LocaleCode, text: string): string[] { - const haystack = text.toLocaleLowerCase(lang === 'zh' ? 'zh' : undefined); + const haystack = text.toLocaleLowerCase(lang === 'zh-Hans' || lang === 'zh-Hant' ? lang : undefined); return BANNED[lang].filter((term) => haystack.includes(term.toLocaleLowerCase())); } @@ -90,11 +92,12 @@ function bannedHits(lang: LocaleCode, text: string): string[] { */ const RETIRED: Record = { en: ['Design a support desk — tickets with priority, a status workflow, and customer links.'], - zh: ['设计一个工单系统:工单、优先级、状态流转。', '做一个库存管理应用:商品、库存量、供应商,以及低库存预警。'], + 'zh-Hans': ['设计一个工单系统:工单、优先级、状态流转。', '做一个库存管理应用:商品、库存量、供应商,以及低库存预警。'], + 'zh-Hant': ['設計一個工單系統:工單、優先順序、狀態流轉。', '做一個庫存管理應用:商品、庫存量、供應商,以及低庫存預警。'], de: ['Entwirf einen Support-Desk — Tickets mit Priorität, einen Status-Workflow und Kundenverknüpfungen.'], fr: ["Conçois un service d'assistance — tickets avec priorité, un flux de statuts et des liens vers les clients."], es: ['Diseña una mesa de ayuda — tickets con prioridad, un flujo de estados y vínculos con los clientes.'], - pt: ['Projete uma central de suporte — chamados com prioridade, um fluxo de status e vínculos com clientes.'], + 'pt-BR': ['Projete uma central de suporte — chamados com prioridade, um fluxo de status e vínculos com clientes.'], ru: ['Спроектируй службу поддержки — заявки с приоритетом, процесс статусов и связи с клиентами.'], ja: ['サポートデスクを設計して — 優先度つきチケット、ステータスのワークフロー、顧客との紐付け。'], ko: ['고객 지원 데스크를 설계해 줘 — 우선순위가 있는 티켓, 상태 워크플로, 고객 연결.'], @@ -103,7 +106,7 @@ const RETIRED: Record = { describe('maker start chips — every shipped pack carries all five (cloud#1984)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines a non-empty string for every chip', (lang) => { @@ -135,7 +138,7 @@ describe('maker start chips — none promises autonomous behaviour (cloud#1984)' }); it('the two chips the card named read as UI, not as automation', () => { - const zh = chipsOf('zh')!; + const zh = chipsOf('zh-Hans')!; // 「状态流转」 → a board grouped by status; 「低库存预警」 → a filtered view. expect(zh.buildFlow).toContain('看板'); expect(zh.buildFlow).not.toContain('流转'); diff --git a/ui/packages/i18n/src/__tests__/marketplace-failedHint-retired-5504.test.ts b/ui/packages/i18n/src/__tests__/marketplace-failedHint-retired-5504.test.ts index fa466b7ebad..7f4081f3682 100644 --- a/ui/packages/i18n/src/__tests__/marketplace-failedHint-retired-5504.test.ts +++ b/ui/packages/i18n/src/__tests__/marketplace-failedHint-retired-5504.test.ts @@ -50,6 +50,7 @@ * neighbourhood. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -83,7 +84,7 @@ describe('`marketplace.load.failedHint` is retired from the ten packs (objectui# // Guards the premise the rest of the file rests on: a pin that iterates an // empty pack list, or asserts absence inside a namespace that itself // vanished, is green for the wrong reason. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const lang of LANGS) { const load = at(builtInLocales[lang], 'marketplace.load'); expect(load, `${lang} lost the marketplace.load root`).toBeDefined(); diff --git a/ui/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx index 7fdcaffe061..0ac5c9dcf1e 100644 --- a/ui/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/marketplace-preview-namespace-3546.test.tsx @@ -67,6 +67,7 @@ * answering and the test would describe a binding the console never uses. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -197,7 +198,7 @@ beforeEach(() => { describe('objectui#3546 slice five — the marketplace and preview namespaces', () => { it('covers all ten packs and all forty keys (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(40); expect(new Set(KEYS).size).toBe(40); // 37 measured missing keys + 3 that came from the prefix family. Splitting @@ -257,12 +258,12 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', expect(at(builtInLocales.de, 'marketplace.disclosure.hooks')).toBe('Lifecycle-Hooks'); expect(at(builtInLocales.fr, 'marketplace.disclosure.hooks')).toBe('Hooks de cycle de vie'); expect(at(builtInLocales.es, 'marketplace.disclosure.runtime.sandbox')).toBe('En sandbox'); - expect(at(builtInLocales.pt, 'marketplace.disclosure.runtime.sandbox')).toBe('Em sandbox'); + expect(at(builtInLocales['pt-BR'], 'marketplace.disclosure.runtime.sandbox')).toBe('Em sandbox'); for (const [lang, key] of [ ['de', 'marketplace.disclosure.hooks'], ['fr', 'marketplace.disclosure.hooks'], ['es', 'marketplace.disclosure.runtime.sandbox'], - ['pt', 'marketplace.disclosure.runtime.sandbox'], + ['pt-BR', 'marketplace.disclosure.runtime.sandbox'], ] as const) { expect(at(builtInLocales[lang], key)).not.toBe(at(builtInLocales.en, key)); } @@ -362,7 +363,7 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', // punctuation throughout (165 fullwidth commas, 13 fullwidth trailing colons // against 6 halfwidth), and preview.changes.loadFailed zh is fullwidth too. for (const key of ['marketplace.disclosure.grantsIntro', 'preview.history.loadFailed']) { - expect((at(builtInLocales.zh, key) as string).endsWith(':'), `zh ${key} lost the fullwidth colon`).toBe(true); + expect((at(builtInLocales['zh-Hans'], key) as string).endsWith(':'), `zh ${key} lost the fullwidth colon`).toBe(true); } // ko has no fullwidth trailing colon anywhere in the pack (16 halfwidth, 0). expect((at(builtInLocales.ko, 'marketplace.disclosure.grantsIntro') as string).endsWith(':')).toBe(true); @@ -413,7 +414,7 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', } }); - it.each(['en', 'zh'] as const)('%s renders every tier through the real template call', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s renders every tier through the real template call', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const tier of ['node', 'sandbox', 'worker']) { const full = `marketplace.disclosure.runtime.${tier}`; @@ -495,7 +496,7 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', const collapsed = LANGS.filter( (l) => at(builtInLocales[l], 'preview.history.revert') === at(builtInLocales[l], 'preview.history.revertAction'), ); - expect(collapsed.sort()).toEqual(['ar', 'zh']); + expect(collapsed.sort()).toEqual(['ar', 'zh-Hans', 'zh-Hant']); expect(at(builtInLocales.ja, 'preview.history.revert')).toBe('取り消し'); expect(at(builtInLocales.ja, 'preview.history.revertAction')).toBe('取り消す'); expect(at(builtInLocales.ko, 'preview.history.revert')).toBe('되돌림'); @@ -514,15 +515,15 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', // usted — filed as a finding), and the tie-break taken is usted, on the // strength of home.pendingDrafts.published ("¡Publicado! Sus cambios…"), the // structural twin of preview.unpublishedBar.published. - expect(at(builtInLocales.zh, 'marketplace.org.heading')).toBe('你的组织'); - expect(at(builtInLocales.zh, 'preview.unpublishedBar.message')).toContain('您的用户'); - expect(at(builtInLocales.zh, 'preview.unpublishedBar.published')).toContain('您的用户'); + expect(at(builtInLocales['zh-Hans'], 'marketplace.org.heading')).toBe('你的组织'); + expect(at(builtInLocales['zh-Hans'], 'preview.unpublishedBar.message')).toContain('您的用户'); + expect(at(builtInLocales['zh-Hans'], 'preview.unpublishedBar.published')).toContain('您的用户'); expect(at(builtInLocales.es, 'marketplace.org.heading')).toBe('Su organización'); expect(at(builtInLocales.es, 'preview.unpublishedBar.published')).toContain('sus usuarios'); expect(at(builtInLocales.es, 'preview.unpublishedBar.message')).toContain('Publíquela'); // ...and the neighbours that ruling was read off are still what it claims. - expect(at(builtInLocales.zh, 'marketplace.subtitle')).toContain('你的'); - expect(at(builtInLocales.zh, 'preview.draftBar.sampleDataBody')).toContain('您'); + expect(at(builtInLocales['zh-Hans'], 'marketplace.subtitle')).toContain('你的'); + expect(at(builtInLocales['zh-Hans'], 'preview.draftBar.sampleDataBody')).toContain('您'); expect(at(builtInLocales.es, 'home.pendingDrafts.published')).toBe('¡Publicado! Sus cambios están activos.'); }); @@ -611,13 +612,14 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', // property and not only as ten strings. const PHRASE: Record = { en: '{{count}} item(s)', - zh: '{{count}} 项', + 'zh-Hans': '{{count}} 项', + 'zh-Hant': '{{count}} 項', ja: '{{count}} 件', ko: '{{count}} 항목', de: 'Elemente: {{count}}', fr: 'Éléments : {{count}}', es: 'Elementos: {{count}}', - pt: 'Itens: {{count}}', + 'pt-BR': 'Itens: {{count}}', ru: 'Элементов: {{count}}', ar: 'العناصر: {{count}}', }; @@ -633,12 +635,13 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', // packs now OPEN the phrase with that noun, and `ar` takes the definite // article a label needs, so there the pack's word is a suffix of it. const UNIT: Partial> = { - zh: '项', + 'zh-Hans': '项', + 'zh-Hant': '項', ja: '件', de: 'Elemente', fr: 'éléments', es: 'elementos', - pt: 'itens', + 'pt-BR': 'itens', ru: 'элементов', ar: 'عناصر', }; @@ -814,7 +817,7 @@ describe('objectui#3546 slice five — the marketplace and preview namespaces', } }); - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); diff --git a/ui/packages/i18n/src/__tests__/marketplace-searchPlaceholder-manifestJargon-10969.test.ts b/ui/packages/i18n/src/__tests__/marketplace-searchPlaceholder-manifestJargon-10969.test.ts index b566413df0f..b4f17def6be 100644 --- a/ui/packages/i18n/src/__tests__/marketplace-searchPlaceholder-manifestJargon-10969.test.ts +++ b/ui/packages/i18n/src/__tests__/marketplace-searchPlaceholder-manifestJargon-10969.test.ts @@ -20,6 +20,7 @@ * lower-casing both sides. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; /** Each pack's spelling of "manifest" before objectui#10969, plus the English. */ @@ -41,7 +42,7 @@ function placeholderOf(catalogue: unknown): string { describe('marketplace.searchPlaceholder — no manifest jargon in any pack (objectui#10969)', () => { it('covers all ten packs', () => { - expect(Object.keys(builtInLocales)).toHaveLength(10); + expect(Object.keys(builtInLocales)).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(Object.entries(builtInLocales))('%s: names no manifest', (_code, catalogue) => { @@ -56,6 +57,6 @@ describe('marketplace.searchPlaceholder — no manifest jargon in any pack (obje }); it('zh keeps the wording objectui#10900 chose', () => { - expect(placeholderOf(builtInLocales.zh)).toBe('按名称或标识搜索应用…'); + expect(placeholderOf(builtInLocales['zh-Hans'])).toBe('按名称或标识搜索应用…'); }); }); diff --git a/ui/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts b/ui/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts index bbe18debad6..0c1a044fad2 100644 --- a/ui/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts +++ b/ui/packages/i18n/src/__tests__/objectView-config-keys-retired-4730.test.ts @@ -49,6 +49,7 @@ * classifier rather than a rubber stamp. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { builtInLocales } from '../locales/index'; @@ -296,7 +297,7 @@ describe('console.objectView config-panel keys are retired (objectui#4730)', () expect(new Set(RETIRED_OBJECT_VIEW_KEYS).size).toBe(RETIRED_OBJECT_VIEW_KEYS.length); // The locale roster, not this namespace — pinned the same way across the // package's retirement files, and left to that roster's own sweep. - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(LANGS).toContain('en'); }); @@ -374,7 +375,7 @@ describe('console.objectView config-panel keys are retired (objectui#4730)', () // A sample across writing systems, so "all ten packs kept the key" cannot be // satisfied by ten copies of the English string. expect(at(builtInLocales.en, 'console.objectView.save')).toBe('Save'); - expect(at(builtInLocales.zh, 'console.objectView.save')).toBe('保存'); + expect(at(builtInLocales['zh-Hans'], 'console.objectView.save')).toBe('保存'); expect(at(builtInLocales.ru, 'console.objectView.save')).toBe('Сохранить'); expect(at(builtInLocales.ar, 'console.objectView.save')).toBe('حفظ'); }); diff --git a/ui/packages/i18n/src/__tests__/objectView-value-language-3582.test.ts b/ui/packages/i18n/src/__tests__/objectView-value-language-3582.test.ts index 149771e6c00..b0645a47d10 100644 --- a/ui/packages/i18n/src/__tests__/objectView-value-language-3582.test.ts +++ b/ui/packages/i18n/src/__tests__/objectView-value-language-3582.test.ts @@ -24,6 +24,7 @@ * be revisited in the same PR instead of drifting for another release. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; // Derived from the map rather than left as `string[]`: `Object.keys` erases @@ -64,7 +65,8 @@ const EN_STALE_READONLY = 'System view defined in code - duplicate to customize. // `LocaleCode[]`, which `entries`/`keys` erase by design. A typo'd pack name is // now a compile error rather than a case that silently never runs. const NATIVE_SCRIPT: Partial> = { - zh: /\p{Script=Han}/u, + 'zh-Hans': /\p{Script=Han}/u, + 'zh-Hant': /\p{Script=Han}/u, ja: /\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Han}/u, ko: /\p{Script=Hangul}/u, ru: /\p{Script=Cyrillic}/u, @@ -73,7 +75,7 @@ const NATIVE_SCRIPT: Partial> = { describe('console.objectView value-language coverage (objectui#3582)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines both keys as non-empty strings', (lang) => { diff --git a/ui/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx index 8688bd30864..71d48c9b73e 100644 --- a/ui/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/organization-namespace-3546.test.tsx @@ -37,6 +37,7 @@ * merely reads oddly. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -189,15 +190,16 @@ const LANGS = Object.keys(builtInLocales) as LocaleCode[]; * page title it labels. */ const COGNATES = [ - 'zh :: organization.settings.logoLabel', + 'zh-Hans :: organization.settings.logoLabel', + 'zh-Hant :: organization.settings.logoLabel', 'fr :: organization.settings.logoLabel', 'de :: organization.settings.logoLabel', 'es :: organization.settings.logoLabel', - 'pt :: organization.settings.logoLabel', + 'pt-BR :: organization.settings.logoLabel', 'fr :: organization.settings.slugLabel', 'de :: organization.settings.slugLabel', 'es :: organization.settings.slugLabel', - 'pt :: organization.settings.slugLabel', + 'pt-BR :: organization.settings.slugLabel', 'es :: organization.settings.generalTitle', 'fr :: organization.tabs.invitations', 'fr :: organization.invitations.title', @@ -223,7 +225,7 @@ beforeEach(() => { describe('objectui#3546 slice two — the organization namespace', () => { it('covers all ten packs and all ninety keys (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(90); expect(new Set(KEYS).size).toBe(90); }); @@ -248,9 +250,9 @@ describe('objectui#3546 slice two — the organization namespace', () => { } } expect(identical.sort()).toEqual(COGNATES); - // 12 permitted pairs out of 810 — i.e. 86 of the 90 keys differ in every + // 13 permitted pairs out of 900 — i.e. 86 of the 90 keys differ in every // single pack. If that ratio ever collapses the assertion above fires. - expect(COGNATES).toHaveLength(12); + expect(COGNATES).toHaveLength(13); }); it('interpolation placeholders survive translation in every pack', () => { @@ -319,7 +321,7 @@ describe('objectui#3546 slice two — the organization namespace', () => { ['organization.switcher.groupLabel', 'WorkspaceSwitcher'], ]; - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); diff --git a/ui/packages/i18n/src/__tests__/organizations-picker-vocabulary-5215.test.ts b/ui/packages/i18n/src/__tests__/organizations-picker-vocabulary-5215.test.ts index bc40bef4e09..9c2dbff633f 100644 --- a/ui/packages/i18n/src/__tests__/organizations-picker-vocabulary-5215.test.ts +++ b/ui/packages/i18n/src/__tests__/organizations-picker-vocabulary-5215.test.ts @@ -70,14 +70,15 @@ const ORG_KEYS = [ // trabajo" / "espace de travail", and Arabic the root of "مساحة" / "مساحات". const PACKS: Record = { en: { orgStem: 'organization', retiredStems: ['workspace'] }, - zh: { orgStem: '组织', retiredStems: ['工作区'] }, + 'zh-Hans': { orgStem: '组织', retiredStems: ['工作区'] }, + 'zh-Hant': { orgStem: '組織', retiredStems: ['工作區'] }, ar: { orgStem: 'مؤسس', retiredStems: ['مساح'] }, de: { orgStem: 'organisation', retiredStems: ['workspace', 'arbeitsbereich'] }, es: { orgStem: 'organizaci', retiredStems: ['trabajo'] }, fr: { orgStem: 'organisation', retiredStems: ['travail'] }, ja: { orgStem: '組織', retiredStems: ['ワークスペース'] }, ko: { orgStem: '조직', retiredStems: ['워크스페이스', '작업 공간'] }, - pt: { orgStem: 'organizaç', retiredStems: ['workspace', 'trabalho'] }, + 'pt-BR': { orgStem: 'organizaç', retiredStems: ['workspace', 'trabalho'] }, ru: { orgStem: 'организац', retiredStems: ['пространств'] }, }; diff --git a/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts b/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts index aa88d9aa510..08bd19b9827 100644 --- a/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts +++ b/ui/packages/i18n/src/__tests__/outbound-agent-messages.test.ts @@ -16,12 +16,18 @@ * (`packages/app-shell/src/console/ai/outboundAgentText.ts`) that reads the two * gate packs directly and never calls `t()` for these keys: * - * zh conversation -> the `zh` pack's value // matches the gate + * zh conversation -> the `zh-Hans` pack's value // matches the gate * anything else -> the `en` pack's value // matches the gate * * ## What this test guarantees, stated as what it asserts * - * Exactly two packs define these keys: `en` and `zh`. That is the whole claim. + * Exactly two packs define these keys: `en` and `zh-Hans` (the Simplified pack, + * exported as `zh`). That is the whole claim. Every other pack is judged — the + * population is `builtInLocales`, typed on the platform locale table, so a pack + * the table gains is judged on the run that first sees it (objectstack#30511). + * The generated `zh-Hant` pack is one of them: `scripts/zh-hant.mjs` drops these + * lines from it (`GATE_ONLY_KEYS`), since a Traditional copy would be a third + * spelling the gate does not recognise. * It is NOT "a `t()` lookup MISSes in every non-Chinese pack" — the wording this * header carried until #3896, which was false for `zh` and was the premise the * bug hid behind: `zh` defines all four, so the `t()` fallback HIT and a zh @@ -41,7 +47,7 @@ * that deletes the offending entry here — not before. */ import { describe, it, expect } from 'vitest'; -import { en, zh, ja, ko, de, fr, es, pt, ru, ar } from '../locales'; +import { builtInLocales, en, zh } from '../locales'; const OUTBOUND_KEYS = [ 'console.ai.planApproveMessage', @@ -52,8 +58,10 @@ const OUTBOUND_KEYS = [ 'console.ai.changesConfirmMessage', ] as const; -const GATE_LOCALES: Record = { en, zh }; -const NON_GATE_LOCALES: Record = { ja, ko, de, fr, es, pt, ru, ar }; +const GATE_LOCALES: Record = { en, 'zh-Hans': zh }; +const NON_GATE_LOCALES: Record = Object.fromEntries( + Object.entries(builtInLocales).filter(([tag]) => !Object.prototype.hasOwnProperty.call(GATE_LOCALES, tag)), +); function lookup(pack: unknown, dotted: string): unknown { return dotted @@ -62,6 +70,11 @@ function lookup(pack: unknown, dotted: string): unknown { } describe('outbound agent messages stay on gate-recognised languages', () => { + it('judges every non-gate pack, the generated zh-Hant one included', () => { + expect(Object.keys(NON_GATE_LOCALES)).toContain('zh-Hant'); + expect(Object.keys(NON_GATE_LOCALES)).toHaveLength(Object.keys(builtInLocales).length - 2); + }); + it.each(Object.keys(NON_GATE_LOCALES))( 'the %s pack defines none of them — a value there could never be sent', (lang) => { @@ -73,7 +86,7 @@ describe('outbound agent messages stay on gate-recognised languages', () => { ); it.each(Object.keys(GATE_LOCALES))('the %s pack defines all of them', (lang) => { - // Both halves are load-bearing since #3896: the resolver reads `zh` for a + // Both halves are load-bearing since #3896: the resolver reads `zh-Hans` for a // Chinese conversation and `en` for every other one, so a key missing from // either pack drops that language onto the resolver's fallback table. for (const k of OUTBOUND_KEYS) { @@ -91,7 +104,7 @@ describe('outbound agent messages stay on gate-recognised languages', () => { const labels = aiLabels.filter((k) => !outboundLeaf.has(k)); expect(labels.length).toBeGreaterThan(20); for (const k of labels) { - expect((de as any).console.ai[k], `de is missing console.ai.${k}`).toBeTypeOf('string'); + expect((builtInLocales.de as any).console.ai[k], `de is missing console.ai.${k}`).toBeTypeOf('string'); } }); }); diff --git a/ui/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx b/ui/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx index ee1e8bef075..b59d67eb6b8 100644 --- a/ui/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/perm-home-namespace-3546.test.tsx @@ -33,6 +33,7 @@ * and the draft bar both pass it in), so i18next is the thing answering. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -118,7 +119,7 @@ beforeEach(() => { describe('objectui#3546 slice six — the home namespace', () => { it('covers all ten packs and all five paths (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(5); expect(new Set(KEYS).size).toBe(5); // The retired facet keys stay retired: no pack defines a `perm.facet` branch. @@ -238,7 +239,7 @@ describe('objectui#3546 slice six — the home namespace', () => { // ru/ar restructure. Copying `en`'s "(s)" into ru is the mistake this pins. expect(at(builtInLocales.en, 'home.pendingDrafts.title')).toContain('change(s)'); const MARKER = /\([\p{L}]{1,4}\)/u; - for (const lang of ['de', 'fr', 'es', 'pt'] as const) { + for (const lang of ['de', 'fr', 'es', 'pt-BR'] as const) { expect( MARKER.test(at(builtInLocales[lang], 'home.pendingDrafts.title') as string), `${lang} title lost its marker`, @@ -248,7 +249,7 @@ describe('objectui#3546 slice six — the home namespace', () => { `${lang} publishedVerified should carry the same marker`, ).toBe(true); } - for (const lang of ['zh', 'ja', 'ko', 'ru'] as const) { + for (const lang of ['zh-Hans', 'zh-Hant', 'ja', 'ko', 'ru'] as const) { expect( MARKER.test(at(builtInLocales[lang], 'home.pendingDrafts.publishedVerified') as string), `${lang} publishedVerified imported a "(s)" marker it does not use`, @@ -266,7 +267,7 @@ describe('objectui#3546 slice six — the home namespace', () => { // The em dash in `Published & verified — …` is U+2014 everywhere, except zh // which writes the doubled `——` its own pack used in the retired home.pendingDrafts.message. const verified = at(builtInLocales[lang], 'home.pendingDrafts.publishedVerified') as string; - expect(verified.includes(lang === 'zh' ? '——' : '—'), `${lang} publishedVerified dash`).toBe(true); + expect(verified.includes(lang === 'zh-Hans' || lang === 'zh-Hant' ? '——' : '—'), `${lang} publishedVerified dash`).toBe(true); expect(verified.includes('&'), `${lang} kept en's ampersand`).toBe(lang === 'en'); } }); @@ -281,7 +282,7 @@ describe('objectui#3546 slice six — the home namespace', () => { expect(at(builtInLocales.ja, 'console.ai.usage.ariaLabel')).toBe('AI 使用状況: {{status}}'); expect(at(builtInLocales.ja, 'home.pendingDrafts.capabilityWarn')).toContain('作成時チェック:'); // zh writes fullwidth punctuation throughout. - expect(at(builtInLocales.zh, 'home.pendingDrafts.capabilityWarn')).toContain('编写检查:'); + expect(at(builtInLocales['zh-Hans'], 'home.pendingDrafts.capabilityWarn')).toContain('编写检查:'); // ko has no fullwidth colon anywhere in the pack. expect(at(builtInLocales.ko, 'home.pendingDrafts.capabilityWarn')).toContain('작성 검사: '); expect(at(builtInLocales.ko, 'home.pendingDrafts.capabilityWarn')).not.toContain(':'); @@ -349,7 +350,7 @@ describe('objectui#3546 slice six — the home namespace', () => { } }); - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); diff --git a/ui/packages/i18n/src/__tests__/plural-categories-11432.test.ts b/ui/packages/i18n/src/__tests__/plural-categories-11432.test.ts index 654bc5afaea..edc0882b882 100644 --- a/ui/packages/i18n/src/__tests__/plural-categories-11432.test.ts +++ b/ui/packages/i18n/src/__tests__/plural-categories-11432.test.ts @@ -35,6 +35,7 @@ * "1 is singular, the rest plural" model gets wrong. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import i18next from 'i18next'; import { builtInLocales } from '../locales'; @@ -111,7 +112,7 @@ const render = (template: string, count: number) => describe('every plural slot is the one i18next selects, in every pack (objectui#11432)', () => { it('the matrix is real — families, packs and categories are all non-empty', () => { expect(FAMILIES.length).toBeGreaterThanOrEqual(5); - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); // Every category of every language has a sample, so no slot goes unjudged. for (const lang of LANGS) { expect([...sampleCounts(lang).keys()].sort(), lang).toEqual( diff --git a/ui/packages/i18n/src/__tests__/provider-load-rerender-10382.test.tsx b/ui/packages/i18n/src/__tests__/provider-load-rerender-10382.test.tsx index 9f43b3d87ef..5aa0c5940ff 100644 --- a/ui/packages/i18n/src/__tests__/provider-load-rerender-10382.test.tsx +++ b/ui/packages/i18n/src/__tests__/provider-load-rerender-10382.test.tsx @@ -37,9 +37,14 @@ import { createI18n } from '../i18n'; import { I18nProvider, useI18nContext, useObjectTranslation } from '../provider'; import { useObjectLabel } from '../useObjectLabel'; import ja from '../locales/ja'; -import zh from '../locales/zh'; +import ptBR from '../locales/pt-BR'; +import zh from '../locales/zh-Hans'; +import zhHant from '../locales/zh-Hant'; -/** The card's config — not a built-in code, so only the app bundle is in play. */ +/** + * The card's config. A read alias of `zh-Hans` (objectstack#30511), whose + * catalogue is resident, so only the app bundle lands after mount. + */ const ZH_CN = { defaultLanguage: 'zh-CN', detectBrowserLanguage: false } as const; /** What the fixed starter loader hands the provider for the card's spec payload (objectui#10381). */ @@ -303,7 +308,7 @@ describe('an app bundle that lands after mount (objectui#10382)', () => { describe('the built-in catalogue path — the grading sibling', () => { it('a boot catalogue that lands after mount reaches the reader already on screen', async () => { - const instance = instanceWithoutCatalogue('zh', 'zh'); + const instance = instanceWithoutCatalogue('zh-Hans', 'zh-Hans'); render( @@ -350,4 +355,48 @@ describe('the built-in catalogue path — the grading sibling', () => { expect(screen.getByTestId('save').textContent).toBe('Save'); expect(renders.save).toBe(1); }); + + // objectstack#30511: the catalogues are keyed by platform locale table tag, + // and an instance often runs in a stored alias of one. The boot effect used + // to write the tag's catalogue a second time under the alias, an `added` + // event that re-rendered every reader (and refetched every chart keyed on + // the resolver) for a catalogue that was already resident. + it.each([ + ['zh', 'zh-Hans', zh.common.save], + ['zh-CN', 'zh-Hans', zh.common.save], + ['zh-TW', 'zh-Hant', zhHant.common.save], + ['pt', 'pt-BR', ptBR.common.save], + ])('(d) an instance in the alias %s, whose %s catalogue is resident, reads it and wakes nobody', async (alias, tag, save) => { + const instance = createI18n({ defaultLanguage: alias, detectBrowserLanguage: false }); + render( + + + , + ); + await settle(); + await settle(); + + expect(instance.language).toBe(alias); + expect(screen.getByTestId('save').textContent).toBe(save); + expect(renders.save).toBe(1); + // The catalogue stays under its tag; the alias reads it through `fallbackLng`. + expect(instance.hasResourceBundle(alias, 'translation')).toBe(false); + expect(instance.hasResourceBundle(tag, 'translation')).toBe(true); + }); + + it('a boot catalogue that lands after mount reaches a reader in an alias of its tag', async () => { + const instance = instanceWithoutCatalogue('zh-TW', 'zh-Hant'); + render( + + + , + ); + expect(screen.getByTestId('save').textContent).toBe('Save'); + + await settle(); + + expect(zhHant.common.save).not.toBe(zh.common.save); + expect(screen.getByTestId('save').textContent).toBe(zhHant.common.save); + expect(instance.hasResourceBundle('zh-TW', 'translation')).toBe(false); + }); }); diff --git a/ui/packages/i18n/src/__tests__/raw-key-call-sites-3546.test.tsx b/ui/packages/i18n/src/__tests__/raw-key-call-sites-3546.test.tsx index d6114c32bf2..02bbfee8c98 100644 --- a/ui/packages/i18n/src/__tests__/raw-key-call-sites-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/raw-key-call-sites-3546.test.tsx @@ -28,6 +28,7 @@ * below that actually pins this fix is a `zh` one. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import React from 'react'; import { I18nProvider, useObjectTranslation } from '../provider'; @@ -70,7 +71,7 @@ beforeEach(() => { describe('objectui#3546 slice one — the five raw-key call-site keys', () => { it('covers all ten packs (guards the loop from silently emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(KEYS).toHaveLength(5); }); @@ -102,7 +103,7 @@ describe('objectui#3546 slice one — the five raw-key call-site keys', () => { // reached the user verbatim: the toast literally read // `console.objectView.cannotEditMetaView`. Nothing shadowed it, which is // why these two are the sites the issue verified on the page. - it.each(['en', 'zh'] as const)('%s resolves both meta-view toasts from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves both meta-view toasts from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const key of [ 'console.objectView.cannotEditMetaView', @@ -160,7 +161,7 @@ describe('objectui#3546 slice one — the five raw-key call-site keys', () => { 'detail.back', ); - it.each(['en', 'zh'] as const)('%s resolves detail.viewSource from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves detail.viewSource from the pack', (lang) => { const { result } = renderHook(() => useDetailish(), { wrapper: wrapperFor(lang) }); const value = result.current.t('detail.viewSource'); expect(value, `${lang} rendered the raw key`).not.toBe('detail.viewSource'); @@ -184,7 +185,7 @@ describe('objectui#3546 slice one — the five raw-key call-site keys', () => { return typeof hostValue === 'string' && hostValue !== key ? hostValue : defaults[key] || key; }; - it.each(['en', 'zh'] as const)('%s resolves the refresh aria-label from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves the refresh aria-label from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); const t = perKey(result.current.t, { 'gantt.toolbar.refresh': 'Refresh' }); expect(t('gantt.toolbar.refresh')).toBe(at(builtInLocales[lang], 'gantt.toolbar.refresh')); diff --git a/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts b/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts index 1990935f5f1..9bddd3fa981 100644 --- a/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts +++ b/ui/packages/i18n/src/__tests__/record-delete-permanent-27823.test.ts @@ -21,6 +21,7 @@ */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales/index'; type LocaleCode = keyof typeof builtInLocales; @@ -39,7 +40,7 @@ function permanenceSentence(lang: LocaleCode): string { describe('record delete says it is permanent and the toast can name the record (objectstack#27823)', () => { it('reads ten packs, and the en permanence sentence is the house wording', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); // Positive control for the extraction: it lands on a real sentence, not ''. expect(permanenceSentence('en')).toBe('This cannot be undone.'); }); diff --git a/ui/packages/i18n/src/__tests__/recordSelectionRefusal-13384.test.ts b/ui/packages/i18n/src/__tests__/recordSelectionRefusal-13384.test.ts index b3d63696404..c7adc315690 100644 --- a/ui/packages/i18n/src/__tests__/recordSelectionRefusal-13384.test.ts +++ b/ui/packages/i18n/src/__tests__/recordSelectionRefusal-13384.test.ts @@ -27,8 +27,8 @@ const actionsOf = (pack: unknown) => (pack as { actions: Record describe('objectui#13384 — the row-count refusals are translated', () => { it('zh-CN reads them in Chinese', () => { - expect(actionsOf(builtInLocales.zh).selectExactlyOneRow).toBe('此操作针对单条记录,请只选择一行。'); - expect(actionsOf(builtInLocales.zh).selectRowFirst).toBe('此操作针对单条记录,请先选择一行。'); + expect(actionsOf(builtInLocales['zh-Hans']).selectExactlyOneRow).toBe('此操作针对单条记录,请只选择一行。'); + expect(actionsOf(builtInLocales['zh-Hans']).selectRowFirst).toBe('此操作针对单条记录,请先选择一行。'); }); it('en holds the English source core falls back to', () => { diff --git a/ui/packages/i18n/src/__tests__/region-locale-fallback-11326.test.tsx b/ui/packages/i18n/src/__tests__/region-locale-fallback-11326.test.tsx index 282a2c140fa..10b3e546b96 100644 --- a/ui/packages/i18n/src/__tests__/region-locale-fallback-11326.test.tsx +++ b/ui/packages/i18n/src/__tests__/region-locale-fallback-11326.test.tsx @@ -1,21 +1,28 @@ /** - * A region-tagged language code reaches the built-in catalogue of its base - * language (objectui#11326). + * A region-tagged language code reaches the built-in catalogue it means + * (objectui#11326), for BOTH Chinese scripts (objectstack#30511). * * The platform answers full BCP-47 tags (`zh-CN`), the Console's language menu - * offers them, and the provider stores what the user picked — while the - * built-in catalogues are keyed by base language (`zh`). The registry used to - * look a code up EXACTLY, so `loadBuiltInLocale('zh-CN')` resolved `null` and + * offers them, and the provider stores what the user picked. The registry used + * to look a code up EXACTLY, so `loadBuiltInLocale('zh-CN')` resolved `null` and * `isBuiltInLanguage('zh-CN')` answered `false`: a stored `zh-CN` booted the * provider with no Chinese catalogue at all, and every built-in string fell * through to `en` on every reload. * + * Since objectstack#30511 the catalogues are keyed by the platform locale + * table's tags (`zh-Hans`, `zh-Hant`, `pt-BR`) and a code is read through the + * table's read aliases first, so the base language is no longer enough: `zh` + * cannot tell a Taiwan reader from a mainland one. `zh-CN`, `zh-SG` and a bare + * `zh` read as `zh-Hans`; `zh-TW`, `zh-HK` and `zh-MO` read as `zh-Hant`. The + * key every case below reads, `common.save`, is spelled differently in the two + * packs (保存 / 儲存), so a case that reached the wrong script fails. + * * ## Why these cases take a FRESH module graph * - * The DOM projects make all ten catalogues resident before any test runs - * (`vitest.setup.i18n-catalogues.ts`). With `zh` resident, i18next answers a - * `zh-CN` lookup out of the `zh` bundle by its own language hierarchy — so in - * that state the defect is invisible and a pin passes on the broken code. A + * The DOM projects make every catalogue resident before any test runs + * (`vitest.setup.i18n-catalogues.ts`). With the packs resident, i18next can + * answer a `zh-CN` lookup out of a resident bundle through its fallback chain — + * so in that state the defect is invisible and a pin passes on the broken code. A * real page load has `en` resident and nothing else, which is the state * `vi.resetModules()` hands back: only then does the catalogue have to come * through the registry, which is the thing under test. @@ -24,7 +31,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { act, render, screen, waitFor } from '@testing-library/react'; -import zh from '../locales/zh.js'; +import zhHans from '../locales/zh-Hans.js'; +import zhHant from '../locales/zh-Hant.js'; import en from '../locales/en.js'; import { matchLanguageTag } from '../locales/registry.js'; @@ -48,19 +56,29 @@ async function freshI18n() { type Fresh = Awaited>; /** A reader built from the FRESH provider's hook, so it reads the fresh instance. */ -function probeFor(useObjectTranslation: Fresh['useObjectTranslation']) { +function probeFor(useObjectTranslation: Fresh['useObjectTranslation'], switchTo = 'zh-CN') { return function Probe() { const { t, language, changeLanguage } = useObjectTranslation(); return (
{t('common.save')} {language} - +
); }; } +/** The codes each Chinese pack serves: the table's read aliases, plus the tag. */ +const SIMPLIFIED_CODES = ['zh-Hans', 'zh', 'zh-CN', 'zh-SG', 'zh-Hans-CN'] as const; +const TRADITIONAL_CODES = ['zh-Hant', 'zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant-TW', 'zh-Hant-HK'] as const; + +it('the two packs really spell the probe key differently — otherwise no case below can tell them apart', () => { + expect(zhHans.common.save).toBe('保存'); + expect(zhHant.common.save).toBe('儲存'); + expect(en.common.save).not.toBe(zhHans.common.save); +}); + const saveText = () => screen.getByTestId('save').textContent; const langText = () => screen.getByTestId('lang').textContent; @@ -69,16 +87,53 @@ afterEach(() => { document.documentElement.lang = ''; }); -describe('the registry answers a region-tagged code with its base catalogue', () => { - it('`loadBuiltInLocale(\'zh-CN\')` resolves the `zh` catalogue', async () => { +describe('the registry answers a region-tagged code with the catalogue of its script', () => { + it('`loadBuiltInLocale(\'zh-CN\')` resolves the `zh-Hans` catalogue', async () => { + const { loadBuiltInLocale, isBuiltInLocaleLoaded } = await freshI18n(); + expect(isBuiltInLocaleLoaded('zh-CN')).toBe(false); + + const viaRegion = (await loadBuiltInLocale('zh-CN')) as typeof zhHans | null; + + expect(viaRegion).not.toBeNull(); + expect(viaRegion?.common.save).toBe(zhHans.common.save); + }); + + it('`loadBuiltInLocale(\'zh-TW\')` resolves the `zh-Hant` catalogue, not the Simplified one', async () => { const { loadBuiltInLocale, isBuiltInLocaleLoaded } = await freshI18n(); - expect(isBuiltInLocaleLoaded('zh')).toBe(false); + expect(isBuiltInLocaleLoaded('zh-TW')).toBe(false); - const viaRegion = (await loadBuiltInLocale('zh-CN')) as typeof zh | null; + const viaRegion = (await loadBuiltInLocale('zh-TW')) as typeof zhHant | null; expect(viaRegion).not.toBeNull(); - expect(viaRegion?.common.save).toBe(zh.common.save); - expect(zh.common.save).not.toBe(en.common.save); + expect(viaRegion?.common.save).toBe(zhHant.common.save); + // Loading Traditional fetched Traditional only. + expect(isBuiltInLocaleLoaded('zh-CN')).toBe(false); + }); + + it.each(SIMPLIFIED_CODES)('`%s` reads as `zh-Hans`', async (code) => { + const { resolveBuiltInLanguage, isBuiltInLanguage } = await freshI18n(); + expect(resolveBuiltInLanguage(code)).toBe('zh-Hans'); + expect(isBuiltInLanguage(code)).toBe(true); + }); + + it.each(TRADITIONAL_CODES)('`%s` reads as `zh-Hant`', async (code) => { + const { resolveBuiltInLanguage, isBuiltInLanguage } = await freshI18n(); + expect(resolveBuiltInLanguage(code)).toBe('zh-Hant'); + expect(isBuiltInLanguage(code)).toBe(true); + }); + + it('reads a stored code case-insensitively, as the table does', async () => { + const { resolveBuiltInLanguage } = await freshI18n(); + expect(resolveBuiltInLanguage('ZH-tw')).toBe('zh-Hant'); + expect(resolveBuiltInLanguage('zh-cn')).toBe('zh-Hans'); + expect(resolveBuiltInLanguage('zh-hant')).toBe('zh-Hant'); + }); + + it('the other renamed pack: a stored `pt` (and a Portuguese region) reads as `pt-BR`', async () => { + const { resolveBuiltInLanguage } = await freshI18n(); + expect(resolveBuiltInLanguage('pt')).toBe('pt-BR'); + expect(resolveBuiltInLanguage('pt-BR')).toBe('pt-BR'); + expect(resolveBuiltInLanguage('pt-PT')).toBe('pt-BR'); }); it('`en` is the control: it resolves itself, unchanged', async () => { @@ -90,9 +145,10 @@ describe('the registry answers a region-tagged code with its base catalogue', () expect(isBuiltInLanguage('en')).toBe(true); }); - it('`isBuiltInLanguage(\'zh-CN\')` agrees with the loader', async () => { + it('`isBuiltInLanguage(\'zh-CN\')` and `(\'zh-TW\')` agree with the loader', async () => { const { isBuiltInLanguage } = await freshI18n(); expect(isBuiltInLanguage('zh-CN')).toBe(true); + expect(isBuiltInLanguage('zh-TW')).toBe(true); }); it('a code with no base catalogue is still refused, exactly as before', async () => { @@ -106,10 +162,9 @@ describe('the registry answers a region-tagged code with its base catalogue', () }); describe('the exact-then-base order', () => { - // No built-in catalogue is keyed by a full tag at the time of writing, so the - // "exact wins" half cannot be shown on a real `zh-TW` beside `zh`. It is - // pinned on the normaliser itself, and on every real catalogue code - // resolving to itself — which a future full-tag catalogue would inherit. + // `matchLanguageTag` is the generic matcher the provider seed and the + // `studio` namespace use over their own sets; the built-in catalogues are + // matched by `resolveBuiltInLanguage`, through the table's aliases first. it('prefers an exact match over the base language, and falls back to the base', () => { const has = (code: string) => code === 'pt' || code === 'pt-BR'; @@ -125,19 +180,40 @@ describe('the exact-then-base order', () => { for (const code of BUILT_IN_LANGUAGE_CODES) { expect(resolveBuiltInLanguage(code)).toBe(code); } - expect(resolveBuiltInLanguage('zh-CN')).toBe('zh'); + expect(resolveBuiltInLanguage('zh-CN')).toBe('zh-Hans'); + expect(resolveBuiltInLanguage('zh-TW')).toBe('zh-Hant'); + }); + + it('falls back by prefix for a region the table does not alias', async () => { + const { resolveBuiltInLanguage } = await freshI18n(); + expect(resolveBuiltInLanguage('fr-CA')).toBe('fr'); + expect(resolveBuiltInLanguage('en-GB')).toBe('en'); + expect(resolveBuiltInLanguage('es-MX')).toBe('es'); + expect(resolveBuiltInLanguage('xx-YY')).toBeNull(); + expect(resolveBuiltInLanguage('')).toBeNull(); }); - it('memoises under the catalogue code — `zh-CN` and `zh` share one catalogue', async () => { + it('memoises under the table tag — `zh-CN`, `zh` and `zh-Hans` share one catalogue', async () => { const { loadBuiltInLocale, getLoadedBuiltInLocales, isBuiltInLocaleLoaded } = await freshI18n(); const viaRegion = await loadBuiltInLocale('zh-CN'); expect(await loadBuiltInLocale('zh')).toBe(viaRegion); - // `createI18n` and the app-shell splash read this snapshot by catalogue - // code; a `zh-CN` key here would be a catalogue neither of them finds. - expect(Object.keys(getLoadedBuiltInLocales()).sort()).toEqual(['en', 'zh']); + expect(await loadBuiltInLocale('zh-Hans')).toBe(viaRegion); + // `createI18n` and the app-shell splash read this snapshot by table tag; + // a `zh-CN` key here would be a catalogue neither of them finds. + expect(Object.keys(getLoadedBuiltInLocales()).sort()).toEqual(['en', 'zh-Hans']); expect(isBuiltInLocaleLoaded('zh-CN')).toBe(true); + expect(isBuiltInLocaleLoaded('zh-TW')).toBe(false); + }); + + it('memoises Traditional under `zh-Hant` — `zh-TW` and `zh-HK` share one catalogue', async () => { + const { loadBuiltInLocale, getLoadedBuiltInLocales } = await freshI18n(); + + const viaTaiwan = await loadBuiltInLocale('zh-TW'); + + expect(await loadBuiltInLocale('zh-HK')).toBe(viaTaiwan); + expect(Object.keys(getLoadedBuiltInLocales()).sort()).toEqual(['en', 'zh-Hant']); }); }); @@ -153,7 +229,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => {
, ); - await waitFor(() => expect(saveText()).toBe(zh.common.save)); + await waitFor(() => expect(saveText()).toBe(zhHans.common.save)); }); it('renders a built-in key in Chinese — a host with no loader', async () => { @@ -167,7 +243,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => {
, ); - await waitFor(() => expect(saveText()).toBe(zh.common.save)); + await waitFor(() => expect(saveText()).toBe(zhHans.common.save)); }); it('paints Chinese on the FIRST render when the host awaited `preloadBootstrapLocale`', async () => { @@ -178,7 +254,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => { // What `apps/console/src/main.tsx` does before `createRoot().render()`. await preloadBootstrapLocale({ config: CONFIG, hasLoader: true }); - expect(isBuiltInLocaleLoaded('zh')).toBe(true); + expect(isBuiltInLocaleLoaded('zh-Hans')).toBe(true); render( ({})}> @@ -187,7 +263,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => { ); // No waitFor: the first paint is the assertion. - expect(saveText()).toBe(zh.common.save); + expect(saveText()).toBe(zhHans.common.save); }); it('keeps the user\'s tag — the language, `` and the stored value stay `zh-CN`', async () => { @@ -201,7 +277,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => { , ); - await waitFor(() => expect(saveText()).toBe(zh.common.save)); + await waitFor(() => expect(saveText()).toBe(zhHans.common.save)); expect(langText()).toBe('zh-CN'); expect(document.documentElement.lang).toBe('zh-CN'); expect(window.localStorage.getItem(STORED)).toBe('zh-CN'); @@ -224,7 +300,7 @@ describe('a provider bootstrapped with a stored `zh-CN`', () => { }); describe('switching to `zh-CN` at runtime', () => { - it('fetches the `zh` catalogue and keeps `zh-CN` as the language', async () => { + it('fetches the `zh-Hans` catalogue and keeps `zh-CN` as the language', async () => { const { I18nProvider, useObjectTranslation, isBuiltInLocaleLoaded } = await freshI18n(); const Probe = probeFor(useObjectTranslation); @@ -234,15 +310,94 @@ describe('switching to `zh-CN` at runtime', () => {
, ); expect(saveText()).toBe(en.common.save); - expect(isBuiltInLocaleLoaded('zh')).toBe(false); + expect(isBuiltInLocaleLoaded('zh-Hans')).toBe(false); await act(async () => { screen.getByRole('button').click(); }); await waitFor(() => expect(langText()).toBe('zh-CN')); - expect(saveText()).toBe(zh.common.save); + expect(saveText()).toBe(zhHans.common.save); expect(document.documentElement.lang).toBe('zh-CN'); expect(window.localStorage.getItem(STORED)).toBe('zh-CN'); }); }); + +describe('a provider bootstrapped with a stored Traditional code', () => { + it.each(['zh-TW', 'zh-HK'])('a stored `%s` renders a built-in key in Traditional Chinese', async (code) => { + const { I18nProvider, useObjectTranslation } = await freshI18n(); + const Probe = probeFor(useObjectTranslation); + window.localStorage.setItem(STORED, code); + + render( + ({})}> + + , + ); + + await waitFor(() => expect(saveText()).toBe(zhHant.common.save)); + // The user's tag is kept, exactly as for `zh-CN`. + expect(langText()).toBe(code); + expect(document.documentElement.lang).toBe(code); + expect(window.localStorage.getItem(STORED)).toBe(code); + }); + + it('paints Traditional on the FIRST render when the host awaited `preloadBootstrapLocale`', async () => { + const { I18nProvider, useObjectTranslation, preloadBootstrapLocale, isBuiltInLocaleLoaded } = + await freshI18n(); + const Probe = probeFor(useObjectTranslation); + window.localStorage.setItem(STORED, 'zh-TW'); + + await preloadBootstrapLocale({ config: CONFIG, hasLoader: true }); + expect(isBuiltInLocaleLoaded('zh-Hant')).toBe(true); + expect(isBuiltInLocaleLoaded('zh-Hans')).toBe(false); + + render( + ({})}> + + , + ); + + expect(saveText()).toBe(zhHant.common.save); + }); + + it('a stored legacy `zh` still renders Simplified — the value phase 1 menus wrote', async () => { + const { I18nProvider, useObjectTranslation } = await freshI18n(); + const Probe = probeFor(useObjectTranslation); + window.localStorage.setItem(STORED, 'zh'); + + render( + ({})}> + + , + ); + + await waitFor(() => expect(saveText()).toBe(zhHans.common.save)); + expect(langText()).toBe('zh'); + }); +}); + +describe('switching to `zh-TW` at runtime', () => { + it('fetches the `zh-Hant` catalogue and keeps `zh-TW` as the language', async () => { + const { I18nProvider, useObjectTranslation, isBuiltInLocaleLoaded } = await freshI18n(); + const Probe = probeFor(useObjectTranslation, 'zh-TW'); + + render( + + + , + ); + expect(saveText()).toBe(en.common.save); + expect(isBuiltInLocaleLoaded('zh-Hant')).toBe(false); + + await act(async () => { + screen.getByRole('button').click(); + }); + + await waitFor(() => expect(langText()).toBe('zh-TW')); + expect(saveText()).toBe(zhHant.common.save); + expect(isBuiltInLocaleLoaded('zh-Hans')).toBe(false); + expect(document.documentElement.lang).toBe('zh-TW'); + expect(window.localStorage.getItem(STORED)).toBe('zh-TW'); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/report-editor-retired-4145.test.ts b/ui/packages/i18n/src/__tests__/report-editor-retired-4145.test.ts index 2084a6795c0..bd493edc6cd 100644 --- a/ui/packages/i18n/src/__tests__/report-editor-retired-4145.test.ts +++ b/ui/packages/i18n/src/__tests__/report-editor-retired-4145.test.ts @@ -42,6 +42,7 @@ * `all-locales-key-parity`'s "en defines every key the other packs define". */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { builtInLocales } from '../locales/index'; @@ -211,7 +212,7 @@ describe('`report.editor.*` is retired to one key (objectui#4145)', () => { expect(new Set(RETIRED_REPORT_EDITOR_KEYS).size).toBe(105); expect(RETIRED_REPORT_EDITOR_KEYS).not.toContain(SURVIVOR); expect(RETIRED_REPORT_EDITOR_KEYS.length + 1).toBe(106); - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it('no pack defines any retired key, in any of the ten packs', () => { @@ -253,7 +254,7 @@ describe('`report.editor.*` is retired to one key (objectui#4145)', () => { expect(at(builtInLocales.en, 'report.editor.panelTitle')).toBe('Edit report'); // A sample across writing systems, so "all ten packs kept the key" cannot be // satisfied by ten copies of the English string. - expect(at(builtInLocales.zh, 'report.editor.panelTitle')).toBe('编辑报表'); + expect(at(builtInLocales['zh-Hans'], 'report.editor.panelTitle')).toBe('编辑报表'); expect(at(builtInLocales.ru, 'report.editor.panelTitle')).toBe('Редактировать отчёт'); expect(at(builtInLocales.ar, 'report.editor.panelTitle')).toBe('تعديل التقرير'); }); diff --git a/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx index ab9baf7ea45..1bb2b8699e0 100644 --- a/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx +++ b/ui/packages/i18n/src/__tests__/residue-namespaces-3546.test.tsx @@ -59,6 +59,7 @@ * slice six, and it is not what this slice repairs. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -199,7 +200,7 @@ beforeEach(() => { describe('objectui#3546 slice seven — the ratchet residue', () => { it('covers all ten packs and all twenty-four paths (guards the loops from emptying)', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(MEASURED_KEYS).toHaveLength(17); expect(LINK_END_MEMBERS).toHaveLength(2); expect(STATUS_MEMBERS).toHaveLength(5); @@ -477,8 +478,8 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.de, 'gantt.linkType.fs')).toBe('Ende → Anfang'); expect(at(builtInLocales.de, 'gantt.linkEnd.start')).toBe('Anfang'); expect(at(builtInLocales.de, 'gantt.linkEnd.end')).toBe('Ende'); - expect(at(builtInLocales.zh, 'gantt.linkType.fs')).toBe('完成 → 开始'); - expect(at(builtInLocales.zh, 'gantt.linkEnd.start')).toBe('开始'); + expect(at(builtInLocales['zh-Hans'], 'gantt.linkType.fs')).toBe('完成 → 开始'); + expect(at(builtInLocales['zh-Hans'], 'gantt.linkEnd.start')).toBe('开始'); expect(at(builtInLocales.ar, 'gantt.linkType.fs')).toBe('نهاية → بداية'); expect(at(builtInLocales.ar, 'gantt.linkEnd.start')).toBe('بداية'); expect(at(builtInLocales.ar, 'gantt.linkEnd.end')).toBe('نهاية'); @@ -552,13 +553,13 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.fr, 'organization.invitations.status.accepted')).toBe('Acceptée'); expect(at(builtInLocales.es, 'organization.accept.accepted')).toBe('Invitación aceptada'); expect(at(builtInLocales.es, 'organization.invitations.status.accepted')).toBe('Aceptada'); - expect(at(builtInLocales.pt, 'organization.accept.accepted')).toBe('Convite aceito'); - expect(at(builtInLocales.pt, 'organization.invitations.status.accepted')).toBe('Aceito'); + expect(at(builtInLocales['pt-BR'], 'organization.accept.accepted')).toBe('Convite aceito'); + expect(at(builtInLocales['pt-BR'], 'organization.invitations.status.accepted')).toBe('Aceito'); // …and `all`, the filter tab's own member, follows the same gender: the // noun it quantifies is "invitations". expect(at(builtInLocales.fr, 'organization.invitations.status.all')).toBe('Toutes'); expect(at(builtInLocales.es, 'organization.invitations.status.all')).toBe('Todas'); - expect(at(builtInLocales.pt, 'organization.invitations.status.all')).toBe('Todos'); + expect(at(builtInLocales['pt-BR'], 'organization.invitations.status.all')).toBe('Todos'); // `canceled` is the ORG withdrawing the invitation, not the invitee // declining it, so de takes `zurückgezogen` from its own cancel toast // rather than the generic `Abgebrochen`. @@ -628,8 +629,8 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { 'Listo', ); } - expect(at(builtInLocales.zh, 'approvalsInbox.statusPending')).toBe('待审批'); - expect(at(builtInLocales.zh, 'organization.invitations.status.pending')).toBe('等待中'); + expect(at(builtInLocales['zh-Hans'], 'approvalsInbox.statusPending')).toBe('待审批'); + expect(at(builtInLocales['zh-Hans'], 'organization.invitations.status.pending')).toBe('等待中'); }); it('the concept neighbours are the ones this slice claims, and they still say so', () => { @@ -642,8 +643,8 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.en, 'home.welcomeAdminDescriptionNoAi')).toBe( 'Set up your first application from the Administration menu on the left.', ); - expect(at(builtInLocales.zh, 'home.welcomeAdminDescriptionNoAi')).toContain('「管理」菜单'); - expect(at(builtInLocales.zh, 'layout.systemNav.administration')).toBe('管理'); + expect(at(builtInLocales['zh-Hans'], 'home.welcomeAdminDescriptionNoAi')).toContain('「管理」菜单'); + expect(at(builtInLocales['zh-Hans'], 'layout.systemNav.administration')).toBe('管理'); expect(at(builtInLocales.de, 'home.welcomeAdminDescriptionNoAi')).toContain('Verwaltungsmenü'); expect(at(builtInLocales.de, 'layout.systemNav.administration')).toBe('Verwaltung'); expect(at(builtInLocales.ru, 'home.welcomeAdminDescriptionNoAi')).toContain('«Администрирование»'); @@ -686,9 +687,9 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // product name stays Latin in all ten, which is why `Studio` shows up in the // loanword check above. expect(at(builtInLocales.en, 'topbar.designInStudio')).toBe('Design in Studio'); - expect(at(builtInLocales.zh, 'topbar.designInStudio')).toBe('在 Studio 中设计'); - expect(at(builtInLocales.zh, 'common.edit')).toBe('编辑'); - expect(at(builtInLocales.zh, 'common.editInStudio')).toBe('在 Studio 中编辑'); + expect(at(builtInLocales['zh-Hans'], 'topbar.designInStudio')).toBe('在 Studio 中设计'); + expect(at(builtInLocales['zh-Hans'], 'common.edit')).toBe('编辑'); + expect(at(builtInLocales['zh-Hans'], 'common.editInStudio')).toBe('在 Studio 中编辑'); // `on this instance` — ru and ar both render instance/deployment with their // own environment word, and have done so consistently. @@ -870,13 +871,13 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // and this hole takes either a field label or the record label above. pt // alone diverges in sentence shape; `en` is unchanged, so the drift gate has // no event here. - expect(composed('pt')).toContain('mais recente que afeta este registro'); - expect(composed('pt')).not.toContain('de este'); - expect(at(builtInLocales.pt, 'detail.concurrentUpdateDescription')).toContain('que afeta {{field}}'); + expect(composed('pt-BR')).toContain('mais recente que afeta este registro'); + expect(composed('pt-BR')).not.toContain('de este'); + expect(at(builtInLocales['pt-BR'], 'detail.concurrentUpdateDescription')).toContain('que afeta {{field}}'); // The split contract the dialog depends on: exactly one hole, so // `split('{{field}}')` yields exactly the two halves it destructures. expect( - (at(builtInLocales.pt, 'detail.concurrentUpdateDescription') as string).split('{{field}}'), + (at(builtInLocales['pt-BR'], 'detail.concurrentUpdateDescription') as string).split('{{field}}'), ).toHaveLength(2); }); @@ -889,7 +890,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { // (45 values against 25 single), everyone else mirrors en. for (const lang of LANGS) { const value = at(builtInLocales[lang], KEY) as string; - expect(value.includes(lang === 'zh' ? '——' : '—'), `${lang} ${KEY} dash`).toBe(true); + expect(value.includes(lang === 'zh-Hans' || lang === 'zh-Hant' ? '——' : '—'), `${lang} ${KEY} dash`).toBe(true); } // fr writes the straight apostrophe U+0027 (502 values against 21 curly) — // across every path, not just this one. @@ -905,11 +906,11 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { expect(at(builtInLocales.ru, 'empty.appNotFoundDescription')).toContain('учётной'); // The quote style around `{{name}}` follows the sibling empty-state value in // the SAME pack: zh curly, ja corner brackets, the rest ASCII. - expect(at(builtInLocales.zh, 'empty.objectNotFoundDescription')).toContain('“{{name}}”'); - expect(at(builtInLocales.zh, 'empty.interfacePageSourceMissing')).toContain('“{{name}}”'); + expect(at(builtInLocales['zh-Hans'], 'empty.objectNotFoundDescription')).toContain('“{{name}}”'); + expect(at(builtInLocales['zh-Hans'], 'empty.interfacePageSourceMissing')).toContain('“{{name}}”'); expect(at(builtInLocales.ja, 'empty.objectNotFoundDescription')).toContain('「{{name}}」'); expect(at(builtInLocales.ja, 'empty.interfacePageSourceMissing')).toContain('「{{name}}」'); - for (const lang of ['ko', 'fr', 'es', 'pt', 'ru', 'ar'] as const) { + for (const lang of ['ko', 'fr', 'es', 'pt-BR', 'ru', 'ar'] as const) { expect(at(builtInLocales[lang], 'empty.interfacePageSourceMissing'), `${lang} quotes`).toContain('"{{name}}"'); } // de WAS the deliberate divergence: at slice-seven time its sibling values @@ -1034,7 +1035,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { ).toBe(true); }); - it.each(['en', 'zh'] as const)('%s resolves every sampled key from the pack', (lang) => { + it.each(['en', 'zh-Hans', 'zh-Hant'] as const)('%s resolves every sampled key from the pack', (lang) => { const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const [key, owner] of SAMPLE) { const value = result.current.t(key); @@ -1063,7 +1064,7 @@ describe('objectui#3546 slice seven — the ratchet residue', () => { it('both template families render every member, in en and in zh', () => { // Exercised the way the call sites build the key, so a family that resolves // in the abstract but not through the template would still fail. - for (const lang of ['en', 'zh'] as const) { + for (const lang of ['en', 'zh-Hans', 'zh-Hant'] as const) { window.localStorage.clear(); const { result } = renderHook(() => useObjectTranslation(), { wrapper: wrapperFor(lang) }); for (const member of LINK_END_MEMBERS) { diff --git a/ui/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts b/ui/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts index 9744a70dec0..f69fbd88641 100644 --- a/ui/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts +++ b/ui/packages/i18n/src/__tests__/searchItemsAvailable-plural-9664.test.ts @@ -37,6 +37,7 @@ * parenthesised plural marker. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import i18next from 'i18next'; import { builtInLocales } from '../locales'; @@ -67,7 +68,7 @@ void instance.init({ describe('search.itemsAvailable reads a singular at one item, in every pack (objectui#9664)', () => { it('the walk covers all ten packs — not an empty assertion', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); for (const lang of LANGS) expect(slotsOf(lang).length, lang).toBeGreaterThan(0); }); @@ -91,7 +92,7 @@ describe('search.itemsAvailable reads a singular at one item, in every pack (obj // select only `other`. The five Latin packs and `ar` have a singular — and // in `ar` the slot two items read is the dual, `_two`, not `_other` (which // serves 100+ with the singular noun, as Arabic does). - const singular: LocaleCode[] = ['en', 'de', 'fr', 'es', 'pt', 'ar']; + const singular: LocaleCode[] = ['en', 'de', 'fr', 'es', 'pt-BR', 'ar']; for (const lang of singular) { const atTwo = `${KEY}_${new Intl.PluralRules(lang).select(2)}`; expect(at(builtInLocales[lang], `${KEY}_one`), `${lang} _one vs ${atTwo}`).not.toBe( diff --git a/ui/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx b/ui/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx index df5b729efb4..4e2d9d59261 100644 --- a/ui/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx +++ b/ui/packages/i18n/src/__tests__/showEmptyRelated-plural-base-3863.test.tsx @@ -46,6 +46,7 @@ * prove reachability and prove the English wording did not move. */ import { describe, it, expect, beforeEach } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { renderHook } from '@testing-library/react'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -125,7 +126,7 @@ beforeEach(() => { describe('objectui#3863 — detail.showEmptyRelated carries a base key in all ten packs', () => { it('covers ten packs and three slots — guards the loops from emptying', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(SLOTS).toHaveLength(3); expect(COUNTS.length).toBeGreaterThanOrEqual(7); }); @@ -269,13 +270,14 @@ describe('objectui#3863 — detail.showEmptyRelated carries a base key in all te const CASES: Array<[LocaleCode, number, string]> = [ ['de', 1, '+ 1 leer'], ['de', 7, '+ 7 leer'], - ['zh', 3, '+ 3 个为空'], + ['zh-Hans', 3, '+ 3 个为空'], + ['zh-Hant', 3, '+ 3 個為空'], ['ja', 3, '+ 3 件(空)'], ['ko', 3, '+ 3개 비어 있음'], ['fr', 1, '+ 1 vide'], ['fr', 7, '+ 7 vides'], ['es', 7, '+ 7 vacíos'], - ['pt', 7, '+ 7 vazios'], + ['pt-BR', 7, '+ 7 vazios'], ]; for (const [lang, count, expected] of CASES) { window.localStorage.clear(); @@ -289,7 +291,7 @@ describe('objectui#3863 — detail.showEmptyRelated carries a base key in all te // objectui#11432 it has its own slot; on this key it repeats `_other`, because // the count governs no noun complement (the "de" of "un million de …" goes // before a noun, and this label has none). - for (const lang of ['fr', 'es', 'pt'] as const) { + for (const lang of ['fr', 'es', 'pt-BR'] as const) { expect(new Intl.PluralRules(lang).select(100)).toBe('other'); expect(new Intl.PluralRules(lang).select(1_000_000)).toBe('many'); window.localStorage.clear(); diff --git a/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts b/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts index f6cf802c970..ced87d2b585 100644 --- a/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts +++ b/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest'; import { builtInLocales } from '../locales'; -const sentence = (lang: 'en' | 'zh') => +const sentence = (lang: 'en' | 'zh-Hans') => (builtInLocales[lang] as unknown as { console: { studio: { accessRequired: string } } }).console.studio.accessRequired; describe('the Studio access refusal names who grants it (objectstack#27521)', () => { @@ -22,7 +22,7 @@ describe('the Studio access refusal names who grants it (objectstack#27521)', () }); it('zh: 管理员,在「设置 → 权限集分配」', () => { - expect(sentence('zh')).toMatch(/管理员/u); - expect(sentence('zh')).toMatch(/设置 → 权限集分配/u); + expect(sentence('zh-Hans')).toMatch(/管理员/u); + expect(sentence('zh-Hans')).toMatch(/设置 → 权限集分配/u); }); }); diff --git a/ui/packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts b/ui/packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts index 07a80e941bf..59ee298dc8a 100644 --- a/ui/packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts @@ -23,6 +23,7 @@ * the same way, with one extra guard the sibling does not need. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; // Derived from the map rather than left as `string[]`: `Object.keys` erases @@ -41,7 +42,7 @@ const charactersRemainingOf = (lang: LocaleCode) => describe('fields.textarea.characterCount locale coverage (objectui#3406)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each(LANGS)('%s defines the counter sentence as a non-empty string', (lang) => { diff --git a/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts index c3943ae00cd..7d755a04a7a 100644 --- a/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts +++ b/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -78,7 +78,7 @@ import { builtInLocales } from '../locales/index'; * The packs whose script is not Latin. Identity with `en` is decidable evidence * of omission here, and only here — see the header on why de/fr/es/pt are out. */ -const NON_LATIN = ['zh', 'ja', 'ko', 'ru', 'ar'] as const; +const NON_LATIN = ['zh-Hans', 'zh-Hant', 'ja', 'ko', 'ru', 'ar'] as const; type NonLatin = (typeof NON_LATIN)[number]; /** A run of Latin letters long enough that a non-Latin pack cannot mean it. */ diff --git a/ui/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts b/ui/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts index 747fcad3922..fa61f558810 100644 --- a/ui/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts +++ b/ui/packages/i18n/src/__tests__/validation-list-joiner-locale-parity.test.ts @@ -45,14 +45,14 @@ describe('validation.formInvalidJoiner is declared per locale (objectstack#5407) }); it('CJK packs enumerate with U+3001', () => { - for (const lang of ['zh', 'ja']) { + for (const lang of ['zh-Hans', 'zh-Hant', 'ja']) { expect(validationOf(lang)[JOINER], lang).toBe(CJK_COMMA); } }); it('Latin-script and Korean packs enumerate with a comma + space', () => { // Korean sits here rather than with zh/ja: it uses the ASCII comma. - for (const lang of ['en', 'es', 'de', 'fr', 'pt', 'ru', 'ko']) { + for (const lang of ['en', 'es', 'de', 'fr', 'pt-BR', 'ru', 'ko']) { expect(validationOf(lang)[JOINER], lang).toBe(', '); } }); @@ -63,7 +63,7 @@ describe('validation.formInvalidJoiner is declared per locale (objectstack#5407) it('no pack outside zh/ja carries the CJK comma — the exact shipped defect', () => { const offenders = Object.keys(builtInLocales).filter( - (lang) => !['zh', 'ja'].includes(lang) && validationOf(lang)[JOINER].includes(CJK_COMMA), + (lang) => !['zh-Hans', 'zh-Hant', 'ja'].includes(lang) && validationOf(lang)[JOINER].includes(CJK_COMMA), ); expect(offenders).toEqual([]); }); diff --git a/ui/packages/i18n/src/__tests__/viewReadonlyTooltip-semantics-3625.test.ts b/ui/packages/i18n/src/__tests__/viewReadonlyTooltip-semantics-3625.test.ts index 8b089f40bb1..aa71a5923b0 100644 --- a/ui/packages/i18n/src/__tests__/viewReadonlyTooltip-semantics-3625.test.ts +++ b/ui/packages/i18n/src/__tests__/viewReadonlyTooltip-semantics-3625.test.ts @@ -47,6 +47,7 @@ * of the two defects came back. */ import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { builtInLocales } from '../locales'; // Derived from the map rather than left as `string[]`: `Object.keys` erases @@ -75,13 +76,14 @@ const EN_RETIRED_SPELLINGS = [ */ const RETIRED: Record = { en: /duplicate|copy/i, - zh: /复制|复刻|拷贝/, + 'zh-Hans': /复制|复刻|拷贝/, + 'zh-Hant': /複製|復刻|複製/, ja: /複製|コピー/, ko: /복제|복사/, de: /duplizier|kopier/i, fr: /dupliqu|duplic|copi/i, es: /dupliqu|duplic|copi/i, - pt: /dupliqu|duplic|copi/i, + 'pt-BR': /dupliqu|duplic|copi/i, ru: /копир|скопир/i, ar: /نسخ|انسخ/, }; @@ -94,13 +96,14 @@ const RETIRED: Record = { */ const ANCHORS: Record = { en: { systemView: /System view/i, inCode: /code/i, readOnly: /read-only/i }, - zh: { systemView: /系统视图/, inCode: /代码/, readOnly: /只读/ }, + 'zh-Hans': { systemView: /系统视图/, inCode: /代码/, readOnly: /只读/ }, + 'zh-Hant': { systemView: /系統檢視/, inCode: /程式碼/, readOnly: /只讀/ }, ja: { systemView: /システムビュー/, inCode: /コード/, readOnly: /読み取り専用/ }, ko: { systemView: /시스템 보기/, inCode: /코드/, readOnly: /읽기 전용/ }, de: { systemView: /Systemansicht/i, inCode: /Code/i, readOnly: /schreibgeschützt/i }, fr: { systemView: /Vue système/i, inCode: /code/i, readOnly: /lecture seule/i }, es: { systemView: /Vista del sistema/i, inCode: /código/i, readOnly: /solo lectura/i }, - pt: { systemView: /Exibição do sistema/i, inCode: /código/i, readOnly: /somente leitura/i }, + 'pt-BR': { systemView: /Exibição do sistema/i, inCode: /código/i, readOnly: /somente leitura/i }, ru: { systemView: /Системное представление/i, inCode: /код/i, readOnly: /чтени/i }, ar: { systemView: /عرض النظام/, inCode: /كود/, readOnly: /قراءة/ }, }; @@ -111,7 +114,8 @@ const ANCHORS: Record> = { - zh: /\p{Script=Han}/u, + 'zh-Hans': /\p{Script=Han}/u, + 'zh-Hant': /\p{Script=Han}/u, ja: /\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Han}/u, ko: /\p{Script=Hangul}/u, ru: /\p{Script=Cyrillic}/u, @@ -120,7 +124,7 @@ const NATIVE_SCRIPT: Partial> = { describe('view.readonlyTooltip semantic coverage (objectui#3625)', () => { it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); expect(Object.keys(RETIRED).sort()).toEqual([...LANGS].sort()); expect(Object.keys(ANCHORS).sort()).toEqual([...LANGS].sort()); }); diff --git a/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts b/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts new file mode 100644 index 00000000000..973c323e216 --- /dev/null +++ b/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The committed zh-Hant pack is exactly what the generator writes from the + * committed zh-Hans pack (objectstack#30511, ruling F of objectstack#29349). + * + * `src/locales/zh-Hant.ts` is not hand-written: `scripts/gen-zh-hant.mjs` + * converts `src/locales/zh-Hans.ts` with the rules in `scripts/zh-hant.mjs` + * (OpenCC `s2twp`, pinned, plus the www.objectos.ai overrides). Two edits can + * leave the pair out of step, and both are ordinary edits that read as done: + * + * - a zh-Hans string changed and `gen:zh-hant` was not run, so Traditional + * readers keep the retired sentence; + * - a conversion rule changed and the pack was not regenerated, so the rule + * says one word and the pack shows another. + * + * Either one reds here, with the command that repairs it. ⛔ The repair is + * never a hand edit of `zh-Hant.ts`: the next regeneration would undo it. + * A rendering that is wrong belongs in `TWP_PHRASE_OVERRIDES` (and in the + * site's converter, which the rules are copied from). + * + * This is a package test, not a `check:*` gate (ruling F adds none). + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import * as converter from '../../scripts/zh-hant.mjs'; +import { + CONVERSION_CASES, + GATE_ONLY_KEYS, + GENERATED_HEADER, + conversionFailures, + generateZhHantPack, + s2t, +} from '../../scripts/zh-hant.mjs'; +import zhHans from '../locales/zh-Hans.js'; +import zhHant from '../locales/zh-Hant.js'; + +// Rooted on THIS FILE, never on `process.cwd()`: the package-scoped and the +// repo-root vitest invocations run in different directories (AGENTS.md). +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const read = (relative: string) => readFileSync(path.join(packageRoot, relative), 'utf8'); + +const REGENERATE = 'run `pnpm --filter @object-ui/i18n gen:zh-hant` and commit src/locales/zh-Hant.ts'; + +describe('the zh-Hant pack is generated, and current', () => { + it('is byte-for-byte what the generator writes from zh-Hans.ts', () => { + const committed = read('src/locales/zh-Hant.ts'); + const generated = generateZhHantPack(read('src/locales/zh-Hans.ts')); + // A boolean first, so a stale pack fails with the repair rather than with + // a 5,000-line diff; the equality below is what a reviewer then reads. + expect(committed === generated, `zh-Hant.ts is stale: ${REGENERATE}`).toBe(true); + expect(committed).toBe(generated); + }); + + it('opens with the generated marker, so nobody edits it by hand', () => { + expect(read('src/locales/zh-Hant.ts').startsWith(GENERATED_HEADER)).toBe(true); + expect(GENERATED_HEADER).toContain('@generated'); + }); + + it('answers every pinned conversion case', () => { + // Counter-probe: an empty case list would make this vacuous. + expect(CONVERSION_CASES.length).toBeGreaterThan(10); + expect(conversionFailures()).toEqual([]); + }); + + it('converts rather than copies: the probe key is Traditional, and is the converter\'s answer', () => { + expect(zhHans.common.save).toBe('保存'); + expect(zhHant.common.save).toBe('儲存'); + expect(zhHant.common.save).toBe(s2t(zhHans.common.save)); + // Placeholders are not Han text, so the converter leaves them alone. + expect(s2t('已选择 {{count}} 条记录')).toContain('{{count}}'); + }); + + it('carries none of the gate-only outbound keys, and drops nothing else', () => { + const ai = zhHant.console.ai as Record; + for (const key of GATE_ONLY_KEYS) expect(ai[key], key).toBeUndefined(); + const hansAi = zhHans.console.ai as Record; + for (const key of GATE_ONLY_KEYS) expect(typeof hansAi[key], key).toBe('string'); + const dropped = Object.keys(hansAi).filter((key) => !(key in ai)); + expect(dropped.sort()).toEqual([...GATE_ONLY_KEYS].sort()); + }); +}); + +describe('the converter is typed by its declaration twin', () => { + it('declares exactly the names the module exports', () => { + const declared = [ + ...read('scripts/zh-hant.d.mts').matchAll(/^export declare (?:const|function) (\w+)/gm), + ].map((match) => match[1]); + // Counter-probe: a regex that stopped matching would compare two empty lists. + expect(declared.length).toBeGreaterThan(5); + expect(declared.sort()).toEqual(Object.keys(converter).sort()); + }); +}); diff --git a/ui/packages/i18n/src/i18n.ts b/ui/packages/i18n/src/i18n.ts index 735277506cc..1048c9f2a80 100644 --- a/ui/packages/i18n/src/i18n.ts +++ b/ui/packages/i18n/src/i18n.ts @@ -5,10 +5,11 @@ */ import i18next, { type i18n as I18nInstance } from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { PLATFORM_LOCALE_READ_ALIASES } from '@objectstack/spec/system'; import { BUILT_IN_LANGUAGE_CODES, getLoadedBuiltInLocales, - isBuiltInLanguage, + resolveBuiltInLanguage, } from './locales/registry.js'; import { isRTL } from './locales/rtl.js'; import type { TranslationKeys } from './locales/en.js'; @@ -117,7 +118,7 @@ function isPlainObject(value: unknown): value is Record { * * ARRAYS ARE REPLACED, NOT CONCATENATED — a deliberate choice, not a library * default. Measured: no built-in pack carries an array value today (every leaf - * in all ten packs is a string), so nothing observable rides on this; the rule + * in every pack is a string), so nothing observable rides on this; the rule * decides what a future array means. An author who writes an array is naming the * whole list, so replacement is the only rule that lets them shorten or reorder * one, and the only one that is idempotent when the merge runs again. This is @@ -159,20 +160,56 @@ function deepMergeTranslations( * `I18nProvider` and {@link preloadBootstrapLocale} answer with THIS function, * so the host and the provider cannot disagree about which catalogue is active. * - * ⚠️ Browser detection is asked of {@link isBuiltInLanguage} rather than of the - * merged resource map: a catalogue that has not been fetched yet is still a - * language this renderer can produce, and asking the map would have made - * detection depend on load order. + * The browser language is matched in this order: + * + * 1. an app pack in `config.resources` keyed by its base language (`zh` for a + * `zh-CN` browser) — the precedence an app's own pack has always had; + * 2. the built-in catalogue the WHOLE tag reads as through the platform locale + * table, answered as its table tag (`zh-TW` → `zh-Hant`, `zh-CN` → + * `zh-Hans`, `pt-BR` → `pt-BR`, `fr-CA` → `fr`). ⛔ Never the base language + * alone: `zh` cannot tell a Taiwan browser from a mainland one. + * + * ⚠️ Browser detection is asked of the registry rather than of the merged + * resource map: a catalogue that has not been fetched yet is still a language + * this renderer can produce, and asking the map would have made detection + * depend on load order. */ export function pickInitialLanguage(config: I18nConfig = {}): string { const { defaultLanguage = 'en', resources = {}, detectBrowserLanguage = true } = config; if (!detectBrowserLanguage || typeof navigator === 'undefined') return defaultLanguage; - const browserLang = navigator.language?.split('-')[0]; - if (!browserLang) return defaultLanguage; - const known = - isBuiltInLanguage(browserLang) || - Object.prototype.hasOwnProperty.call(resources, browserLang); - return known ? browserLang : defaultLanguage; + const browserTag = navigator.language; + if (!browserTag) return defaultLanguage; + const browserLang = browserTag.split('-')[0]; + if (browserLang && Object.prototype.hasOwnProperty.call(resources, browserLang)) return browserLang; + return resolveBuiltInLanguage(browserTag) ?? defaultLanguage; +} + +/** + * i18next's `fallbackLng`, read off the platform locale table's read aliases. + * + * The built-in catalogues are keyed by table tag (`zh-Hans`, `pt-BR`), while an + * instance's language is often a stored code (`zh`, `zh-CN`, `zh-TW`, `pt`): + * `sys_user.locale`, a cached seed, an app's `supportedLocales`. i18next's own + * hierarchy only walks a code DOWN (`zh-TW` → `zh`), never across to the tag it + * means, so each alias names its table tag as the first fallback, ahead of + * `fallbackLanguage`: `zh-TW` resolves `zh-TW`, `zh`, then `zh-Hant`, then + * `en`. A longer code reaches its entry through i18next's own lookup of this + * map (the whole code, its script part, then its language part: `pt-PT` → + * `pt` → `pt-BR`; `zh-Hant-TW` → `zh-Hant`). + * + * An object, not a function: i18next caches the resolved hierarchy for an + * object `fallbackLng` and recomputes it on every lookup for a function one. + */ +function fallbackLanguagesFor(fallbackLanguage: string): Record { + const fallbacks: Record = { default: [fallbackLanguage] }; + // A table tag falls back to `fallbackLanguage` alone. Listed so a longer tag + // stops at its script part: i18next asks this map for `zh-Hant` before `zh`, + // so `zh-Hant-TW` never borrows a missing key from the Simplified catalogue. + for (const tag of BUILT_IN_LANGUAGE_CODES) fallbacks[tag] = [fallbackLanguage]; + for (const [code, tag] of Object.entries(PLATFORM_LOCALE_READ_ALIASES)) { + fallbacks[code] = tag === fallbackLanguage ? [fallbackLanguage] : [tag, fallbackLanguage]; + } + return fallbacks; } /** @@ -298,7 +335,7 @@ export function createI18n(config: I18nConfig = {}): I18nInstance { // so the discovery fetch never runs and the splash never appears. instance.use(initReactI18next).use(cjkLatinSpacingProcessor).init({ lng, - fallbackLng: fallbackLanguage, + fallbackLng: fallbackLanguagesFor(fallbackLanguage), resources: mergedResources, postProcess: [CJK_LATIN_SPACING_PROCESSOR], interpolation: { diff --git a/ui/packages/i18n/src/index.ts b/ui/packages/i18n/src/index.ts index c951e1b549d..4c3c7b7ebf2 100644 --- a/ui/packages/i18n/src/index.ts +++ b/ui/packages/i18n/src/index.ts @@ -2,7 +2,8 @@ * @object-ui/i18n * * Internationalization (i18n) support for Object UI. - * Provides 10+ built-in language packs, RTL support, and date/currency formatting. + * Provides one built-in language pack per platform locale (`PLATFORM_LOCALES` + * in `@objectstack/spec/system`), RTL support, and date/currency formatting. * * @example * ```tsx @@ -10,7 +11,7 @@ * * function App() { * return ( - * + * * * * ); @@ -22,7 +23,7 @@ *
*

{t('common.save')}

* - * + * *
* ); * } @@ -85,8 +86,8 @@ export { useObjectLabel, useSafeFieldLabel } from './useObjectLabel.js'; // The record-named save message every record save surface toasts. export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMessage.js'; -// Locale packs — ⛔ ONE catalogue is statically re-exported here, not ten -// (objectui#7479). +// Locale packs — ⛔ ONE catalogue is statically re-exported here, not every +// pack (objectui#7479). // // This entry used to re-export all ten packs and `builtInLocales`, which made // every one of them statically reachable from any import of this package. The @@ -95,7 +96,7 @@ export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMess // one, and every new key paid for by every visitor in every language. // // ⛔ Do not restore a static re-export of a second catalogue here, and do not -// re-export `builtInLocales` from this entry. Both put all ten back: +// re-export `builtInLocales` from this entry. Both put every pack back: // `builtInLocales` names every pack in one object literal, so nothing can shake // it. `scripts/check-eager-locale-catalogues.mjs` fails the build if a // non-active catalogue reappears in the console's eager closure, and @@ -106,9 +107,12 @@ export { recordSaveMessage, type RecordSaveMessageInput } from './recordSaveMess // - `en` stays — it is `fallbackLng`, the source of `TranslationKeys`, and // the synchronous dictionary the app-shell splash renders from before i18n // is usable. See `./locales/registry.js` for the full argument. -// - `loadBuiltInLocale(code)` fetches any of the ten on demand. -// - `BUILT_IN_LANGUAGE_CODES` enumerates them at zero payload. -// - `@object-ui/i18n/locales` is the explicit, published door to all ten for +// - `loadBuiltInLocale(code)` fetches any pack on demand, and +// `resolveBuiltInLanguage(code)` names the pack a stored or browser code +// reads as (`zh-CN` → `zh-Hans`, `zh-TW` → `zh-Hant`). +// - `BUILT_IN_LANGUAGE_CODES` enumerates them at zero payload: the platform +// locale table's tags, in its menu order. +// - `@object-ui/i18n/locales` is the explicit, published door to every pack for // a caller that genuinely wants them all resident (the parity suites do). export { isRTL, RTL_LANGUAGES } from './locales/rtl.js'; export { @@ -118,6 +122,7 @@ export { isBuiltInLanguage, isBuiltInLocaleLoaded, loadBuiltInLocale, + resolveBuiltInLanguage, type LocaleCatalogue, } from './locales/registry.js'; export { default as en } from './locales/en.js'; diff --git a/ui/packages/i18n/src/locales/index.ts b/ui/packages/i18n/src/locales/index.ts index e97dfe0f4ba..f6f381d8744 100644 --- a/ui/packages/i18n/src/locales/index.ts +++ b/ui/packages/i18n/src/locales/index.ts @@ -1,5 +1,5 @@ /** - * @object-ui/i18n - Locale index — the explicit ALL TEN door. + * @object-ui/i18n - Locale index — the explicit ALL-PACKS door. * * ⛔ This module is NOT reachable from the package entry any more * (objectui#7479). Importing it pulls every catalogue this package ships — @@ -9,7 +9,7 @@ * * What it is still for, and the only two things it is for: * - * - the parity / drift suites, which must compare all ten packs at once and + * - the parity / drift suites, which must compare every pack at once and * run in Node, where a megabyte of translations costs nobody anything; * - an app that deliberately wants every catalogue resident, which reaches it * through the published `@object-ui/i18n/locales` subpath — a specifier @@ -18,49 +18,76 @@ * ⚠️ Anything on a page-load path belongs on `./registry.js`, not here. */ export { default as en, type TranslationKeys } from './en.js'; -export { default as zh } from './zh.js'; +// Export names are JavaScript identifiers, not pack keys: `zh` is the zh-Hans +// pack, `zhHant` the zh-Hant pack and `pt` the pt-BR pack. The KEYS — the table +// tags — are {@link builtInLocales}'. +export { default as zh } from './zh-Hans.js'; +export { default as zhHant } from './zh-Hant.js'; export { default as ja } from './ja.js'; export { default as ko } from './ko.js'; export { default as de } from './de.js'; export { default as fr } from './fr.js'; export { default as es } from './es.js'; -export { default as pt } from './pt.js'; +export { default as pt } from './pt-BR.js'; export { default as ru } from './ru.js'; export { default as ar } from './ar.js'; /** - * Map of all built-in locales keyed by language code + * Map of all built-in locales, keyed by platform locale table tag + * (`PLATFORM_LOCALES` in `@objectstack/spec/system`), in table order. + * + * ⛔ Keys are table tags, not the codes stored values carry: there is no `zh` + * or `pt` key. A reader holding a stored code (`zh-CN`, `pt`) finds its pack + * through `resolveBuiltInLanguage` (`./registry.js`), which reads the table's + * read aliases. */ import en from './en.js'; -import zh from './zh.js'; +import zh from './zh-Hans.js'; +import zhHant from './zh-Hant.js'; import ja from './ja.js'; import ko from './ko.js'; import de from './de.js'; import fr from './fr.js'; import es from './es.js'; -import pt from './pt.js'; +import pt from './pt-BR.js'; import ru from './ru.js'; import ar from './ar.js'; +import type { PlatformLocaleTag } from '@objectstack/spec/system'; -// Annotated rather than inferred: the inferred type spells all ten packs' -// literal types out inline in the emitted declaration, which outgrew what tsc -// will serialize (TS7056). Each `typeof ` is the same type, emitted as a -// reference. +// Annotated rather than inferred: the inferred type spells every pack's literal +// types out inline in the emitted declaration, which outgrew what tsc will +// serialize (TS7056). Each `typeof ` is the same type, emitted as a +// reference. `satisfies` holds the key set to the table: a tag added to the +// table without its pack here (or a pack here the table lacks) is a compile +// error. export const builtInLocales: { readonly en: typeof en; - readonly zh: typeof zh; - readonly ja: typeof ja; - readonly ko: typeof ko; - readonly de: typeof de; - readonly fr: typeof fr; + readonly 'zh-Hans': typeof zh; + readonly 'zh-Hant': typeof zhHant; readonly es: typeof es; - readonly pt: typeof pt; - readonly ru: typeof ru; readonly ar: typeof ar; -} = { en, zh, ja, ko, de, fr, es, pt, ru, ar } as const; + readonly fr: typeof fr; + readonly 'pt-BR': typeof pt; + readonly ru: typeof ru; + readonly de: typeof de; + readonly ja: typeof ja; + readonly ko: typeof ko; +} = { + en, + 'zh-Hans': zh, + 'zh-Hant': zhHant, + es, + ar, + fr, + 'pt-BR': pt, + ru, + de, + ja, + ko, +} as const satisfies Record; // Direction and the lazy registry live in payload-free modules so the runtime -// can reach them without reaching the ten packs above. Re-exported here so the +// can reach them without reaching the packs above. Re-exported here so the // historical import path keeps answering. export { RTL_LANGUAGES, isRTL } from './rtl.js'; export { @@ -70,6 +97,7 @@ export { isBuiltInLanguage, isBuiltInLocaleLoaded, loadBuiltInLocale, + resolveBuiltInLanguage, type LocaleCatalogue, } from './registry.js'; @@ -82,19 +110,19 @@ import { registerBuiltInLocale } from './registry.js'; * ⛔ Deliberately a CALL and not an import-time side effect: this package * declares `"sideEffects": false`, and a module that registered on import would * make that declaration false for every consumer's bundler. A caller that wants - * all ten resident says so. + * every pack resident says so. * * Who calls it: * * - a test that renders in a non-`en` locale and asserts SYNCHRONOUSLY. One - * line at module scope, next to the import that already paid for these ten + * line at module scope, next to the import that already paid for these * modules, is the shape AGENTS.md prescribes for a cost that belongs in the * import phase — ⛔ never a `beforeAll`, which is bounded by `hookTimeout` * and only moves the race; * - an app that deliberately ships every language resident and wants no * fetch at switch time. * - * ⚠️ It is NOT how a normal app boots. Calling it costs the whole ~1.8 MB of + * ⚠️ It is NOT how a normal app boots. Calling it costs the whole ~2 MB of * catalogues on the page-load path, which is the payload objectui#7479 removed. * `preloadBootstrapLocale()` — one catalogue, the active one — is that path. */ diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt-BR.ts similarity index 100% rename from ui/packages/i18n/src/locales/pt.ts rename to ui/packages/i18n/src/locales/pt-BR.ts diff --git a/ui/packages/i18n/src/locales/registry.ts b/ui/packages/i18n/src/locales/registry.ts index 4b7f10368b7..3376bed9ded 100644 --- a/ui/packages/i18n/src/locales/registry.ts +++ b/ui/packages/i18n/src/locales/registry.ts @@ -3,21 +3,39 @@ * * ## What this module is for * - * `locales/index.ts` names all ten packs in ten static `import` statements, so - * every module that reached ANY of them through the package entry reached all - * ten. The console measured that: one eagerly loaded chunk carrying ten - * catalogues of which a viewer reads one, and every new translation key paid + * `locales/index.ts` names every pack in a static `import` statement, so every + * module that reached ANY of them through the package entry reached all of + * them. The console measured that (ten packs at the time): one eagerly loaded + * chunk carrying every catalogue of which a viewer reads one, and every new translation key paid * for by every visitor in every language (objectui#7399's measurement, and the * per-chunk ceiling it ran out of). * * This module is the entry's replacement door. It holds: * - * - the ten language CODES, which cost nothing; + * - the language CODES, which cost nothing — the platform locale table's tags + * (`PLATFORM_LOCALE_TAGS` from `@objectstack/spec/system`, ruling F of + * objectstack#29349), not a list of this package's own; * - one static `import` — `en`, and only `en` (see below); - * - nine `() => import(...)` loaders, one per catalogue, each its own chunk; + * - one `() => import(...)` loader per other tag, each its own chunk; * - a synchronous registry of what has actually been loaded so far. * - * ## Why `en` is static and the other nine are not + * ## The table decides the set, and old codes keep resolving + * + * A catalogue is keyed by its table tag (`zh-Hans`, `zh-Hant`, `pt-BR`, …), and + * the loader map below is TYPED on the table: a tag added to (or removed from) + * `PLATFORM_LOCALES` without its catalogue is a compile error here, and + * `all-locales-key-parity.test.ts` reads the same table, so a language the + * Console lacks is red twice before it ships. + * + * The codes that stored values already carry (`sys_user.locale`, a cached seed, + * an `Accept-Language` answer, an app's `supportedLocales`) are not table tags: + * `zh`, `zh-CN`, `zh-TW`, `pt`, `ja-JP`, `en-US`. {@link resolveBuiltInLanguage} + * reads every code through the table's read aliases (`platformLocaleTagOf`) — + * the whole code first, then each shorter prefix — so `zh-CN` is served by + * `zh-Hans`, `zh-TW` and `zh-Hant-TW` by `zh-Hant`, `pt` and `pt-PT` by + * `pt-BR`, `de-AT` by `de`. ⛔ No second alias list lives in this package. + * + * ## Why `en` is static and the others are not * * ⛔ This is not a shortcut around the ask; it is what makes the ask safe, and * the card admits it in as many words ("no `packages/i18n/src/locales/*` module @@ -29,7 +47,7 @@ * define — the four `console.ai.*` outbound messages are exempted from * parity by `all-locales-key-parity.test.ts`, and a plural category no * pack enumerates is resolved the same way — renders through `en`. With no - * `en` bundle those render as the RAW KEY, in the UI, in nine languages. + * `en` bundle those render as the RAW KEY, in the UI, in every other language. * 2. `@object-ui/app-shell`'s `LoadingScreen` renders BEFORE i18n is usable * and on the server-down path where nothing else loads. It needs a * synchronous dictionary, and leaving it with English literals instead is @@ -37,19 +55,25 @@ * 3. A caller of the synchronous, published `createI18n()` that never awaits * anything must still get a usable instance. * - * So `en` is the documented resident fallback and the other nine are fetched on - * demand. What a `zh` viewer pays is `en` + `zh`, not ten. + * So `en` is the documented resident fallback and every other catalogue is + * fetched on demand. What a `zh-Hans` viewer pays is `en` + `zh-Hans`, not + * every pack. * * ## The loaders are spelled out, one per line, on purpose * * ⛔ Never collapse them into `import(`./${lang}.js`)`. A template literal makes * the specifier dynamic, and every bundler answers that by emitting EVERY file - * the pattern could match — which is the ten-catalogue closure again, now - * spelled in a way that reads lazy. Ten literal specifiers are ten chunks the - * bundler can name, and `apps/console/vite.config.ts` names them - * (`i18n-locale-`) so `scripts/check-eager-locale-catalogues.mjs` can + * the pattern could match — which is the all-catalogue closure again, now + * spelled in a way that reads lazy. One literal specifier per tag is one chunk + * the bundler can name, and `apps/console/vite.config.ts` names them + * (`i18n-locale-`) so `scripts/check-eager-locale-catalogues.mjs` can * weigh them in the BUILT bundle rather than in this source file. */ +import { + PLATFORM_LOCALE_TAGS, + platformLocaleTagOf, + type PlatformLocaleTag, +} from '@objectstack/spec/system'; import en from './en.js'; import type { TranslationKeys } from './en.js'; @@ -70,44 +94,48 @@ export type LocaleCatalogue = Readonly>; export const DEFAULT_BUILT_IN_LANGUAGE = 'en'; /** - * The nine catalogues fetched on demand — one literal specifier each, so each - * one is a chunk of its own. ⛔ Read the header before touching the spelling. + * The catalogues fetched on demand — one literal specifier each, so each one is + * a chunk of its own. ⛔ Read the header before touching the spelling. + * + * Typed on the platform locale table: every table tag but `en` must have a + * loader here and nothing else may, so the table and the packs cannot drift + * apart without a compile error. */ -const LAZY_LOCALE_LOADERS: Readonly Promise<{ default: LocaleCatalogue }>>> = - Object.freeze({ - zh: () => import('./zh.js'), - ja: () => import('./ja.js'), - ko: () => import('./ko.js'), - de: () => import('./de.js'), - fr: () => import('./fr.js'), - es: () => import('./es.js'), - pt: () => import('./pt.js'), - ru: () => import('./ru.js'), - ar: () => import('./ar.js'), - }); +const LAZY_LOCALE_LOADERS: Readonly< + Record, () => Promise<{ default: LocaleCatalogue }>> +> = Object.freeze({ + 'zh-Hans': () => import('./zh-Hans.js'), + 'zh-Hant': () => import('./zh-Hant.js'), + es: () => import('./es.js'), + ar: () => import('./ar.js'), + fr: () => import('./fr.js'), + 'pt-BR': () => import('./pt-BR.js'), + ru: () => import('./ru.js'), + de: () => import('./de.js'), + ja: () => import('./ja.js'), + ko: () => import('./ko.js'), +}); /** - * Every language code this package ships a catalogue for — the ten, in the - * order `builtInLocales` declared them, and with NO catalogue bytes behind the + * Every language code this package ships a catalogue for: the platform locale + * table's tags, in table (menu) order, with NO catalogue bytes behind the * list. This is what a language switcher enumerates. */ -export const BUILT_IN_LANGUAGE_CODES: readonly string[] = Object.freeze([ - DEFAULT_BUILT_IN_LANGUAGE, - ...Object.keys(LAZY_LOCALE_LOADERS), -]); +export const BUILT_IN_LANGUAGE_CODES: readonly string[] = Object.freeze([...PLATFORM_LOCALE_TAGS]); /** * Exact-then-base language-tag matching: `tag` itself when `has(tag)`, else * its base language (`zh-CN` → `zh`) when `has(base)`, else `null`. * - * The ONE implementation of this order in the package (objectui#11326). The - * registry asks it which catalogue serves a code, and `I18nProvider` asks it - * which language a cached seed boots in — each with its own `has`, so the two - * can never disagree about the ORDER while each keeps its own notion of what - * exists. + * A generic matcher over a caller's own set, for sets that are NOT the platform + * locale table: `I18nProvider` asks it which language a cached seed boots in + * (over the built-in packs plus `config.resources`), and the `studio` namespace + * which of its two catalogues serves a code. The built-in catalogues are + * matched by {@link resolveBuiltInLanguage}, which reads the table's read + * aliases first. * - * Exact first, so a catalogue keyed by a full tag (a `pt-BR` beside `pt`, a - * `zh-TW` beside `zh`) is never shadowed by its base language. + * Exact first, so a catalogue keyed by a full tag (a `pt-BR` beside `pt`) is + * never shadowed by its base language. */ export function matchLanguageTag(tag: string, has: (code: string) => boolean): string | null { if (has(tag)) return tag; @@ -125,29 +153,46 @@ function hasBuiltInCatalogue(code: string): boolean { } /** - * The code of the built-in catalogue that serves `lang`, or `null` when this + * The tag of the built-in catalogue that serves `lang`, or `null` when this * package ships none for it. * - * A region-tagged code is served by its base language's catalogue: the - * platform answers `zh-CN`, a language menu offers `zh-CN`, and the user's - * stored choice is `zh-CN`, while the catalogue is keyed `zh`. An exact match - * still wins — see {@link matchLanguageTag}. + * `lang` is read through the platform locale table's read aliases + * (`platformLocaleTagOf`, case-insensitive): a table tag answers itself and a + * stored code answers the tag it means — `zh`, `zh-CN`, `zh-SG` → `zh-Hans`; + * `zh-TW`, `zh-HK`, `zh-MO` → `zh-Hant`; `pt` → `pt-BR`; `ja-JP` → `ja`; + * `en-US` → `en`. A code the table does not name is retried one subtag shorter + * at a time (RFC 4647 lookup), so `zh-Hant-TW` is served by `zh-Hant`, + * `zh-Hans-CN` by `zh-Hans`, `de-AT` by `de` and `pt-PT` — through `pt` — by + * `pt-BR`. The script subtag is kept until last, which is what tells the two + * Chinese scripts apart: a region code cannot (Singapore writes Simplified, + * Hong Kong Traditional), so the read aliases name each one. */ export function resolveBuiltInLanguage(lang: string): string | null { - return matchLanguageTag(lang, hasBuiltInCatalogue); + if (typeof lang !== 'string') return null; + let candidate = lang; + while (candidate) { + const tag = platformLocaleTagOf(candidate); + if (tag !== undefined && hasBuiltInCatalogue(tag)) return tag; + const cut = candidate.lastIndexOf('-'); + if (cut <= 0) return null; + candidate = candidate.slice(0, cut); + } + return null; } /** - * Whether `@object-ui/i18n` ships a catalogue that serves `lang` — exactly, or - * through its base language (`zh-CN` → `zh`; see {@link resolveBuiltInLanguage}). + * Whether `@object-ui/i18n` ships a catalogue that serves `lang` — a table tag, + * a read alias of one, or a longer tag one of those prefixes (`zh-CN` → + * `zh-Hans`; see {@link resolveBuiltInLanguage}). */ export function isBuiltInLanguage(lang: string): boolean { return resolveBuiltInLanguage(lang) !== null; } /** - * The catalogues loaded so far. `en` is in here from module evaluation; each of - * the other nine appears once {@link loadBuiltInLocale} has resolved for it. + * The catalogues loaded so far, keyed by table tag. `en` is in here from module + * evaluation; each other catalogue appears once {@link loadBuiltInLocale} has + * resolved for it. */ const loaded: Record = { [DEFAULT_BUILT_IN_LANGUAGE]: en as LocaleCatalogue, @@ -157,7 +202,9 @@ const loaded: Record = { const inFlight = new Map>(); /** - * A synchronous snapshot of the catalogues this process has resolved. + * A synchronous snapshot of the catalogues this process has resolved, keyed by + * table tag (`zh-Hans`, not `zh-CN`): a reader holding a language code looks it + * up as `getLoadedBuiltInLocales()[resolveBuiltInLanguage(code)]`. * * ⚠️ A SNAPSHOT, not a live view: callers that render from it must re-read it * after `loadBuiltInLocale` resolves, or they will keep rendering the answer @@ -172,7 +219,7 @@ export function getLoadedBuiltInLocales(): Readonly 嵌入程式碼', + copyReact: '複製 React 程式碼片段', + editSharing: '編輯分享與提交後行為', + copiedUrl: '已複製 URL', + copiedIframe: '已複製 iframe 程式碼片段', + copiedReact: '已複製 React 程式碼片段', + clipboardUnavailable: '剪貼簿不可用', + invalidSlug: '短連結標識無效', + published: '已釋出 {{name}}', + publishFailed: '釋出失敗:{{message}}', + saved: '已儲存 {{name}}', + saveFailed: '儲存失敗:{{message}}', + publishDialogTitle: '釋出表單檢視', + publishDialogDescription: '選擇一個現有的表單檢視,向匿名訪客開放。釋出會在其配置中設定 {{enabled}}、{{allowAnonymous}} 和 {{publicLink}}:只有三者都設定時,公開表單路由才會提供該表單。', + formView: '表單檢視', + selectFormView: '— 選擇表單檢視 —', + urlSlug: 'URL 短連結標識', + slugHint: '僅限小寫字母、數字、連字元和下劃線。', + publishing: '正在釋出…', + publish: '釋出', + editTitle: '編輯 {{name}}', + editTitleFallback: '編輯表單', + editDescription: '配置公開 URL 以及提交後的行為。', + afterSubmit: '提交後', + behaviorThankYou: '顯示感謝面板', + behaviorRedirect: '重定向到某個 URL', + behaviorContinue: '重置以接受下一份回覆', + behaviorNextRecord: '前進到下一條記錄(內部佇列)', + thankYouTitle: '標題', + thankYouTitlePlaceholder: '謝謝!', + thankYouMessage: '訊息', + thankYouMessagePlaceholder: '我們已收到你的提交。', + redirectUrl: '重定向 URL', + redirectHint: '應用內路徑,以 {{slash}} 開頭——用 {{token}} 插入所提交記錄的欄位。若要讓瀏覽器離開應用,請改用應用導航項。', + saving: '正在儲存…', + }, + // objectui#13460 — Developer → Flow Runs (`FlowRunsPage`). + flowRuns: { + title: '流程執行', + subtitle: '選擇一個流程,用示例輸入觸發測試執行,並檢視最近的執行記錄', + flowTitle: '流程', + flowDescription: '選擇要測試的流程定義', + noFlows: '未找到流程定義。', + testRunTitle: '測試執行', + testRunDescription: '填寫輸入值並呼叫流程', + noInputs: '此流程未宣告輸入變數。點選「{{action}}」即可不帶引數呼叫。', + running: '執行中…', + runFlow: '執行流程', + failed: '失敗', + waitingForInput: '等待輸入', + result: '結果', + pausedAtScreen: '此執行已在介面 {{node}} 處暫停。', + continueRun: '繼續執行', + recentRuns: '最近執行', + noRuns: '暫無執行記錄。請使用「{{panel}}」面板呼叫此流程。', + columnRun: '執行', + columnStatus: '狀態', + columnStarted: '開始時間', + columnDuration: '耗時', + runTitle: '執行 {{id}}', + stepInput: '輸入', + stepOutput: '輸出', + stepError: '錯誤', + // A client that lacks the method a panel calls (#25916). + clientMethodUnavailable: '此客戶端不支援 {{method}}', + }, + // objectui#13460 — the Integrations page's "Connect an AI agent" card (`AgentConnectSection`). + agentConnect: { + title: '連線 AI 智慧體(MCP)', + description: '本系統中的每個應用都是可供智慧體使用的工具集。連線 Claude、Cursor、Codex 或任意 MCP 客戶端——一次連線即可覆蓋你的所有應用(新應用會自動出現)。', + endpoint: 'MCP 端點', + disabledTitle: '本系統未啟用 MCP', + disabledBody: '連線智慧體需要主動開啟。管理員可通過設定 {{flag}} 啟用。你仍可以在下方生成金鑰並下載技能檔案。', + copyMcpUrl: '複製 MCP URL', + apiKey: 'API 金鑰', + keyNamePlaceholder: '金鑰名稱(如 Claude 桌面版)', + generating: '生成中…', + generateKey: '生成金鑰', + keyHint: '金鑰以你的身份執行(你的權限 + 行級安全)。僅顯示一次——請立即複製。可隨時在 API 金鑰列表中撤銷。', + noKeyInResponse: '請求已成功,但響應中沒有 API 金鑰。傳輸過程沒有出錯——請檢查 {{route}} 的響應體。', + requestFailed: '請求失敗({{status}})', + generateFailed: '生成金鑰失敗', + skillTitle: '智慧體技能(可移植)', + skillHint: '適用於任何支援技能的智慧體(Claude、Codex、Gemini、Cursor…)。', + stepsTitle: '三步完成連線', + step1: '新增一個指向上方端點的遠端 MCP 伺服器。', + step2: '設定請求頭 {{header}}(會話 Bearer 令牌不是 API 金鑰)。', + step3: '放入 {{file}},讓智慧體知道如何操作你的資料。', + newKeyTitle: '你的新 API 金鑰', + newKeyDescription: '請立即複製——{{notice}}', + newKeyNotice: '出於安全考慮,它不會再次顯示。', + sendAs: '請以 {{header}} 傳送。它攜帶你的權限——請像密碼一樣保管,洩露後請立即撤銷。', + }, + }, + system: { + aiApprovals: { + title: 'AI 審批', + subtitle: '審閱 AI 智慧體提議的操作。批准後在服務端執行;拒絕則把原因發回模型,以便它在下一輪修正。', + queueTitle: '佇列', + queueDescription: '每 5 秒輪詢一次。決定會以你的身份記錄。', + }, + // objectui#13460 — System → Audit Log (`AuditLogPage`). + auditLog: { + title: '審計日誌', + subtitle: '只讀的系統活動與記錄變更,最新的在前。', + openAsList: '以列表開啟', + openPresetAsList: '以列表開啟 {{label}}', + presets: '預設', + presetOtherSource: '此檢視讀取的是其他資料來源。', + presetFilterUnsupported: '此檢視的篩選條件無法在此應用:{{message}}', + action: '操作', + allActions: '全部操作', + object: '物件', + anyObject: '任意物件', + searchObjects: '搜尋物件…', + loadingObjects: '正在載入物件…', + noObjects: '未找到物件。', + actor: '操作者', + anyUser: '任意使用者', + from: '起始日期', + to: '截止日期', + clearFilters: '清除篩選({{n}})', + loadFailed: '載入審計日誌失敗', + emptyTitle: '暫無審計記錄', + emptyFiltered: '請嘗試清除篩選條件或擴大日期範圍。', + emptyUnfiltered: '被跟蹤的物件發生修改後,事件會顯示在這裡。', + timestamp: '時間', + page: '第 {{page}} 頁', + pageEnd: '第 {{page}} 頁 · 已到末尾', + eventFallback: '審計事件', + ipAddress: 'IP 地址', + tenant: '租戶', + targetRecord: '目標記錄', + userAgent: '使用者代理', + changePayload: '變更內容', + before: '變更前', + after: '變更後', + metadata: '中繼資料', + system: '系統', + }, + }, + }, + auth: { + login: { + title: '登入您的賬戶', + description: '輸入您的郵箱和密碼以繼續', + emailLabel: '郵箱', + emailPlaceholder: 'name@example.com', + passwordLabel: '密碼', + passwordPlaceholder: '輸入您的密碼', + forgotPasswordText: '忘記密碼?', + submitButton: '登入', + submittingButton: '登入中…', + noAccountText: '還沒有賬戶?', + signUpText: '註冊', + signingIn: '正在登入…', + ssoHandoff: '繼續前往 {{target}}', + devAdminHint: { + title: '本地開發實例', + signIn: '以開發管理員登入', + }, + errors: { + invalidCredentials: '郵箱或密碼錯誤,請重試。', + emailNotVerified: '請先驗證您的郵箱後再登入。', + oauthCallbackFailed: '單點登入未能完成——登入連結已過期或已被使用,請重新發起登入。', + twoFactorInvalidCode: '驗證碼無效,請檢查身份驗證器應用後重試。', + twoFactorInvalidBackupCode: '該備用碼無效或已被使用。', + twoFactorTooManyAttempts: '錯誤次數過多,請重新使用密碼登入。', + twoFactorExpired: '本次登入已過期,請重新使用密碼登入。', + twoFactorLocked: '失敗次數過多,您的賬戶已被暫時鎖定,請稍後再試。', + }, + // objectui#3546 slice three — phone/OTP sign-in. `{seconds}` is LoginForm's own + // literal `.replace()` hole, not i18next interpolation: keep the single braces. + emailOrPhoneLabel: '郵箱或手機號', + emailOrPhonePlaceholder: 'name@example.com 或 +1 555 000 0000', + phoneLabel: '手機號', + phonePlaceholder: '+1 555 000 0000', + otpCodeLabel: '驗證碼', + otpCodePlaceholder: '6 位驗證碼', + sendOtpButton: '獲取驗證碼', + resendOtpCountdownText: '{seconds} 秒後可重新發送', + usePhoneOtpText: '使用驗證碼登入', + usePasswordSignInText: '改用密碼登入', + socialButton: '使用 {provider} 繼續', + orText: '或使用郵箱繼續', + ssoButton: '使用 SSO 登入', + ssoOnlyDescription: '使用你所在組織的單點登入進行登入', + noSignInMethodText: '當前沒有可用的登入方式,請聯絡管理員。', + usePasswordText: '改用密碼登入', + backToSsoText: '返回單點登入', + twoFactorTitle: '雙重驗證', + twoFactorDescription: '請輸入身份驗證器應用中的 6 位驗證碼。', + twoFactorBackupDescription: '請輸入您設定雙重驗證時儲存的任意一個備用碼。', + twoFactorCodeLabel: '驗證碼', + twoFactorBackupCodeLabel: '備用碼', + twoFactorVerifyButton: '驗證', + twoFactorVerifyingButton: '正在驗證…', + twoFactorUseBackupCodeText: '改用備用碼', + twoFactorUseAuthenticatorText: '改用身份驗證器應用', + twoFactorBackText: '返回登入', + // objectui#12600 — the console login card's own header, the password + // field's reveal toggle, and the footer's legal links. + welcomeTitle: '歡迎回來', + welcomeDescription: '登入後繼續你的工作', + invitationTitle: '接受邀請', + invitationDescription: '請使用收到邀請的郵箱。', + invitationSignUpButton: '註冊並加入', + invitationSignInDivider: '或登入已有賬號', + invitationPreviewTitle: '{{inviter}} 邀請你加入 {{organization}}', + invitationPreviewTitleNoInviter: '你受邀加入 {{organization}}', + invitationPreviewDescription: '請使用收到邀請的郵箱({{email}})。', + invitationGoneTitle: '此邀請連結已失效', + invitationGoneDescription: '邀請可能已過期、已被使用或已被撤回。請聯絡邀請你的人重新發送邀請。', + invitationGoneSignIn: '前往登入', + invitationLinkHint: '收到了邀請?請開啟邀請郵件中的連結接受邀請。', + showPasswordText: '顯示密碼', + hidePasswordText: '隱藏密碼', + footer: { + privacy: '隱私', + terms: '條款', + }, + }, + register: { + title: '建立賬戶', + description: '建立賬號,開始搭建。', + invitationTitle: '註冊賬號並加入', + invitationDescription: '請使用收到邀請的郵箱註冊,邀請只能由該郵箱接受。', + invitationPreviewDescription: '請使用收到邀請的郵箱({{email}})註冊,邀請只能由該郵箱接受。', + nameLabel: '姓名', + namePlaceholder: '張三', + emailLabel: '郵箱', + emailPlaceholder: 'name@example.com', + passwordLabel: '密碼', + passwordPlaceholder: '建立密碼(至少8個字元)', + confirmPasswordLabel: '確認密碼', + confirmPasswordPlaceholder: '確認您的密碼', + passwordMismatchError: '兩次輸入的密碼不一致', + passwordTooShortError: '密碼至少需要8個字元', + submitButton: '建立賬戶', + submittingButton: '建立中…', + hasAccountText: '已有賬戶?', + signInText: '登入', + socialButton: '使用 {provider} 註冊', + orText: '或使用郵箱繼續', + captchaRetry: '重試', + errors: { + userExists: '該郵箱已被註冊,請直接登入或更換郵箱。', + selfRegistrationClosed: '本系統未開放自助註冊,請聯絡管理員獲取邀請。', + captchaFailed: '安全驗證未完成,請重試。', + emailDomainNotAllowed: '該郵箱的域名不允許在此註冊,請使用組織郵箱,或聯絡管理員獲取邀請。', + invitationSentTo: '此邀請傳送至 {{email}},請使用該郵箱註冊以接受邀請。', + invitationAddressOnly: '此邀請只能由收到邀請的郵箱接受,請使用該郵箱註冊。', + }, + verifyInbox: { + title: '請檢查您的郵箱', + description: '我們已向 {{email}} 傳送了一封驗證郵件,請點選郵件中的連結啟用賬戶。', + resend: '重新發送驗證郵件', + resending: '傳送中…', + resent: '驗證郵件已傳送。', + backToSignIn: '返回登入', + }, + }, + forgotPassword: { + title: '重置密碼', + description: '輸入您的郵箱地址,我們將傳送重置密碼連結', + emailLabel: '郵箱', + emailPlaceholder: 'name@example.com', + submitButton: '傳送重置連結', + submittingButton: '傳送中…', + successTitle: '請檢視您的郵箱', + successDescription: '我們已將密碼重置連結傳送至 {email},請檢查您的收件箱。', + backToSignInText: '返回登入', + rememberPasswordText: '記住密碼了?', + signInText: '登入', + // objectui#3546 slice three — the SMS reset branch of the same form. + phoneLabel: '手機號', + phonePlaceholder: '+1 555 000 0000', + otpCodeLabel: '驗證碼', + otpCodePlaceholder: '6 位驗證碼', + sendOtpButton: '獲取驗證碼', + resendOtpCountdownText: '{seconds} 秒後可重新發送', + newPasswordLabel: '新密碼', + newPasswordPlaceholder: '輸入新密碼', + resetButton: '重置密碼', + usePhoneResetText: '通過簡訊驗證碼重置', + useEmailResetText: '改用郵箱重置', + phoneSuccessTitle: '密碼已重置', + phoneSuccessDescription: '您的密碼已重置,現在可以使用新密碼登入了。', + noDeliveryTitle: '此處無法通過郵件傳送重置連結', + noDeliveryDescription: '此部署不傳送郵件,因此密碼重置連結無法送達。請聯絡管理員:管理員可以在“設定 → 使用者”中為你設定新密碼。', + }, + setPassword: { + title: '設定一個備用密碼', + description: '您是通過單點登入進入的。設定一個本地密碼,這樣即使單點登入不可用,您仍能直接登入。', + email: '郵箱', + newPassword: '新密碼', + confirmPassword: '確認密碼', + submit: '設定密碼', + submitting: '儲存中…', + success: '本地密碼已設定', + failed: '無法設定密碼', + passwordsMismatch: '兩次輸入的密碼不一致', + noSession: '您的會話已過期。', + backToSignIn: '重新登入', + }, + resetPassword: { + title: '設定新密碼', + description: '請選擇一個您未使用過的密碼。', + newPassword: '新密碼', + confirmPassword: '確認密碼', + submit: '更新密碼', + submitting: '更新中…', + success: '密碼已更新', + failed: '重置失敗', + invalidToken: '此重置連結無效或已過期。', + missingToken: '重置連結缺失或已過期', + passwordsMismatch: '兩次輸入的密碼不一致', + requestNewLink: '獲取新連結', + }, + verifyEmail: { + title: '驗證您的郵箱地址', + description: '我們已向您的郵箱傳送了一封驗證郵件,請點選郵件中的連結以驗證您的賬戶。', + sentTo: '傳送至:', + verifyingTitle: '驗證中…', + verifyingDescription: '正在確認您的郵箱,請稍候。', + successTitle: '郵箱已驗證', + successDescription: '您的郵箱已確認,現在可以登入了。', + handoffSuccessDescription: '您的郵箱已確認。請回到您正在登入的應用,在那裡登入。', + errorTitle: '驗證失敗', + errorDescription: '驗證失敗,請重新獲取驗證連結。', + missingToken: '驗證連結缺少令牌。', + emailMissing: '缺少郵箱地址', + resendButton: '重新發送驗證郵件', + resending: '傳送中…', + resent: '郵件已傳送!請檢視收件箱', + resentSuccess: '驗證郵件已傳送!', + resentDescription: '請檢視收件箱並點選驗證連結。', + resendFailed: '無法重新發送驗證郵件', + signInLink: '前往登入', + backToSignIn: '返回登入', + backToLogin: '返回登入', + checkSpam: '沒有收到郵件?請檢查垃圾郵件資料夾或聯絡支援。', + or: '或', + // objectui#3546 slice three. + resendUnavailable: '本系統可能未配置郵件傳送服務。如果問題持續存在,請聯絡支援。', + }, + setup: { + welcomeTitle: '歡迎使用 ObjectStack', + description: '建立第一個所有者賬戶以完成本次部署的初始化。', + yourName: '您的姓名', + orgName: '組織名稱', + orgNamePlaceholder: 'Acme Inc.', + emailLabel: '郵箱', + emailPlaceholder: 'name@example.com', + passwordLabel: '密碼', + passwordHint: '至少 8 個字元', + submit: '建立所有者賬戶', + submitting: '設定中…', + failed: '設定失敗', + }, + device: { + title: '授權新裝置', + subtitle: '批准此裝置以 {{email}} 的身份登入。', + userCodeLabel: '裝置程式碼', + requesterLabel: '連線請求來自', + approveWarning: '僅當這是你剛剛自己發起的連線時才批准。批准後,該執行環境將能訪問你所在組織的私有軟體包。', + loggedInAs: '已以 {{email}} 身份登入', + approve: '批准裝置', + approving: '批准中…', + approvedTitle: '裝置已授權', + approvedDescription: '您可以返回該裝置,它應該很快就會登入。', + approveSuccess: '裝置已授權', + approveSuccessDescription: '您可以關閉此視窗。', + approveFailed: '批准失敗', + deny: '拒絕請求', + denying: '拒絕中…', + deniedTitle: '訪問被拒絕', + deniedDescription: '該裝置將不會被授予訪問權限。', + denyFailed: '拒絕請求失敗', + invalidTitle: '無效的裝置連結', + invalidDescription: 'URL 中未提供裝置程式碼。', + loading: '載入中…', + cancel: '取消', + // objectui#3546 slice three — device-authorization plugin switched off. + disabledTitle: '未啟用裝置授權', + disabledDescription: '此部署未啟用裝置授權,因此無法在此批准該裝置。', + }, + shell: { + tenantHostHint: '您正在登入本系統', + poweredBy: '由 ObjectStack 提供技術支援', + }, + layout: { + headline: '更快地構建強大的業務應用。', + subhead: '面向企業資料管理、工作流與分析的統一平台。', + }, + remediation: { + signOut: '改為退出登入', + password: { + title: '您的密碼已過期', + resetTitle: '您必須修改密碼', + fallbackMessage: '請設定新密碼後繼續。', + resetBody: '管理員為您設定了臨時密碼。請設定您自己的新密碼後繼續。', + current: '當前密碼', + temporary: '臨時密碼', + next: '新密碼', + confirm: '確認新密碼', + submit: '修改密碼並繼續', + submitting: '正在更新…', + mismatch: '兩次輸入的新密碼不一致。', + sameAsTemporary: '請設定一個與臨時密碼不同的新密碼。', + wrongPassword: '你輸入的密碼不正確,請檢查後重試。', + failed: '無法修改密碼。', + }, + mfa: { + title: '設定雙重驗證', + fallbackMessage: '您的組織要求使用身份驗證器應用後方可繼續。', + confirmPassword: '確認您的密碼', + continue: '繼續', + preparing: '正在準備…', + enrollFailed: '無法開始繫結。', + unavailable: '此部署未開啟雙重驗證,暫時無法繫結身份驗證器。請聯絡管理員在“設定 → 認證”中開啟後再試。', + scanTitle: '使用身份驗證器掃碼', + scanBody: '使用 Google Authenticator、1Password、Authy 等應用掃描此二維碼,然後輸入 6 位驗證碼。', + backupTitle: '儲存您的備用碼', + backupBody: '請妥善保管——裝置丟失時每個備用碼可使用一次。', + codeLabel: '6 位驗證碼', + verify: '驗證並繼續', + verifying: '正在驗證…', + invalidCode: '驗證碼無效,請重試。', + }, + impersonation: { + passwordTitle: '{{user}} 必須修改密碼', + mfaTitle: '{{user}} 必須設定雙重驗證', + body: '您正在以 {{user}} 的身份操作,此操作只能由該使用者本人完成。請結束模擬登入,返回您自己的賬戶。', + }, + }, + }, + // objectui#3546 slice three — the /oauth/consent screen. + oauth: { + consent: { + title: '{{appName}} 請求訪問您的賬戶', + request: '{{appName}} 正在請求授權{{suffix}}。', + unknownApp: '某個應用', + unidentified: '無法識別此應用。', + clientId: '客戶端 ID', + willAllow: '此應用將能夠:', + scope: { + openid: '確認您的身份', + profile: '讀取您的基本資料(姓名、頭像)', + email: '讀取您的郵箱地址', + offlineAccess: '保持登入狀態(重新整理訪問權限)', + dataRead: '讀取記錄(不超出您自己的權限)', + dataWrite: '建立、修改和刪除記錄(不超出您自己的權限)', + actionsExecute: '代表您執行操作(不超出您自己的權限)', + metadataWrite: '以草稿形式構建和修改此應用的中繼資料(不超出您自己的權限),由人來發布', + unrecognised: '無法識別的權限:{{scope}}', + }, + deny: '拒絕', + authorize: '授權', + submitting: '授權中…', + granted: '已授予訪問權限', + denied: '訪問被拒絕', + noRedirect: '伺服器未返回跳轉地址。', + failed: '授權失敗', + footer: '您可以隨時在賬戶設定中撤銷訪問權限。', + }, + }, + profile: { + title: '個人資料', + subtitle: '管理您的賬戶設定', + saving: '儲存中…', + avatar: { + upload: '上傳', + replace: '更換', + remove: '移除', + }, + info: { + title: '個人資訊', + description: '更新您的姓名並檢視賬戶詳情', + saved: '個人資料已更新。', + name: '姓名', + email: '郵箱', + emailImmutable: '郵箱不可修改。', + role: '角色', + save: '儲存更改', + }, + language: { + title: '語言', + description: '傳送給您的通知和訊息所使用的語言。介面語言另行設定,在地球圖示選單中切換。', + label: '首選語言', + systemDefault: '使用部署預設值', + save: '儲存', + saved: '語言偏好已更新。', + readOnly: '您賬戶的語言由管理員管理。', + }, + appLock: { + title: "應用鎖", + off: "已關閉", + onImmediately: "已開啟,離開應用後立即鎖定", + onAfter: "已開啟,離開應用{{duration}}後鎖定", + }, + password: { + changeTitle: '修改密碼', + setTitle: '設定本地密碼', + changeDescription: '更新您登入所用的密碼。', + setDescription: '您通過單點登入進入。設定本地密碼後,也可以使用郵箱和密碼登入。', + current: '當前密碼', + new: '新密碼', + password: '密碼', + confirm: '確認密碼', + tooShort: '密碼長度至少為 8 個字元', + mismatch: '兩次輸入的密碼不一致', + enterCurrent: '請輸入當前密碼', + changed: '密碼已修改。', + localSet: '本地密碼已設定。現在您可以使用郵箱和密碼登入。', + changeAction: '修改密碼', + setAction: '設定密碼', + }, + }, + errors: { + unauthorized: '您沒有權限執行此操作。', + forbidden: '訪問被拒絕。', + unknown: '發生未知錯誤。', + }, + refusalDetails: { + more: '更多', + less: '收起', + copy: '複製詳情', + copied: '已複製', + }, + workspace: { + label: '組織', + default: '我的組織', + switch: '切換組織', + createTitle: '建立組織', + createDescription: '組織是團隊協作的共享空間。', + createButton: '建立組織', + nameLabel: '組織名稱', + namePlaceholder: '例如:極客科技', + slugLabel: 'URL 標識', + slugHint: '用於 URL 中,僅支援小寫字母、數字和連字元。', + settings: '組織設定', + multiOrgDisabled: '此實例已停用建立新組織。', + createFailed: '建立組織失敗', + }, + help: { + keyboardShortcuts: '鍵盤快捷鍵', + onThisPage: '本頁目錄', + appDocs: '本應用文件', + allDocs: '全部文件', + onlineDocs: '線上文件', + loadingDocumentation: '正在載入文件', + about: '關於', + aboutTitle: '關於此控制台', + aboutDescription: '此控制台編譯所用的構建。', + aboutBuild: '控制台構建', + aboutBuildUnknown: '未知構建(構建此控制台時未設定版本)', + }, + sidebar: { + helpTooltip: '幫助與文件', + activityFeed: '活動動態', + notifications: '通知', + approvals: '審批', + inbox: '訊息中心', + inboxAriaLabel: '開啟訊息中心', + area: '區域', + packageManagement: '軟體包管理', + searchNavigation: '搜尋導航…', + recent: '最近使用', + starred: '已收藏', + removeFromFavorites: '從收藏中移除 {{name}}', + allReports: '全部報表', + keepOpen: '保持側邊欄展開', + }, + topbar: { + aiAssistant: 'AI 助手', + designInStudio: '在 Studio 中設計', + editApp: '編輯應用', + openAppInStudio: '在 Studio 中檢視應用', + openAssistant: '開啟{{name}}助手', + offline: '離線', + usersOnline: '當前線上使用者', + switchView: '切換檢視', + connection: { + connecting: '連線中…', + reconnecting: '正在重新連線…', + disconnected: '已斷開連線', + error: '連線錯誤', + }, + // 產品名旁的平台階段徽章(PreviewBadge)。 + stage: { + preview: '預覽版', + beta: '測試版', + tooltip: '本平台處於預覽版,功能可能調整。', + }, + }, + home: { + nav: '首頁', + yourApps: '我的應用', + showMoreApps: '更多', + showLess: '收起', + continueEmpty: '你開啟過的內容會顯示在這裡。', + // objectui#12602 — Home's "My work" section: its heading, the link to the + // full inbox, each column's title, "view all" link and empty line. + myWork: { + title: "我的工作", + openInbox: "開啟收件箱", + viewAll: "檢視全部", + approvals: "待審批", + approvalsEmpty: "沒有待審批的事項", + notifications: "通知", + notificationsEmpty: "沒有未讀通知", + }, + greetingMorning: '早上好', + greetingAfternoon: '下午好', + greetingEvening: '晚上好', + greetingNight: '夜深了', + greetingSeparator: ',', + greetingEnd: '。', + search: '搜尋應用、記錄、頁面…', + summary: { + approvals: '今天有 {{count}} 項待審批', + approvals_one: '今天有 {{count}} 項待審批', + approvals_other: '今天有 {{count}} 項待審批', + none: '今天沒有待辦', + }, + addApp: '新增應用', + appOpened: '{{time}}開啟', + build: { + title: '構建應用', + noCapability: '本系統不提供構建應用功能 — 它需要「管理中繼資料」權限,而你的賬號沒有該權限。', + inStudio: '在 Studio 中新建', + }, + template: { + title: '從模板開始', + marketplaceDisabled: '本執行環境未配置應用市場,因此這裡沒有可安裝的模板。', + }, + open: '開啟', + loading: '載入中…', + welcome: '用 AI 搭建你的業務系統', + welcomeAdminDescription: '用一句話描述你的業務,AI 會為你生成物件、介面、API 和智慧體工具;也可以從左側選單自行搭建。', + welcomeAdminDescriptionNoBuild: '從左側的「管理」選單建立你的第一個應用。有了資料後,AI 助手可以幫你查詢和分析。', + welcomeAdminDescriptionNoAi: '從左側的「管理」選單建立你的第一個應用。', + noAppsTitle: '還沒有應用', + noAppsDescription: '系統正在準備中——管理員共享給你的應用會顯示在這裡。', + buildWithAI: '用 AI 搭建', + askAI: '問 AI', + recoveryReminder: { + message: '建議設定一個備用密碼,這樣即使單點登入不可用,你仍能直接登入。', + cta: '設定密碼', + dismiss: '關閉', + }, + pendingDrafts: { + // objectui#12602 — the drafts row in Home's "My work" section: the + // count, then the first draft names joined by `nameSeparator`, in + // their package when they share one. + title: "{{count}} 項未釋出更改", + title_one: "{{count}} 項未釋出更改", + title_other: "{{count}} 項未釋出更改", + inPackage: "{{names}} 在 {{package}} 包裡等待發布", + waiting: "{{names}} 等待發布", + nameSeparator: "、", + cta: '釋出', + publishing: '釋出中…', + published: '已釋出!更改已生效。', + publishFailed: '釋出失敗', + // objectui#3546 切片六 —— usePublishAllDrafts 其餘幾條 toast。 + // seedWarn 與 console.ai.seedWarn 同串,直接沿用該鍵既有譯文。 + nothing: '沒有需要釋出的內容。', + probeWarn: '已釋出,但驗證發現了問題。', + seedWarn: '已釋出,但部分示例資料未能載入。', + publishedVerified: '已釋出並驗證 —— {{count}} 行示例資料已生效。', + capabilityWarn: '編寫檢查:{{count}} 個能力引用無法解析。', + }, + systemSettings: '系統設定', + browseMarketplace: '瀏覽應用市場', + quickActions: { + title: '快捷操作', + systemSettings: '系統設定', + systemSettingsDesc: '配置您的系統', + }, + recentApps: { + title: '最近訪問', + itemType: { + object: '物件', + dashboard: '儀表盤', + page: '頁面', + report: '報表', + package: '軟體包', + view: '列表檢視', + record: '記錄', + metadata: '中繼資料', + }, + }, + starredApps: { + title: '收藏', + }, + gettingStarted: { + description: '收藏一個應用即可固定到此處,一鍵直達。開啟過的內容會自動出現在「最近訪問」中。', + }, + appCard: { + noDescription: '暫無描述', + default: '預設', + }, + }, + layout: { + appSwitcher: { + switchApplication: '切換應用', + appsAvailable: '共 {{count}} 個可用應用', + allApps: '所有應用', + editAppInStudio: '在 Studio 中編輯 {{app}}', + openAppInStudio: '在 Studio 中檢視 {{app}}', + backToHome: '返回首頁', + settings: '系統設定', + productHome: '{{product}} 首頁', + }, + systemNav: { + systemSettings: '系統設定', + applications: '應用', + appMarketplace: '應用市場', + users: '使用者', + organizations: '組織', + roles: '角色', + configuration: '配置', + administration: '管理', + datasources: '資料來源', + documentation: '文件', + }, + activityFeed: { + title: '最近動態', + filter: '篩選', + empty: '暫無最近動態', + accessDenied: '您沒有檢視動態的權限。', + viewAll: '檢視全部動態', + ariaLabel: '動態訊息', + typeCreate: '建立', + typeUpdate: '更新', + typeDelete: '刪除', + typeComment: '評論', + typeSystem: '系統', + relativeJustNow: '剛剛', + relativeSecondsAgo: '{{count}} 秒前', + relativeMinutesAgo: '{{count}} 分鐘前', + relativeHoursAgo: '{{count}} 小時前', + relativeDaysAgo: '{{count}} 天前', + }, + metadata: { + label: '中繼資料', + toggleTitle: '切換中繼資料檢視器', + panelTitle: '中繼資料檢視器', + jsonBadge: 'JSON', + copyJson: '複製 JSON', + }, + }, + search: { + title: '搜尋', + back: '返回', + placeholder: '搜尋物件、儀表盤、頁面、報表…', + inputAriaLabel: '搜尋物件、儀表盤、頁面、報表', + resultsCount: '找到 {{count}} 條與“{{query}}”相關的結果', + resultsCount_one: '找到 {{count}} 條與“{{query}}”相關的結果', + resultsCount_other: '找到 {{count}} 條與“{{query}}”相關的結果', + itemsAvailable: '共 {{count}} 項可搜尋', + itemsAvailable_one: '共 {{count}} 項可搜尋', + itemsAvailable_other: '共 {{count}} 項可搜尋', + noResults: '未找到結果', + noResultsHint: '請嘗試調整搜尋關鍵字', + typeObjects: '物件', + typeDashboards: '儀表盤', + typePages: '頁面', + typeReports: '報表', + badgeObject: '物件', + badgeDashboard: '儀表盤', + badgePage: '頁面', + badgeReport: '報表', + }, + empty: { + objectNotFound: '未找到物件', + objectNotFoundDescription: '物件 “{{name}}” 的定義不存在。請檢查配置或返回選擇有效的物件。', + interfacePageSourceMissing: '此介面頁引用了 “{{name}}”,但該來源不可用。', + recordNotFound: '未找到記錄', + recordNotFoundDescription: '您查詢的記錄不存在、已被刪除,或您沒有訪問權限。', + recordAccessDenied: '無權訪問{{object}}記錄', + recordAccessDeniedDescription: '你沒有檢視此類記錄的權限。如需訪問,請聯絡管理員。', + recordLoadFailed: '無法載入此記錄', + recordLoadFailedDescription: '載入時出錯。請檢查網路連線後重試。', + recordAccessEnded: '你的審批決定已記錄', + recordAccessEndedDescription: '你的審批環節已結束,對此記錄的訪問權限也隨之結束。', + pageNotFound: '未找到頁面', + pageNotFoundDescription: '未找到頁面 “{{name}}”,可能已被刪除或重新命名。', + dashboardNotFound: '未找到儀表盤', + dashboardNotFoundDescription: '未找到儀表盤 “{{name}}”,可能已被刪除或重新命名。', + reportNotFound: '未找到報表', + reportNotFoundDescription: '未找到報表 “{{name}}”,可能已被刪除或重新命名。', + noAppsConfigured: '尚未配置應用', + noAppsConfiguredDescription: '當前沒有任何已註冊的應用。請建立您的第一個應用,或前往系統設定進行配置。', + appNotAvailable: '應用不可用', + appNotAvailableDescription: '此應用不可用 —— 請稍後重試。', + appNotFound: '無法開啟此應用', + appNotFoundDescription: '伺服器未返回你的賬號可訪問的此應用。', + appUnreachable: '無法連線到伺服器', + appUnreachableDescription: '無法檢查此應用。請稍後重試。', + appAccessDenied: '你沒有訪問此應用的權限', + appAccessDeniedDescription: '此應用存在,但你的賬號未獲授權開啟它。請聯絡管理員為你開通訪問權限。', + appAccessDeniedHome: '返回首頁', + appNothingAvailable: '{{app}} 中暫時沒有你可以使用的內容', + appNothingAvailableDescription: '此應用中沒有你的賬號可以開啟的頁面。請聯絡管理員為你開通訪問權限。', + appNothingAvailableGrantDescription: '此應用中沒有你的賬號可以開啟的頁面。你可以在 Setup 中授予訪問權限。', + appNoReadableObjectDescription: '你的賬號暫時無法讀取此應用中的任何記錄。請聯絡管理員為你分配權限集。', + appNoReadableObjectGrantDescription: '你的賬號暫時無法讀取此應用中的任何記錄。你可以在 Setup 中授予訪問權限。', + appOpensFromMenu: '{{app}} 的內容都在選單裡', + appOpensFromMenuDescription: '此應用沒有自己的頁面。請在選單中選擇一個條目,開啟連結或執行操作。', + openSetup: '開啟 Setup', + createFirstApp: '建立您的第一個應用', + systemSettings: '系統設定', + back: '返回', + }, + preview: { + empty: { + loadFailedTitle: '草稿預覽載入失敗', + loadFailedDescription: '無法讀取草稿資料。請重試,或檢查網路連線。', + notReadyTitle: '“{{app}}” 尚未進入草稿', + notReadyDescription: '構建可能仍在進行,或在該應用落稿前失敗了。請回到對話檢視構建狀態 —— 草稿就緒後此視窗會自動重新整理。', + nothingTitle: '暫無可預覽的內容', + retry: '重試', + }, + draftBar: { + message: '草稿預覽 —— 您看到的是未釋出的更改,釋出前不會生效。', + messageClean: '草稿預覽 —— 沒有未釋出的更改,此處內容均已生效。', + publish: '釋出', + publishing: '釋出中…', + exit: '退出預覽', + changes: '變更', + pendingHere: '草稿預覽 · 本頁 {{count}} 項未釋出', + pendingHere_one: '草稿預覽 · 本頁 {{count}} 項未釋出', + pendingHere_other: '草稿預覽 · 本頁 {{count}} 項未釋出', + // 魔法時刻提示:剛搭好的草稿因為種子/示例資料要釋出後才載入,預覽裡 + // 儀表盤顯示 0、列表顯示“暫無內容”。直接說明,避免把空預覽誤當成搭壞了。 + sampleDataTitle: '示例資料將在釋出後出現', + sampleDataBody: '您正在預覽應用的結構。釋出後即可載入示例資料並正式上線。', + publishCta: '釋出以檢視實際效果', + }, + changes: { + title: '待發布的變更', + description: '釋出將帶來以下變更。“新增”為全新內容,“更新”將覆蓋線上版本。', + savedToCustomization: '儲存到 {{package}} 的定製', + customizationEnvironment: '這些變更儲存在當前環境的資料庫裡,是 {{pair}} 的條目,不在專案原始碼中;只有通過 {{command}} 才能帶到其他環境。', + loading: '正在載入待發布變更…', + loadFailed: '無法載入待發布變更:', + empty: '沒有待發布的內容 —— 所有草稿均已釋出。', + kindNew: '新增', + kindUpdate: '更新', + detailLoading: '正在載入詳情…', + detailLoadFailed: '無法載入變更詳情:', + detailNone: '未檢測到差異 —— 草稿與已釋出版本一致。', + fieldOrderChanged: '欄位順序已變更', + // objectstack#27176 — who drafted part of this item: the copilot, or an agent through MCP. + draftedByCopilot: '包含由 AI 副駕起草的更改', + draftedByMcpClient: '包含由 MCP 客戶端「{{client}}」起草的更改', + draftedByMcp: '包含由 MCP 客戶端起草的更改', + detailChangedKeys: '其他變更:', + detailNewKeys: '包含:', + detailNewItem: '釋出後將新增此項。', + generatedGroup: '平台生成({{n}})', + confirmNote: '釋出將一次性發布此包全部 {{count}} 個待發布草稿:要麼全部生效,要麼都不生效。', + confirmNote_one: '釋出將一次性發布此包全部 {{count}} 個待發布草稿:要麼全部生效,要麼都不生效。', + confirmNote_other: '釋出將一次性發布此包全部 {{count}} 個待發布草稿:要麼全部生效,要麼都不生效。', + // objectui#11591 — the package-less sheet: no package to batch, so each + // draft publishes by itself and one that fails stays pending. + confirmNoteSeparate: '釋出會逐個單獨釋出這 {{count}} 個待發布草稿:釋出失敗的草稿仍保持待發布,其餘草稿照常釋出。', + confirmNoteSeparate_one: '釋出會逐個單獨釋出這 {{count}} 個待發布草稿:釋出失敗的草稿仍保持待發布,其餘草稿照常釋出。', + confirmNoteSeparate_other: '釋出會逐個單獨釋出這 {{count}} 個待發布草稿:釋出失敗的草稿仍保持待發布,其餘草稿照常釋出。', + publishConfirm: '全部發布', + noteLabel: '變更說明(可選)', + notePlaceholder: '改了什麼、為什麼改。會顯示在每一項的歷史記錄裡。', + summaryItems: '{{count}} 項', + summaryItems_one: '{{count}} 項', + summaryItems_other: '{{count}} 項', + summaryType: '{{type}} {{n}}', + typeCount: { + object: '物件 {{count}}', + object_one: '物件 {{count}}', + object_other: '物件 {{count}}', + view: '檢視 {{count}}', + view_one: '檢視 {{count}}', + view_other: '檢視 {{count}}', + page: '頁面 {{count}}', + page_one: '頁面 {{count}}', + page_other: '頁面 {{count}}', + dashboard: '儀表盤 {{count}}', + dashboard_one: '儀表盤 {{count}}', + dashboard_other: '儀表盤 {{count}}', + app: '應用 {{count}}', + app_one: '應用 {{count}}', + app_other: '應用 {{count}}', + action: '動作 {{count}}', + action_one: '動作 {{count}}', + action_other: '動作 {{count}}', + report: '報表 {{count}}', + report_one: '報表 {{count}}', + report_other: '報表 {{count}}', + flow: '流程 {{count}}', + flow_one: '流程 {{count}}', + flow_other: '流程 {{count}}', + permission: '權限集 {{count}}', + permission_one: '權限集 {{count}}', + permission_other: '權限集 {{count}}', + agent: '智慧體 {{count}}', + agent_one: '智慧體 {{count}}', + agent_other: '智慧體 {{count}}', + email_template: '郵件模板 {{count}}', + email_template_one: '郵件模板 {{count}}', + email_template_other: '郵件模板 {{count}}', + object_extension: '物件擴充 {{count}}', + object_extension_one: '物件擴充 {{count}}', + object_extension_other: '物件擴充 {{count}}', + dataset: '資料集 {{count}}', + dataset_one: '資料集 {{count}}', + dataset_other: '資料集 {{count}}', + hook: '鉤子 {{count}}', + hook_one: '鉤子 {{count}}', + hook_other: '鉤子 {{count}}', + translation: '翻譯 {{count}}', + translation_one: '翻譯 {{count}}', + translation_other: '翻譯 {{count}}', + }, + // objectui#12929 — the unchecked choice under an added object field whose + // literal default the batch publish can write into the existing records. + backfillOption: '補預設值({{count}} 條已有記錄)', + backfillOption_one: '補預設值({{count}} 條已有記錄)', + backfillOption_other: '補預設值({{count}} 條已有記錄)', + backfillOptionNoCount: '補預設值', + // [`7a90afdf9`] Pre-publish security-posture findings, shown next to + // the confirm button so a refusal the door would issue is read BEFORE + // the click rather than as a toast after the batch rolled back. + securityBlockTitle: '釋出會被拒絕 —— 有 {{count}} 項需要先做出決定', + securityBlockTitle_one: '釋出會被拒絕 —— 有 {{count}} 項需要先做出決定', + securityBlockTitle_other: '釋出會被拒絕 —— 有 {{count}} 項需要先做出決定', + securityBlockWhere: '請在物件的“設定 → 記錄共享模型”中修改後重新發布。', + }, + // ADR-0045 — the materialized-but-unlisted app banner + // (UnpublishedAppBar.tsx), sibling of draftBar above. + unpublishedBar: { + message: '未釋出的應用 —— 功能完整,但只有構建者能看到。釋出後即可讓您的使用者看到。', + publish: '釋出', + publishing: '釋出中…', + published: '已釋出!應用現在對您的使用者可見。', + publishFailed: '釋出失敗', + }, + // ADR-0067 — the append-only build/revert timeline (CommitTimeline.tsx); + // `button` is the banner's entry point into it. + history: { + button: '歷史', + title: '構建歷史', + description: '此應用的每一次變更,最新的在最前。撤銷任一步驟即可回退 —— 無需確認釋出。', + loadFailed: '無法載入歷史記錄:', + loadFailedUnavailable: + '提交儲存暫時不可達 —— 本次讀取並未發生,因此不顯示任何歷史記錄。請稍後重試。', + loading: '正在載入歷史記錄…', + empty: '此應用暫無歷史記錄。', + revertLabel: '已撤銷一處變更', + applyLabel: '構建變更', + revert: '撤銷', + items: '{{count}} 項', + revertAction: '撤銷', + reverted: '已撤銷 —— 該變更已還原。', + revertFailed: '撤銷失敗', + revertUnavailable: + '提交儲存暫時不可達 —— 此次撤銷可能尚未生效。請重新開啟歷史記錄確認後再重試。', + }, + }, + actionDialog: { + title: '操作引數', + description: '請填寫以下資訊以繼續操作。', + selectPlaceholder: '請選擇 {{label}}', + requiredError: '{{label}} 為必填項', + lookupPlaceholder: '{{label}} 的記錄 ID', + lookupHelpText: '該引數未配置引用物件,無法使用記錄選擇器。請直接填寫記錄 ID,或聯絡管理員修正該動作引數。', + unresolvedParam: '無法顯示該引數:它所依據的欄位不在物件中繼資料中,因此無法構建它所需的控制項。請聯絡管理員修正該動作定義。', + carryOverHint: '原樣帶入(只讀)', + cancel: '取消', + confirm: '確認', + uploading: '上傳中…', + }, + actionConfirm: { + title: '確認操作', + confirm: '繼續', + cancel: '取消', + }, + navigationSync: { + addedPage: '導航已更新:已新增頁面 “{{name}}”', + addedDashboard: '導航已更新:已新增儀表盤 “{{name}}”', + removedPage: '導航已更新:已移除頁面 “{{name}}”', + removedDashboard: '導航已更新:已移除儀表盤 “{{name}}”', + renamedPage: '導航已更新:頁面 “{{oldName}}” 已重新命名為 “{{newName}}”', + renamedDashboard: '導航已更新:儀表盤 “{{oldName}}” 已重新命名為 “{{newName}}”', + undoLabel: '撤銷', + undone: '已撤銷導航變更', + undoFailed: '撤銷導航變更失敗', + updateFailed: '更新導航失敗', + }, + objectActions: { + deleteSuccess: '{{label}} 刪除成功', + deletedNamed: '{{label}}“{{name}}”已刪除', + deleteFailed: '刪除 {{label}} 失敗', + noRecordId: '未提供記錄 ID', + deleteConfirm: '確定要刪除此記錄嗎?此操作無法撤銷。', + deleteConfirmTitle: '刪除{{label}}“{{name}}”?', + bulkDeleteConfirmTitle: '刪除 {{count}} 條 {{label}} 記錄?', + bulkDeleteConfirmTitle_one: '刪除 {{count}} 條 {{label}} 記錄?', + bulkDeleteConfirmTitle_other: '刪除 {{count}} 條 {{label}} 記錄?', + deleteConfirmButton: '刪除', + deleteCascade: '同時會刪除以下物件中的相關記錄:{{objects}}。', + bulkDeleteSuccess: '已刪除 {{count}} 條 {{label}} 記錄', + bulkDeleteSuccess_one: '已刪除 {{count}} 條 {{label}} 記錄', + bulkDeleteSuccess_other: '已刪除 {{count}} 條 {{label}} 記錄', + bulkDeletePartial: '成功 {{succeeded}} 條,失敗 {{failed}} 條', + }, + objectViewActions: { + renameFailed: '重新命名檢視失敗', + deleteFailed: '刪除檢視失敗', + }, + dashboardActions: { + printDialogOpening: '正在開啟瀏覽器列印對話方塊(不是匯出 PDF)', + exportFailed: '匯出失敗:{{message}}', + forecastSoon: '預測檢視即將上線', + }, + user: { + // objectui#13461 — the top bar's avatar button, which holds only the avatar. + accountMenu: '賬戶選單', + profile: '個人資料', + logout: '退出登入', + preferences: '偏好設定', + theme: '主題', + language: '語言', + }, + organizations: { + mine: '我的組織', + create: '建立組織', + title: '組織', + heading: '您的組織', + subtitle: '選擇一個組織以繼續,或新建一個。', + searchPlaceholder: '搜尋組織', + new: '新建組織', + current: '當前組織', + manage: '管理', + emptyTitle: '尚無組織', + emptyDescription: '建立您的第一個組織以開始使用。', + noMatches: '沒有匹配的組織。', + }, + organization: { + roles: { + owner: '所有者', + admin: '管理員', + delegatedAdmin: '受託管理員', + member: '成員', + external: '外部成員', + }, + errors: { + notAllowedToInvite: '您無權邀請使用者加入該組織。', + notAllowedToInviteWithRole: '您無權以該角色邀請使用者。', + alreadyInvited: '該使用者已被邀請加入本組織。', + organizationExists: '該組織已存在。', + slugTaken: '該標識已被佔用。', + notAllowedToCreate: '您無權建立新組織。', + notTheRecipient: '您不是該邀請的收件人。', + invitationNotFound: '該邀請已失效或已過期。', + unknown: '出了點問題,請重試。', + }, + backToList: '返回組織列表', + notFound: '未找到組織', + notFoundDescription: '該組織不存在,或您沒有訪問權限。', + tabs: { + members: '成員', + invitations: '邀請', + settings: '設定', + }, + members: { + title: '成員', + inviteMember: '邀請成員', + inviteRestrictedNote: '只有組織管理員可以邀請成員。', + inviteManagedNote: '成員通過你在雲端的組織加入,請在那裡邀請。', + removeMember: '移除成員', + removeConfirmTitle: '移除成員?', + removeConfirmDescription: '將把 {{name}} 從該組織中移除,其訪問權限會立即失效。', + removeConfirmAction: '移除', + memberRemoved: '成員已移除', + removeFailed: '移除成員失敗', + roleUpdated: '角色已更新', + roleUpdateFailed: '更新角色失敗', + memberActions: '成員操作', + loadFailed: '載入成員失敗', + }, + invitations: { + title: '邀請', + empty: '暫無邀請。', + expiresAt: '過期時間', + linkCopied: '邀請連結已複製', + copyFailed: '複製連結失敗', + canceled: '邀請已取消', + cancelFailed: '取消邀請失敗', + cancelTitle: '取消邀請?', + cancelDescription: '將撤銷發給 {{email}} 的邀請。', + cancelAction: '取消邀請', + keepAction: '保留邀請', + inviteTitle: '邀請成員', + inviteDescription: '對方將收到加入該組織的邀請。', + emailLabel: '郵箱', + roleLabel: '角色', + placementLabel: '任職安排(可選)', + placementDescription: '在邀請被接受時生效。僅列出您有權分派的業務單元和崗位。', + businessUnitLabel: '業務單元', + businessUnitPlaceholder: '不指定任職', + positionsLabel: '崗位', + sendInvite: '傳送邀請', + sentTitle: '邀請已建立', + sentDescription: '請將下方連結傳送給受邀人。對方需要登入後才能接受。', + linkLabel: '接受連結', + invitedAs: '{{email}} 已以 {{role}} 身份受邀', + copyLinkLabel: '複製邀請連結', + loadFailed: '載入邀請失敗', + inviteFailed: '邀請成員失敗', + status: { + all: '全部', + pending: '等待中', + accepted: '已接受', + rejected: '已拒絕', + canceled: '已取消', + }, + }, + settings: { + generalTitle: '常規', + generalDescription: '更新組織資訊。', + readOnlyNote: '只有所有者可以修改設定。', + nameLabel: '組織名稱', + slugLabel: '標識', + slugLockedNote: '該組織還有正在使用的環境,因此無法在此修改標識:重新命名會同時遷移這些環境的子域名。', + logoLabel: 'Logo', + logoUpload: '上傳', + logoReplace: '更換', + logoClear: '移除', + logoUploaded: 'Logo 已上傳,儲存後生效', + logoUploadFailed: '上傳 Logo 失敗', + save: '儲存更改', + saved: '設定已儲存', + saveFailed: '儲存設定失敗', + leaveTitle: '退出組織', + leaveDescription: '退出後您將失去對該組織的訪問權限。', + leaveAction: '退出', + leaveConfirmTitle: '退出組織?', + leaveConfirmDescription: '確定要退出 {{name}} 嗎?您的訪問權限會立即失效。', + leaveConfirmAction: '退出', + leftOrg: '您已退出該組織', + leaveFailed: '退出組織失敗', + dangerZone: '危險操作', + deleteTitle: '刪除組織', + deleteDescription: '永久刪除該組織及其全部資料。', + deleteAction: '刪除', + deleteConfirmTitle: '刪除組織?', + deleteConfirmDescription: '此操作不可撤銷,所有資料將被永久刪除。請輸入組織標識以確認。', + deleteConfirmSlugLabel: '輸入“{{slug}}”以確認', + deleteConfirmAction: '刪除組織', + deleted: '組織已刪除', + deleteFailed: '刪除組織失敗', + }, + accept: { + title: '您收到一份邀請', + description: '您受邀以 {{role}} 身份加入 {{orgName}}。', + errorTitle: '邀請不可用', + goToOrgs: '前往組織列表', + loading: '正在載入邀請…', + organization: '組織', + role: '角色', + expiresAt: '過期時間', + invitedBy: '邀請人', + accept: '接受邀請', + accepted: '邀請已接受', + joinedDescription: '歡迎加入 {{orgName}},您的身份是 {{role}}。', + continue: '繼續', + acceptFailed: '接受邀請失敗', + decline: '拒絕', + declined: '邀請已拒絕', + declineFailed: '拒絕邀請失敗', + }, + current: { + label: '當前組織', + }, + switcher: { + label: '切換組織', + groupLabel: '當前工作組織', + groupHint: '新建記錄將歸屬該組織。檢視會顯示您所有組織的資料。', + manageMembers: '管理成員', + }, + }, + notifications: { + regionLabel: '通知', + empty: '暫無通知', + emptyUnread: '已讀完所有通知', + filterUnread: '未讀', + filterAll: '全部', + markAllRead: '全部標記為已讀', + viewAll: '檢視全部通知', + groupCount: '{{count}} 條通知', + groupUnread: '{{count}} 條未讀', + groupUnread_one: '{{count}} 條未讀', + groupUnread_other: '{{count}} 條未讀', + groupMarkRead: '標記已讀', + groupMarkReadTitle: '將此類通知全部標記為已讀', + approvalsPending: '{{count}} 條待審批', + approvalsPending_one: '{{count}} 條待審批', + approvalsPending_other: '{{count}} 條待審批', + viewApprovals: '檢視審批', + noPendingApprovals: '暫無待審批事項', + openApprovalsInbox: '開啟審批中心', + badgeTotal: '共 {{total}} 項', + badgeNotifications: '{{count}} 個未讀主題', + badgeNotifications_one: '{{count}} 個未讀主題', + badgeNotifications_other: '{{count}} 個未讀主題', + badgeApprovals: '{{count}} 條待審批', + badgeApprovals_one: '{{count}} 條待審批', + badgeApprovals_other: '{{count}} 條待審批', + arrivalMany: '{{count}} 條新訊息', + arrivalMany_one: '{{count}} 條新訊息', + arrivalMany_other: '{{count}} 條新訊息', + arrivalRepeats: '該主題有 {{count}} 條新訊息', + arrivalRepeats_one: '該主題有 {{count}} 條新訊息', + arrivalRepeats_other: '該主題有 {{count}} 條新訊息', + arrivalOpen: '檢視', + toastEnabled: '站內提醒', + desktopEnabled: '桌面通知', + desktopBlocked: '已被攔截。請在瀏覽器設定中允許本站傳送通知。', + desktopUnsupported: '當前瀏覽器不支援桌面通知。', + }, + // objectui#13460 — the share-link dialog (`@object-ui/components` `ShareDialog`). + shareDialog: { + title: '分享{{label}}', + thisRecord: '此記錄', + description: '生成一個任何人(或指定人員)都能用來開啟此記錄的連結。連結可隨時撤銷。', + whoCanAccess: '誰可以訪問', + audienceLinkOnly: '任何擁有連結的人', + audienceLinkOnlyHelp: '無需登入', + audienceSignedIn: '已登入使用者', + audienceSignedInHelp: '需登入後才能檢視', + expiresIn: '有效期', + expiry1d: '1 天', + expiry7d: '7 天', + expiry30d: '30 天', + expiry90d: '90 天', + expiryNever: '永不過期', + password: '密碼(可選)', + passwordPlaceholder: '開啟時需要輸入密碼', + label: '標籤(僅你可見)', + labelPlaceholder: '如:已發給設計評審', + createLink: '建立連結', + existingLinks: '已有連結', + existingLinksCount: '已有連結({{n}})', + noLinks: '暫無分享連結。請在上方建立一個。', + revokedHeading: '已撤銷', + revokedBadge: '已撤銷', + copy: '複製', + copied: '已複製!', + revoke: '撤銷', + loadFailed: '載入分享連結失敗', + createFailed: '建立分享連結失敗', + revokeFailed: '撤銷連結失敗', + }, + // objectui#13460 — the public landing of a `/s/:token` share link (`SharedRecordPage`). + sharedRecord: { + signInTitle: '需要登入', + signInBody: '所有者僅將此連結分享給已登入使用者。', + signIn: '登入', + passwordTitle: '需要密碼', + passwordBody: '所有者為此連結設定了密碼。', + passwordLabel: '密碼', + passwordPlaceholder: '輸入密碼', + continue: '繼續', + cannotOpen: '無法開啟此連結', + passwordUnsendable: '此密碼包含無法傳送的字元,例如中文字元或表情符號。請讓連結所有者設定不含這些字元的密碼。', + wrongPassword: '密碼錯誤。', + loadFailedStatus: '載入失敗(HTTP {{status}})。', + invalidLink: '此連結無效或已不可用。', + expiredLink: '此連結已過期或已被撤銷。', + loadFailed: '載入分享內容失敗。', + sharedConversation: '分享的對話', + loadingMessages: '正在載入訊息…', + noMessages: '此對話暫無訊息。', + sharedObject: '分享的 {{object}}', + redactedFields: '部分欄位已被所有者隱藏:{{fields}}', + }, + publicForm: { + submit: '提交', + submitting: '提交中…', + poweredBy: '由 ObjectStack 提供技術支援', + thankYouTitle: '感謝您的提交!', + thankYouMessage: '我們已成功收到您的資訊。', + redirecting: '將在 {{seconds}} 秒後跳轉…', + redirectPending: '正在跳轉…', + loading: '正在載入表單…', + requiredFields: '必填項:{{fields}}', + }, + connectAgent: { + disabled: { + title: '此部署已停用 MCP', + body: 'MCP 服務已關閉(OS_MCP_SERVER_ENABLED=false),暫無可連線的端點。請聯絡運維人員重新啟用。', + }, + url: { + title: '本系統的 MCP 端點', + body: '任何支援 MCP 的 AI 客戶端都用這個 URL 連線。身份是自助的:部署自身就是 OAuth 2.1 授權伺服器,互動式客戶端只需在瀏覽器登入——以你自己的身份連線,每次呼叫都受你的權限和行級安全約束。', + downloadSkill: '下載 SKILL.md', + skillHint: '可移植的智慧體技能檔案:教會任何支援技能的智慧體如何使用本系統。', + }, + claude: { + body: '設定 → 聯結器 → 新增自定義聯結器,貼上此 URL,按提示在瀏覽器登入。', + reachability: 'claude.ai(網頁版)由 Anthropic 伺服器發起連線——部署需公網 HTTPS 可達。Claude Desktop 等本地客戶端可直連內網部署。', + }, + claudeCode: { + body: '一條命令,首次使用時自動彈出 OAuth 登入:', + plugin: '或安裝官方外掛(附帶 ObjectStack 技能和引導式 /objectstack:connect 命令):', + }, + cursor: { + addButton: '新增到 Cursor', + body: '點選一鍵按鈕,或將以下內容加入 .cursor/mcp.json:', + }, + fields: { + name: '名稱', + transport: '傳輸型別', + url: '伺服器 URL', + }, + codex: { + body: '在 Codex 客戶端中:設定 → MCP 伺服器 → 新增伺服器,按下表填寫:', + hint: '儲存後按提示重啟,在 objectstack 旁點選「Authenticate」並在瀏覽器登入。在輸入框中輸入 /mcp 可檢視是否已連線。如果你的版本不支援瀏覽器登入,請新增請求頭 Authorization: Bearer (在下方建立)。', + }, + workbuddy: { + body: '騰訊 WorkBuddy:聯結器 → 自定義聯結器 → 配置 MCP,把以下條目合併進 mcpServers(配置儲存在 ~/.workbuddy/mcp.json):', + hint: '儲存後在 objectstack 聯結器上點選「信任」——未信任前不會生效。如果你的版本沒有彈出登入,請新增請求頭 Authorization: Bearer (在下方建立)。', + }, + doubao: { + title: '豆包工作', + body: '技能 · 聯結器 → 新建 → 新建自定義聯結器,按下表填寫:', + hint: '按提示在瀏覽器登入。若未出現登入,在自定義 Headers 中新增 Authorization: Bearer (在下方建立)。雲端執行的任務要求部署可通過公網 HTTPS 訪問。', + }, + qwenwork: { + title: '千問辦公', + body: '擴充 → 聯結器 → + 新增 → 貼上 JSON 配置:', + hint: '也可手動新增:傳輸型別選 Streamable HTTP 並填寫此 URL。如果你的版本不支援瀏覽器登入,請新增請求頭 Authorization: Bearer (在下方建立)。', + }, + apiKey: { + title: 'API 金鑰', + badge: '無頭場景', + body: '用於 CI、腳本和無瀏覽器的智慧體。金鑰等同於你的身份,請像密碼一樣保管,且僅顯示一次。', + namePlaceholder: '金鑰名稱(如 ci-agent)', + mint: '建立金鑰', + confirmTitle: '建立 API 金鑰「{{name}}」?', + minting: '建立中…', + showOnce: '金鑰「{{name}}」已建立——請立即複製,之後不再顯示:', + done: '完成——隱藏', + }, + }, + excelImport: { + noImportableFields: '該物件沒有可匯入的欄位。', + schemaReadFailed: '無法讀取物件結構。', + imported: '已把 {{count}} 行真實資料匯入「{{object}}」。', + imported_one: '已把 {{count}} 行真實資料匯入「{{object}}」。', + imported_other: '已把 {{count}} 行真實資料匯入「{{object}}」。', + importFrom: '把真實資料匯入自', + into: '到', + opening: '開啟中…', + importAction: '匯入', + dismiss: '忽略', + }, + cloudWorkspaceTimezoneNotice: { + seeded: '環境時區在建立時已按你的瀏覽器設為 {{zone}},可在「設定 → 本地化」中修改。', + }, + aiApprovals: { + title: 'AI 審批', + description: 'AI 智慧體提出的操作,需要人工稽核後才會執行。', + tabPending: '待審批', + tabDecided: '已處理', + tabAll: '全部', + statusPending: '待審批', + statusApproved: '已通過', + statusExecuted: '已執行', + statusFailed: '失敗', + statusRejected: '已拒絕', + colTool: '工具', + colAction: '動作', + colObject: '物件', + colStatus: '狀態', + colProposed: '提出時間', + colDecision: '決定', + emptyTitle: '暫無待處理操作', + emptyDescription: '當 AI 提出敏感操作時,會顯示在這裡等待稽核。', + view: '檢視', + approve: '通過', + reject: '拒絕', + working: '處理中…', + approveAndExecute: '通過並執行', + outcomeApprove: '對 {{id}} 的通過操作:{{message}}', + outcomeReject: '對 {{id}} 的拒絕操作:{{message}}', + outcomeExecuteFailed: '操作執行失敗', + drawerFallbackTitle: '待處理操作', + drawerSubtitle: '在 {{object}} 上執行工具 {{tool}}', + fieldProposedBy: '提出者', + fieldDecidedBy: '處理人', + fieldConversation: '會話', + fieldToolInput: '工具輸入', + fieldResult: '結果', + fieldError: '錯誤', + fieldRejectionReason: '拒絕原因', + rejectTitle: '要拒絕此操作嗎?', + rejectBody: '該原因會回傳給 AI,便於它調整下一步回覆。', + rejectPlaceholder: '可選原因(例如:“記錄 ID 有誤 — 請先與使用者確認。”)', + }, + aiModelStatus: { + summary: '構建 / 提問使用 {{conversational}}({{conversationalSource}});結構化使用 {{structured}}({{structuredSource}})。', + summaryRouting: '路由策略:免費方案 → {{free}},付費方案 → {{paid}}。', + modelUnknown: '未知', + sourceUnknown: '配接器未上報', + rowConversational: 'Build / Ask 模型', + rowStructured: '結構化(藍圖 / 種子)', + rowReasoning: '推理強度', + readFailed: '無法讀取有效 AI 模型。本系統可能未執行 AI 服務,或你沒有 ai:read 權限。', + readFailedWithStatus: '無法讀取有效 AI 模型(HTTP {{status}})。本系統可能未執行 AI 服務,或你沒有 ai:read 權限。', + overridesInEffect: '生效的 env 覆蓋:', + noOverrides: '無 —— 跑的是部署程式碼的預設值。', + adapter: '配接器:', + sourceCodeDefault: '程式碼預設(無 env 覆蓋)', + sourceInherits: '與 build/ask 相同', + sourcePinned: '被 {{source}} 釘住', + }, + // objectui#7254 — the AI copilot's tool cards. Three families, all of them + // English-only until this landed while every other string on the same screen + // was translated: + // + // `tool.*` — one entry per PLATFORM-PROVIDED tool name + // (@objectstack/spec `PLATFORM_TOOLS_BY_PACKAGE`, the + // closed registry those runtimes are conformance-tested + // against). `humanizeToolName` looks each up as + // `chatbot.tool.` and falls back to its English + // title-caser for a custom / third-party tool, so a name + // missing here is degraded, never broken. Every value, `en` + // included, is a short sentence in the pack's language + // saying what the step does (cloud#2927 Target 3, ruled + // 「同意」; objectui#12724): never the tool id, and never + // the title-caser's output for it, which is how the step + // reads for a tool the pack has no entry for. + // `toolLabels-locale-parity-7481` holds both. + // `toolState.*` — the card-header badge + activity-chip vocabulary. ONE + // set for both surfaces (they used to carry separate + // tables and disagreed on casing). + // `plan.*` — the "N objects · N views · N dashboards" strip. Plural + // FAMILIES: one slot per CLDR category the pack's language + // selects (objectui#11432), plus the base key for a call + // made without a count. `all-locales-key-parity.test.ts` + // derives the required slots from `Intl.PluralRules`. + // + // objectui#7481 — five `tool.*` entries are NEWER than the registry above: + // `get_authoring_rules` (cloud#1837), `load_tools`, `open_record`, `test_flow` + // and `toggle_flow` are registered by cloud `service-ai-studio` but the pinned + // `@objectstack/spec` snapshot does not list them yet. They are kept here on + // purpose: a step label the user reads must not wait on a pin bump. When the + // pin advances, `chatbotToolLabels-locale-parity-7481` starts checking them + // against the registry instead of against its own hand-held list. + chatbot: { + // objectui#13461 — the composer's send button and the scroll-to-latest button. + sendMessage: '傳送訊息', + scrollToBottom: '滾動到最新訊息', + answer: { + showFull: '展開全文', + collapse: '收起', + expandTable: '展開', + exitExpandedTable: '關閉', + }, + tool: { + aggregate_data: '彙總相關資料', + get_record: '讀取這條記錄', + query_data: '查詢所需資料', + query_records: '查詢相關記錄', + read_package_doc: '閱讀應用文件', + search_knowledge: '在知識庫中查詢', + search_package_docs: '在應用文件中查詢', + visualize_data: '把資料畫成圖表', + add_field: '給物件新增欄位', + apply_blueprint: '按方案搭建應用', + apply_edit: '應用這次修改', + create_metadata: '新建一項中繼資料', + create_object: '新建一個物件', + create_package: '新建一個應用包', + create_record: '新建記錄', + create_seed: '為物件生成示例資料', + delete_field: '刪除一個欄位', + describe_metadata: '檢視中繼資料的內容', + describe_object: '檢視物件的結構', + get_active_package: '確認當前應用包', + get_authoring_rules: '查閱編寫規範', + get_metadata_schema: '查閱中繼資料的格式', + get_package: '檢視應用包資訊', + list_metadata: '列出已有中繼資料', + list_objects: '列出已有物件', + list_packages: '列出已有應用包', + load_tools: '載入更多工具', + modify_field: '修改一個欄位', + open_record: '開啟這條記錄', + propose_blueprint: '擬定應用方案', + remove_sample_data: '清理示例資料', + set_active_package: '切換到目標應用包', + suggest_builder: '建議改用 AI 搭建', + test_flow: '試執行流程', + todo_write: '記下後續檢查項', + toggle_flow: '啟用或停用流程', + update_metadata: '更新一項中繼資料', + update_record: '修改記錄', + validate_expression: '檢查表達式是否有效', + verify_build: '核對搭建結果', + }, + toolState: { + agentActivity: '智慧體活動', + pending: '等待中', + running: '執行中', + awaitingApproval: '待確認', + responded: '已回覆', + completed: '已完成', + error: '出錯', + denied: '已拒絕', + failed: '失敗', + }, + phase: { + build: '搭建', + design: '設計方案', + stepCount: '{{count}} 步', + stepCount_one: '{{count}} 步', + stepCount_other: '{{count}} 步', + expand: '展開步驟', + collapse: '收起步驟', + }, + plan: { + countObjects: '{{count}} 個物件', + countObjects_one: '{{count}} 個物件', + countObjects_other: '{{count}} 個物件', + countViews: '{{count}} 個檢視', + countViews_one: '{{count}} 個檢視', + countViews_other: '{{count}} 個檢視', + countDashboards: '{{count}} 個儀表盤', + countDashboards_one: '{{count}} 個儀表盤', + countDashboards_other: '{{count}} 個儀表盤', + countSeedData: '示例資料', + extendTarget: '新增到現有應用:{{app}}', + awaitingConfirm: '等待確認', + iterateOrNew: { + question: '這個方案和你已有的應用「{{app}}」重疊。要在它上面繼續完善,還是另建一個獨立應用?', + questionUnnamed: '這個方案和你已有的一個應用重疊。要在那個應用上繼續完善,還是另建一個獨立應用?', + extend: '在「{{app}}」上繼續完善', + buildNew: '另建一個獨立應用', + replyHint: '回覆告訴我選哪一個。', + }, + objectsAndFields: '物件與欄位', + views: '檢視', + suggestedField: '補充', + nameField: '名稱:{{field}}', + }, + build: { + building: '正在構建 · {{app}}…', + built: '已構建 · {{app}}', + builtAndLive: '已搭建並上線 · {{app}}', + appFallback: '你的應用', + addingSampleData: '正在新增示例資料', + group: { + object: '物件', + view: '檢視', + dashboard: '儀表盤', + app: '應用', + seed: '示例資料', + dataset: '資料集', + permission: '權限', + other: '其他', + }, + moreArtifacts: '+{{n}} 個', + verifying: '正在檢查改動…', + verifyStep: '第 {{n}} 步', + verified: '已檢查改動', + unknownPhase: '未知的構建階段', + nextStepsLead: '接下來可以說:{{steps}}', + nextStepQuote: '「{{step}}」', + draftPosture: '之後的修改先存草稿,在對話裡點「釋出」即可上線。', + checksPassed: '核對通過 {{passed}}/{{total}}', + }, + changes: { + planTitle: '修改方案 · {{count}} 項', + planTitle_one: '修改方案 · {{count}} 項', + planTitle_other: '修改方案 · {{count}} 項', + saveDraft: '存為草稿', + reviewAndPublish: '檢視變更並發布', + undo: '撤銷', + undoWithOthers: '撤銷(連同另外 {{count}} 項草稿)', + undoWithOthers_one: '撤銷(連同另外 {{count}} 項草稿)', + undoWithOthers_other: '撤銷(連同另外 {{count}} 項草稿)', + undoConfirm: '撤銷會丟棄這個應用包全部 {{total}} 項未釋出的草稿,其中 {{count}} 項不屬於這次修改。', + undoConfirm_one: '撤銷會丟棄這個應用包全部 {{total}} 項未釋出的草稿,其中 {{count}} 項不屬於這次修改。', + undoConfirm_other: '撤銷會丟棄這個應用包全部 {{total}} 項未釋出的草稿,其中 {{count}} 項不屬於這次修改。', + undoConfirmUnknown: '撤銷會丟棄這個應用包裡全部未釋出的草稿,不只是這次修改。', + undoConfirmAction: '全部撤銷', + undoCancel: '取消', + undoing: '正在撤銷…', + undone: '已撤銷', + undoFailed: '撤銷失敗', + kind: { + field: '欄位', + object: '物件', + view: '檢視', + dashboard: '儀表盤', + app: '應用', + seed: '示例資料', + package: '應用包', + }, + }, + }, + chatbotError: { + title: '響應失敗', + fallbackDetail: '出了點問題,請重試。', + details: '詳情', + hide: '隱藏', + retry: '重試', + }, + chatbotQuota: { + title: '需要升級', + fallbackMessage: 'AI 額度已用完。', + buyCredits: '購買額度包', + upgradePlan: '升級方案', + }, + environment: { + setUpProduction: '建立你的生產環境', + entitlement: { + productionLimitTitle: '你已經有生產環境了', + productionLimitBody: + '每個組織只包含一個生產環境。如果還需要,可以新建一個組織,或者聯絡我們瞭解企業版方案。', + contactSalesCta: '聯絡銷售', + }, + }, + cloudConnection: { + checking: '正在檢查連線…', + retry: '重試', + errors: { + expired: '請求在獲批前已過期,請重新開始。', + accessDenied: '連線請求已被拒絕,請重新開始。', + bindFailed: '繫結失敗。', + deviceCodeFailed: '裝置碼請求失敗。', + }, + waiting: { + popupOpened: '請在剛開啟的視窗中批准此連線——本頁面會自動更新。', + polling: '正在等待雲端控制台批准…', + openApproval: '開啟批准頁面', + copy: '複製', + copied: '已複製', + codePrefilled: '批准頁面已自動填入該程式碼。', + openItHere: '視窗沒有出現?點此開啟', + cancel: '取消', + }, + bound: { + title: '已連線到 ObjectOS Cloud', + runtime: '執行環境', + organization: '組織', + approvedBy: '批准人', + runtimeId: '執行環境 ID', + environment: '環境', + since: '連線於', + privatePackages: '組織的私有包現已顯示在應用市場的「你的組織」分類下。', + disconnect: '斷開連線', + }, + unbound: { + title: '未連線', + body: '此執行環境尚未連線到控制平面。點選「連線」會在新視窗中開啟你的雲賬戶,只需點選一次即可批准連線——無需在本頁面輸入任何 ID 或憑據。', + connect: '連線', + }, + }, + marketplace: { + title: '應用市場', + subtitle: '瀏覽已通過稽核、釋出到 ObjectStack 目錄中的應用。點選應用檢視詳情並安裝到你的某個環境中。', + searchPlaceholder: '按名稱或標識搜尋應用…', + searchAria: '搜尋市場應用', + installed: '已安裝', + installedCount: '已安裝({{count}})', + refresh: '重新整理', + all: '全部', + noApprovedYet: '應用市場暫無已通過稽核的應用。', + noMatchFilters: '沒有應用匹配當前篩選條件。', + noDescription: '未提供描述。', + back: '返回應用市場', + browseLink: '去應用市場看看 →', + installedAdditiveNote: '解除安裝會立即將該應用從執行中的核心中移除,其權限集及相應授權也一併移除;同時刪除快取的清單,下次啟動時不再載入該包。其物件中已儲存的記錄仍保留在資料庫中。', + versionBadge: 'v{{version}}', + installedBadge: '已安裝 v{{version}}', + // objectui#11645 — an install-local entry this runtime refused to load at + // startup (the listing's `notLoaded` marker), read on Installed Apps. + notLoaded: { + badge: '未載入', + protocolIncompatible: '本執行環境未載入此包:它面向協議 {{requiredRange}},而本執行環境不支援該協議。', + otherReason: '本執行環境未載入此包({{code}})。', + }, + load: { + failed: '應用市場載入失敗', + failedHintConfigured: '本執行環境通過 {{url}} 上的控制面訪問應用市場。請檢查該地址是否線上、能否從本執行環境訪問。', + failedHintSameOrigin: '應用市場目錄通過本執行環境訪問。請檢查執行環境是否線上、能否訪問該目錄。', + loading: '正在載入應用市場目錄…', + slow: '仍在載入 —— 應用市場目錄響應緩慢。', + retry: '重試', + unreachable: '無法訪問應用市場目錄。', + answeredError: '應用市場目錄返回了錯誤(HTTP {{status}})。', + packageFailed: '應用載入失敗', + notFound: '未找到。', + }, + disabled: { + title: '應用市場已關閉', + description: '本執行環境未配置應用市場,因此這裡沒有可瀏覽或安裝的內容。', + hint: '當執行環境以 OS_CLOUD_URL=off(或 none、local、disabled)啟動時,這就是預期狀態。如需開啟應用市場,請把 OS_CLOUD_URL 指向一個控制面並重啟執行環境。', + }, + detail: { + homepage: '主頁', + installedV: '已安裝 · v{{version}}', + about: '簡介', + noReadme: '未提供 README。', + versions: '版本', + noApprovedVersions: '暫無已稽核版本。', + prerelease: '預釋出', + moreOptions: '更多安裝選項', + uninstallFromRuntime: '從本執行環境解除安裝', + addSampleData: '新增示例資料', + reseedAgain: '重新載入示例資料', + purgeSampleData: '清除示例資料', + purgeConfirm: '刪除此應用預置的所有示例記錄?使用者自行新增的記錄不會被刪除。', + purgeSuccess: '已刪除 {{count}} 條示例記錄。', + purgeSuccess_one: '已刪除 {{count}} 條示例記錄。', + purgeSuccess_other: '已刪除 {{count}} 條示例記錄。', + purgeNoData: '沒有可清除的示例記錄。', + reseedQueued: '示例資料將在下次訪問該環境時重新載入。', + reseedLocalSuccess: '已重新載入示例資料:新增 {{inserted}} 條,更新 {{updated}} 條。', + reseedPartialErrors: '(有 {{count}} 條記錄寫入失敗)', + reseedPartialErrors_one: '(有 {{count}} 條記錄寫入失敗)', + reseedPartialErrors_other: '(有 {{count}} 條記錄寫入失敗)', + sampleDataKernelUnavailable: '此控制面沒有環境核心,因此無法在這裡重新載入或清除示例資料。請在該環境自己的執行環境中操作。', + updateAvailable: '有可用更新', + }, + action: { + install: '安裝', + reinstall: '重新安裝', + working: '處理中…', + installToCloud: '安裝到雲端…', + installed: '已安裝', + installing: '安裝中…', + updating: '更新中…', + uninstall: '解除安裝', + uninstalling: '解除安裝中…', + details: '詳情', + close: '關閉', + dismiss: '關閉', + openOnCloud: '在雲端開啟', + backHome: '返回首頁', + updateTo: '更新', + }, + install: { + dialogTitle: '安裝 {{name}}', + dialogDescCurrent: '安裝到當前環境({{host}})。', + dialogDescPicker: '選擇要將此應用安裝到的環境。需要先登入 ObjectOS Cloud。', + environment: '環境', + environmentPlaceholder: '選擇一個環境', + includeSampleData: '包含示例資料', + noEnvs: '當前組織沒有可用的環境。', + noPermission: '你沒有權限在任何環境中安裝應用。僅組織所有者和管理員可以安裝 — 請聯絡你的組織管理員。', + signInFirst: '請先登入 ObjectOS Cloud,然後點選下方"在雲端開啟"。', + success: '安裝成功。開啟環境即可看到新應用。', + localSuccess: '已將 v{{version}} 安裝到本執行環境。"{{name}}" 應該已經出現在應用切換器中。', + localLoadsAtRestart: '已將 v{{version}} 安裝到本執行環境。"{{name}}" 將在執行時下次重啟時載入,在此之前不會出現在應用切換器中。', + localManifestConflict: '{{message}}\n提示:本地已有應用佔用此 manifest_id,請先從 objectstack.config.ts 中移除。', + // ADR-0141 D2 — the install refusal DEPENDENCY_MISSING, one line per unmet entry. + dependencyMissing: { + heading: '安裝被拒絕:此軟體包宣告的依賴在當前環境中未滿足,未安裝任何內容。', + absent: '請先安裝 {{dependency}} {{range}}。', + outOfRange: '請先安裝 {{dependency}} {{range}}(當前安裝的是 {{installedVersion}})。', + unreadableRange: '{{packageId}} 為 {{dependency}} 宣告的版本範圍 {{range}} 無法識別,請修改版本範圍後重新發布軟體包。', + unreadableVersion: '請安裝 {{dependency}} 在 {{range}} 範圍內的正式版本(當前安裝的 {{installedVersion}} 不是“主版本.次版本.修訂號”格式)。', + unreadableDeclaration: '{{packageId}} 的依賴宣告格式無法識別,請寫成“軟體包 ID → 版本範圍”的對映後重新發布軟體包。', + }, + // objectstack#30326 — the install answer's data.protocolCheckSkipped: installed, the protocol range unchecked. + protocolCheckSkipped: { + noRange: '{{packageId}} 未宣告它支援的平台協議版本,因此已在平台協議 {{protocolVersion}} 下安裝,未做相容性檢查,可能無法在當前平台版本上正常執行。聲明瞭所支援協議(engines.protocol)的軟體包版本會在安裝時接受檢查。', + unparsedRange: '{{packageId}} 宣告的平台協議為 {{requiredRange}},本執行環境無法識別,因此已在平台協議 {{protocolVersion}} 下安裝,未做相容性檢查,可能無法在當前平台版本上正常執行。聲明瞭可識別協議範圍(engines.protocol)的軟體包版本會在安裝時接受檢查。', + }, + localUnauthorized: '請先登入本執行環境再試。', + localMarketplaceUnavailable: '本執行環境未配置 OS_CLOUD_URL,無法訪問應用市場目錄。', + updateTo: '更新 → v{{version}}', + installedVersion: '已安裝 v{{version}}', + deploying: '正在部署應用…', + deployed: '應用已部署完成。', + openApp: '開啟 {{name}}', + deployTimeout: '安裝已記錄,但當前環境尚未提供該應用。它可能仍在部署中,請稍後再次檢查。如果一直沒有出現,可能是軟體包未能載入、不包含應用,或其應用需要你尚未擁有的權限。', + checkAgain: '再次檢查', + // ADR-0131 D6 — the install modes a package's manifest permits, and + // what an unmanaged install answers (MarketplacePackagePage). + mode: { + label: '安裝方式', + managed: '託管', + managedHint: '軟體包保持權威:以程式碼形式註冊並接收其升級,其中的條目不在此處編輯。', + unmanaged: '非託管副本', + unmanagedHint: '一次性複製到本執行環境,成為它自己的中繼資料:每個條目都可在此編輯,副本不再接收升級。', + required: '請先選擇安裝方式,再安裝此軟體包。', + }, + unmanagedSuccess: '已將 "{{name}}" 的 v{{version}} 複製到本執行環境,成為它自己的中繼資料,位於基礎包 {{base}} 中。副本可編輯,且不再接收升級。', + unmanagedStrippedLocks: '軟體包鎖定了以下條目。副本可編輯,因此這些鎖未被複制:', + unmanagedIgnored: '本執行環境不支援非託管安裝,因此 "{{name}}" 以託管方式安裝(以程式碼形式註冊),而不是被複制。請先升級執行環境,然後解除安裝該軟體包,再以非託管副本重新安裝。', + }, + // objectui#12146 — the install dialog opened as an UPDATE of the copy + // this environment holds (MarketplacePackagePage). + update: { + dialogTitle: '更新 {{name}}', + dialogDescCurrent: '更新當前環境({{host}})中已安裝的應用。', + dialogDescPicker: '選擇要更新已安裝應用的環境。需要先登入 ObjectOS Cloud。', + majorNotice: '這是一次主版本更新(v{{fromMajor}} → v{{toMajor}}),可能包含不相容的變更。', + releaseNotes: '檢視 v{{version}} 的版本說明', + success: '已更新到 v{{version}}。', + deployTimeout: '更新已記錄,但當前環境尚未提供 v{{version}}。可能仍在部署中,請稍後再次檢查。', + }, + // objectui#3546 — the MarketplacePage "Your organization" strip. + org: { + heading: '你的組織', + install: '安裝', + installed: '已安裝 {{name}}', + installedBadge: '已安裝', + installing: '安裝中…', + }, + // ADR-0025 PD4 §3.5/§3.11 — the pre-install consent panel + // (PluginDisclosure.tsx). `runtime` is a CLOSED enum: spec + // PluginRuntimeSchema = z.enum(['node', 'sandbox', 'worker']). + disclosure: { + containsCode: '此軟體包包含程式碼', + reviewed: '已稽核並批准', + unreviewed: '尚未稽核', + signed: '已簽名', + grantsIntro: '此軟體包請求以下權限:', + notEnforced: '安裝時會記錄這份清單,後續版本要求更多權限時會再次徵求同意;但執行環境目前還不會把該軟體包限制在此清單內。', + services: '平台服務', + hooks: '生命週期鉤子', + network: '網路訪問', + fs: '檔案系統訪問', + noPermissions: '不請求任何特殊權限。', + acknowledge: '我瞭解此軟體包會執行程式碼,並將獲得上述權限。', + runtime: { + node: '程序內 · 完全信任', + sandbox: '沙箱隔離', + worker: '程序外', + }, + }, + // ADR-0090 D5/D9 — 安裝時建議:包宣告 isDefault 的權限集建議繫結到 + // everyone/guest 崗位,由管理員在此確認,服務端絕不自動繫結。 + suggestedBindings: { + promptEveryone: '此應用建議將「{{set}}」授予所有已登入使用者(Everyone 崗位)。', + promptGuest: '此應用建議將「{{set}}」授予未登入訪客(Guest 崗位)。', + confirm: '授予', + confirming: '授予中…', + dismiss: '忽略', + confirmedToast: '「{{set}}」已授予 {{anchor}}。', + dismissedToast: '已忽略「{{set}}」的建議。', + guestUnsupported: '此處無法授予:Guest 崗位的權限集不由已接受的建議派生。', + acceptedState: '「{{set}}」已為所有已登入使用者(Everyone 崗位)接受。', + notInForceState: '「{{set}}」已接受,但尚未生效:本次請求未改變 Everyone 崗位。原因見伺服器日誌。', + dismissedState: '「{{set}}」未授予:該建議已被忽略或撤銷。', + revoke: '撤銷', + revoking: '撤銷中…', + revokedToast: '已撤銷對「{{set}}」的接受。', + reopen: '重新考慮', + reopening: '重新開啟中…', + reopenedToast: '已重新開啟「{{set}}」的建議:接受之前不授予任何權限。', + }, + uninstall: { + confirm: '從本執行環境解除安裝 {{manifestId}} v{{version}}?\n\n該應用將立即從執行中的核心中移除,其權限集及相應授權也一併移除,快取的清單也將被刪除。其物件中已儲存的記錄仍保留在資料庫中。', + successInList: '已移除 {{manifestId}}:該應用已從執行中的核心中移除,其權限集及相應授權也已一併移除。其物件中已儲存的記錄仍保留在資料庫中。', + successInDetail: '已移除 {{manifestId}} 及其清單快取:該應用已從執行中的核心中移除,其權限集及相應授權也已一併移除。其物件中已儲存的記錄仍保留在資料庫中。', + confirmNotLoaded: '從本執行環境解除安裝 {{manifestId}} v{{version}}?\n\n磁碟上的清單快取將被移除。本執行環境並未載入該包,因此它沒有任何部分在執行。', + successNotLoaded: '已移除 {{manifestId}}。該包未被載入,無需重啟執行環境。', + }, + accessDenied: { + title: '應用市場僅限管理員使用', + description: '你沒有權限在本環境中安裝應用。請聯絡組織的所有者或管理員申請權限。', + }, + category: { + crm: '客戶關係', + erp: '企業資源', + hr: '人力資源', + finance: '財務', + project: '專案管理', + collaboration: '協作', + analytics: '分析', + integration: '整合', + automation: '自動化', + ai: '人工智慧', + security: '安全', + 'developer-tools': '開發者工具', + 'ui-theme': '介面主題', + storage: '儲存', + other: '其他', + }, + relativeTime: { + today: '今天', + daysAgo: '{{count}} 天前', + monthsAgo: '{{count}} 個月前', + monthsAgo_one: '{{count}} 個月前', + monthsAgo_other: '{{count}} 個月前', + yearsAgo: '{{count}} 年前', + yearsAgo_one: '{{count}} 年前', + yearsAgo_other: '{{count}} 年前', + }, + }, + approvalsInbox: { + recordUnresolvable: '無法開啟此記錄', + loadMore: '載入更多', + loadingMore: '載入中…', + loadedOf: '已載入 {{loaded}} / 共 {{total}} 條', + actEscalate: 'SLA 升級', + systemSlaActor: '系統(SLA)', + reassignBtn: '轉籤', + reassignTitle: '將該審批轉籤給其他人?', + reassignBody: '你的審批席位將轉移給所選人員,對方會收到通知並可立即處理。', + reassignTo: '新審批人', + reassignToPlaceholder: '選擇使用者,或輸入郵箱 / role:<角色名>', + reassignSuccess: '已轉籤給 {{to}}', + requestInfoBtn: '退回補充', + requestInfoTitle: '請申請人補充材料?', + requestInfoBody: '請求保持待審批狀態;申請人會收到通知並可在動態中回覆。', + requestInfoPlaceholder: '需要申請人補充什麼?', + requestInfoSent: '已退回給申請人補充材料', + remindBtn: '催辦', + remindSuccess: '已提醒 {{count}} 位審批人', + remindSuccess_one: '已提醒 {{count}} 位審批人', + remindSuccess_other: '已提醒 {{count}} 位審批人', + remindThrottled: '剛剛已傳送過催辦,請稍後再試。', + replyPlaceholder: '在此回覆…', + slaRemaining: 'SLA 剩餘 {{dur}}', + slaOverdue: 'SLA 已超期 {{dur}}', + actReassign: '轉籤', + reassignFromTo: '從 {{from}} 轉給 {{to}}', + actRemind: '催辦', + actRequestInfo: '要求補充材料', + actComment: '評論', + stepProgress: '審批步驟', + prevRequest: '上一條', + nextRequest: '下一條', + positionOf: '第 {{index}} / {{total}} 條請求', + progressApprovals: '審批進度 — {{got}} / {{need}}', + progressGroups: '會籤進度 — {{got}} / {{need}} 組', + progressEligible: '{{count}} 位可審批人', + progressEligible_one: '{{count}} 位可審批人', + progressEligible_other: '{{count}} 位可審批人', + approverSlots: '兼任 {{count}} 個審批席位', + approverSlots_one: '兼任 {{count}} 個審批席位', + approverSlots_other: '兼任 {{count}} 個審批席位', + progressBar: '決策進度', + declaredActions: '操作', + decisionPanelOffRequestPage: '審批決策面板僅在審批請求頁面上可用。', + snapshotAsSubmitted: '提交時的內容', + attachmentChip: '附件', + attachmentOpenFailed: '無法開啟附件,請重試', + approveOneTitle: '通過“{{title}}”?', + approveOneBody: '將以你的身份通過該請求。如需填寫審批意見或附件,請開啟該請求處理。', + flowOrigin: '流程發起', + submittedByYou: '由你提交', + sortBy: '排序', + sortRecent: '最新優先', + sortOldest: '最早優先', + sortAmount: '金額(從高到低)', + quickPhrase1: '同意,符合要求。', + quickPhrase2: '有條件同意,請關注執行情況。', + quickPhrase3: '請補充相關材料後重新提交。', + title: '審批中心', + subtitle: '檢視並處理審批請求。', + refresh: '重新整理', + tabMyPending: '待我審批', + tabSubmitted: '我發起的', + tabAll: '全部', + searchPlaceholder: '搜尋記錄、流程、申請人…', + clearSearch: '清除搜尋', + statusFilter: '狀態', + allStatuses: '全部狀態', + processFilter: '流程', + allProcesses: '全部流程', + objectFilter: '物件', + allObjects: '全部物件', + filterCount: '{{shown}} / {{total}} 條', + selected: '已選擇', + actionableCount: '({{count}} 條可操作)', + actionableCount_one: '({{count}} 條可操作)', + actionableCount_other: '({{count}} 條可操作)', + selectAll: '全選', + selectRow: '選擇該請求', + colRequest: '審批事項', + colRecord: '記錄', + colRequester: '申請人', + colStatus: '狀態', + colWaiting: '提交時間', + colActions: '操作', + statusPending: '待審批', + statusApproved: '已通過', + statusRejected: '已拒絕', + statusRecalled: '已撤回', + statusReturned: '已退回修改', + sendBackBtn: '退回修改', + sendBackTitle: '將該申請退回修改?', + sendBackBody: '本輪審批結束,記錄解鎖,申請人可修改資料。重新提交後將為所有審批人開啟新一輪審批。', + sendBackPlaceholder: '需要修改什麼才能通過審批?', + sendBackSuccess: '已退回修改——申請人現在可以編輯並重新提交', + sendBackAutoRejected: '已達退回次數上限——申請已被自動拒絕', + actRevise: '退回修改', + actResubmit: '重新提交', + roundChip: '第 {{n}} 輪', + returnedHint: '審批人將申請退回給你。記錄已解鎖——修改資料後重新提交,將開啟新一輪審批。', + resubmitBtn: '重新提交', + resubmitting: '正在重新提交…', + resubmitSuccess: '已重新提交——新一輪審批已開啟', + resubmitPlaceholder: '你修改了什麼?', + editRecordBtn: '編輯記錄', + abandonTitle: '放棄本次修改?', + abandonBody: '撤回申請而不重新提交,審批到此結束。', + emptyTitle: '暫無請求', + emptyPending: '全部處理完畢,沒有等待你審批的請求。', + emptyOther: '這裡還沒有內容。', + emptyViewAll: '檢視全部請求', + waitingOnOthers: '{{count}} 個申請正在等待其他審批人', + waitingOnOthers_one: '{{count}} 個申請正在等待其他審批人', + waitingOnOthers_other: '{{count}} 個申請正在等待其他審批人', + noMatches: '沒有符合當前篩選條件的請求。', + keyboardHint: '快捷鍵:j/k 移動 · Enter 開啟 · x 選擇 · a 通過 · r 拒絕', + drawerTitle: '審批請求', + submittedAgo: '提交於{{when}}', + completedAt: '完成於{{when}}', + waitingOn: '等待以下審批人', + approverNameSeparator: '、', + approverUnstaffed: '{{seat}}(暫無在崗人員)', + history: '審批動態', + noActions: '暫無操作記錄。', + actSubmit: '提交', + actApprove: '通過', + actReject: '拒絕', + actRecall: '撤回', + overrideActionLabel: '管理員代為{{action}}', + overrideNotice: '你在該步驟沒有審批人席位。繼續將以管理員身份代為處理:該步驟會立即結案,尚未表態的審批人都會被跳過。此決定將記錄為管理員代辦。', + overrideNoticeWho: '你在該步驟沒有審批人席位。繼續將以管理員身份代為處理:該步驟會立即結案,並跳過尚未表態的審批人 —— {{who}}。此決定將記錄為管理員代辦。', + overrideReassignNotice: '你在該步驟沒有審批人席位。繼續將以管理員身份代為處理:該步驟會轉籤給你選擇的新審批人,取代所有尚未表態的審批人。請求保持待審批狀態,等待新審批人處理。此次轉籤將記錄為管理員代辦。', + overrideReassignNoticeWho: '你在該步驟沒有審批人席位。繼續將以管理員身份代為處理:該步驟會轉籤給你選擇的新審批人,取代尚未表態的審批人 —— {{who}}。請求保持待審批狀態,等待新審批人處理。此次轉籤將記錄為管理員代辦。', + overrideRecallNotice: '這不是你發起的請求。繼續將以管理員身份代為處理:該請求會被撤回,尚未表態的審批人都將無法再處理它,記錄隨即解鎖。', + overrideRecallNoticeWho: '這不是你發起的請求。繼續將以管理員身份代為處理:該請求會被撤回,記錄隨即解鎖,尚未表態的審批人將無法再處理它 —— {{who}}。', + viaOverrideChip: '管理員代辦', + viaOverrideHint: '操作者在該步驟沒有審批人席位 —— 經管理員代辦通道進入。', + overrideActor: '以其他身份操作(管理員)', + actor: '操作身份', + auto: '自動', + overrideHint: '例如 role:sales_manager。留空則使用自動識別的身份。', + comment: '審批意見(可選)', + approve: '通過', + approving: '正在通過…', + reject: '拒絕', + rejecting: '正在拒絕…', + recall: '撤回', + recalling: '正在撤回…', + cancel: '取消', + clear: '清除', + approveN: '通過 {{count}} 條', + rejectN: '拒絕 {{count}} 條', + bulkApproveTitle: '通過這 {{count}} 條請求?', + bulkApproveTitle_one: '通過這 {{count}} 條請求?', + bulkApproveTitle_other: '通過這 {{count}} 條請求?', + bulkApproveBody: '每條請求都將以你的身份通過,對應流程沿“通過”分支繼續。', + bulkRejectTitle: '拒絕這 {{count}} 條請求?', + bulkRejectTitle_one: '拒絕這 {{count}} 條請求?', + bulkRejectTitle_other: '拒絕這 {{count}} 條請求?', + bulkRejectBody: '將拒絕所選請求並通知各申請人。', + bulkApproved: '已通過 {{count}} 條請求', + bulkApproved_one: '已通過 {{count}} 條請求', + bulkApproved_other: '已通過 {{count}} 條請求', + bulkRejected: '已拒絕 {{count}} 條請求', + bulkRejected_one: '已拒絕 {{count}} 條請求', + bulkRejected_other: '已拒絕 {{count}} 條請求', + bulkPartial: '{{ok}} 條成功,{{fail}} 條失敗:{{which}}', + rejectOneTitle: '拒絕“{{title}}”?', + rejectOneBody: '將拒絕該請求並通知申請人。', + rejectTitle: '拒絕該請求?', + rejectBody: '將把該請求標記為已拒絕,並通知申請人。', + recallTitle: '撤回該請求?', + recallBody: '撤回後審批人將無法繼續處理,記錄隨即解鎖。', + inlineApproved: '已通過“{{title}}”', + inlineRejected: '已拒絕“{{title}}”', + approvedFinal: '已通過', + approvedWaiting: '已通過——等待其餘審批人', + rejectedToast: '已拒絕', + recalledToast: '請求已撤回', + whyDisabled: '只有指定的審批人才能處理該請求。當前等待:{{who}}。', + whyDisabledSubmitter: '這是你發起的請求,你可以撤回;但只有指定審批人才能通過或拒絕。', + noActor: '無法確定操作身份', + actionFailed: '操作失敗', + loadFailed: '載入請求失敗', + recallUnavailable: '當前部署暫不支援撤回。', + requestGone: '該請求已不存在,請重新整理列表。', + notAllowed: '你沒有權限執行此操作。', + alreadyDecided: '該請求已被處理,請重新整理列表。', + justNow: '剛剛', + minutesAgo: '{{count}} 分鐘前', + minutesAgo_one: '{{count}} 分鐘前', + minutesAgo_other: '{{count}} 分鐘前', + hoursAgo: '{{count}} 小時前', + hoursAgo_one: '{{count}} 小時前', + hoursAgo_other: '{{count}} 小時前', + daysAgo: '{{count}} 天前', + daysAgo_one: '{{count}} 天前', + daysAgo_other: '{{count}} 天前', + }, + filterBuilder: { + currency: '幣種', + currencyNeedsAnd: '只有在「全部條件都滿足(AND)」時,幣種金額才按所選幣種比較。', + where: '其中', + and: '且', + or: '或', + clearAll: '清除全部', + selectField: '選擇欄位', + operator: '運算子', + selectValue: '選擇值', + value: '值', + addFilter: '新增篩選', + removeCondition: '移除條件', + trueLabel: '是', + falseLabel: '否', + noResults: '無結果', + searchField: '搜尋 {{label}}…', + enterId: '輸入 {{label}} ID', + addValue: '輸入值後按回車', + removeValue: '移除 {{value}}', + rangeStart: '起始值', + rangeEnd: '結束值', + operators: { + equals: '等於', + not_equals: '不等於', + contains: '包含', + icontains: '包含(忽略大小寫)', + not_contains: '不包含', + is_empty: '為空', + is_not_empty: '不為空', + greater_than: '大於', + less_than: '小於', + greater_than_or_equal: '大於或等於', + less_than_or_equal: '小於或等於', + before: '早於', + after: '晚於', + between: '介於', + in: '屬於', + not_in: '不屬於', + starts_with: '以…開頭', + ends_with: '以…結尾', + is_null: '為 null', + is_not_null: '不為 null', + exists: '已設定', + notExists: '未設定', + }, + emptyCheckHint: { + text: '「{{isEmpty}}」也匹配空白文本;「{{isNull}}」只匹配沒有值的記錄。', + list: '「{{isEmpty}}」也匹配空列表;「{{isNull}}」只匹配沒有值的記錄。', + }, + }, + sortBuilder: { + sortBy: '排序依據', + thenBy: '然後按', + selectField: '選擇欄位', + ascending: 'A → Z', + descending: 'Z → A', + oldestFirst: '最早 → 最新', + newestFirst: '最新 → 最早', + addSort: '新增排序', + removeSort: '刪除排序', + }, + collaboration: { + commentCount: '{{count}} 條評論', + commentCount_one: '{{count}} 條評論', + commentCount_other: '{{count}} 條評論', + resolvedSuffix: ' · 已解決', + sortComments: '評論排序', + sortOldest: '最早優先', + sortNewest: '最新優先', + resolve: '標記已解決', + reopen: '重新開啟', + justNow: '剛剛', + minutesAgo: '{{count}} 分鐘前', + minutesAgo_one: '{{count}} 分鐘前', + minutesAgo_other: '{{count}} 分鐘前', + hoursAgo: '{{count}} 小時前', + hoursAgo_one: '{{count}} 小時前', + hoursAgo_other: '{{count}} 小時前', + daysAgo: '{{count}} 天前', + daysAgo_one: '{{count}} 天前', + daysAgo_other: '{{count}} 天前', + edited: '(已編輯)', + reactionCount: '{{count}} 個回應', + reactionCount_one: '{{count}} 個回應', + reactionCount_other: '{{count}} 個回應', + addThumbsUp: '點贊', + reply: '回覆', + reactThumbsUp: '以點贊回應', + reactHeart: '以愛心回應', + cancelReply: '取消回覆', + replyingTo: '正在回覆 {{name}}…', + replyingToComment: '正在回覆該評論…', + commentPlaceholder: '新增評論…(輸入 @ 提及他人)', + send: '傳送', + presentUserCount: '{{count}} 人線上', + presentUserCount_one: '{{count}} 人線上', + presentUserCount_other: '{{count}} 人線上', + moreUserCount: '另有 {{count}} 人', + moreUserCount_one: '另有 {{count}} 人', + moreUserCount_other: '另有 {{count}} 人', + userStatusTitle: '{{name}}({{status}})', + statusActive: '活躍', + statusIdle: '空閒', + statusAway: '離開', + }, + wizard: { + missingRequired: '請填寫以下必填欄位:{{fields}}', + back: '上一步', + submitting: '提交中…', + stepFallback: '第 {{n}} 步', + progressLabel: '進度', + emptyStep: '此步驟未配置欄位', + }, + flowRunner: { + title: '輸入', + submitting: '提交中…', + saveAndContinue: '儲存並繼續', + nextStep: '已儲存 — 下一步', + completed: '流程「{{flow}}」已完成', + }, + appManagement: { + title: '應用', + subtitle: '管理所有已配置的應用', + newApp: '新建應用', + searchLabel: '搜尋應用', + searchPlaceholder: '搜尋應用…', + selectedCount: '已選擇 {{n}} 個', + bulkEnable: '啟用', + bulkDisable: '停用', + selectAll: '全選({{n}})', + empty: '未找到應用。', + defaultBadge: '預設', + active: '已啟用', + inactive: '已停用', + selectApp: '選擇 {{name}}', + openApp: '開啟應用', + openAppNamed: '開啟 {{name}}', + editApp: '編輯應用', + editAppNamed: '編輯 {{name}}', + editWithAi: '用 AI 編輯', + editWithAiNamed: '用 AI 編輯 {{name}}', + enableApp: '啟用應用', + disableApp: '停用應用', + enableAppNamed: '啟用 {{name}}', + disableAppNamed: '停用 {{name}}', + setDefault: '設為預設', + setDefaultNamed: '將 {{name}} 設為預設', + deleteApp: '刪除應用', + deleteAppNamed: '刪除 {{name}}', + confirmDelete: '再次點選以確認刪除', + confirmDeleteNamed: '確認刪除 {{name}}', + toast: { + noClient: '無法連線到中繼資料服務', + unknownError: '未知錯誤', + appEnabled: '{{name}} 已啟用', + appDisabled: '{{name}} 已停用', + toggleFailed: '切換應用狀態失敗:{{reason}}', + setDefaultDone: '{{name}} 已設為預設', + setDefaultFailed: '設定預設應用失敗:{{reason}}', + appDeleted: '{{name}} 已刪除', + deleteFailed: '刪除應用失敗:{{reason}}', + nothingToDelete: '{{name}} 無可刪除:{{reason}}', + alreadyAtDefault: '它已是其軟體包的預設狀態', + bulkEnabled: '已啟用 {{n}} 個應用', + bulkDisabled: '已停用 {{n}} 個應用', + bulkFailed: '{{n}} 個失敗:{{details}}', + bulkFailureEntry: '{{name}}({{reason}})', + bulkFailureJoiner: ';', + bulkOperationFailed: '批次操作失敗:{{reason}}', + }, + }, + // objectui#6301 — 設定 › 打包動作(ADR-0126 §7.4)。術語沿用本包既有譯法: + // `common.package` = 軟體包。頁面的流程一半(列表與克隆對話方塊)已移入 Studio + // 的自動化區(ADR-0146 D11),其鍵隨之離開各語言包。⛔ 本組不含任何血緣/漂移 + // 措辭(§9)。 + packagedAutomation: { + refresh: '重新整理', + actionsHeading: '打包動作', + colActivation: '啟用狀態', + on: '已啟用', + off: '已停用', + cancel: '取消', + toggleFailedHttp: '無法更改啟用狀態(HTTP {{status}})。', + toggleFailed: '無法更改啟用狀態。', + actionsSubtitle: '由已安裝軟體包提供的動作。在本部署中停用後,它在所有提供它的位置都不再執行。你仍然可以在 Studio 中另行編寫自己的動作。', + colAction: '動作', + colObject: '物件', + actionToggleLabel: '{{object}} 上 {{label}} 的啟用狀態', + actionsEmptyTitle: '沒有打包動作', + actionsEmptyBody: '本部署中沒有任何已安裝軟體包宣告動作。你自己編寫的動作在 Studio 中。', + actionsLoadFailed: '無法載入打包動作。', + switchedOn: '「{{label}}」已啟用。', + switchedOff: '「{{label}}」已在本部署中停用。', + actionOffTitle: '停用「{{label}}」?', + actionOffBody: '在重新啟用之前,它將在本部署中所有提供它的地方對所有使用者停止執行。', + actionOffConfirm: '停用', + }, + connectorCredentials: { + title: "聯結器憑據", + subtitle: "已安裝應用的聯結器所需的憑據。值會加密儲存,之後不再顯示;欄位留空即保留當前值。", + refresh: "重新整理", + dismiss: "關閉", + colCredential: "憑據", + colStatus: "狀態", + colLastChanged: "最近更改", + colValue: "值", + statusSet: "已設定", + statusMissing: "缺失", + statusRejected: "被拒絕", + rejectedReason: "上游拒絕了它:{{reason}}", + undeclared: "已沒有任何已安裝的應用宣告它。", + sourceEnv: "來自伺服器環境變數", + sourceVault: "來自宿主的憑據解析器", + envReadOnly: "由同名的伺服器環境變數提供,且優先生效。請在伺服器上更改。", + changedBy: "{{at}},由 {{user}}", + verifiedAt: "最近一次確認可用:{{at}}", + placeholderSet: "輸入值", + placeholderReplace: "輸入新值以替換", + valueLabel: "{{ref}} 的新值", + save: "儲存", + saving: "正在儲存", + saved: "已儲存 {{ref}}。使用它的聯結器現在會用它重新連線。", + clear: "清除", + cleared: "已清除 {{ref}}。", + clearTitle: "清除 {{ref}}?", + clearBody: "儲存的值將被刪除,使用它的每個聯結器都會停止工作,直到重新設定值。", + clearConfirm: "清除憑據", + cancel: "取消", + notServedTitle: "此伺服器不管理聯結器憑據", + notServedBody: "它沒有聯結器憑據路由。請將每個憑據設定為同名的伺服器環境變數,或升級伺服器。", + unavailableTitle: "此處不管理聯結器憑據", + loadFailed: "無法載入聯結器憑據。", + retry: "重試", + emptyTitle: "沒有聯結器憑據", + emptyBody: "已安裝的應用中沒有需要憑據的聯結器。", + stepTitle: "憑據", + stepIntro: "此應用的聯結器需要以下憑據。現在逐個輸入,或先跳過、稍後再設定。", + skip: "暫時跳過", + skippedTitle: "已暫時跳過", + skippedBody: "在各自設定之前,使用它們的聯結器保持斷開。", + openPage: "在聯結器憑據頁面中設定", + connectionsHint: "通過 OAuth 登入供應商的聯結器在以下頁面連線:", + connectionsLink: "聯結器連線", + }, + // Setup → Integrations: Connector connections (ADR-0143 D5). + connectorConnections: { + title: "聯結器連線", + subtitle: "為聯結器代表本組織呼叫的每個供應商登入一次。平台會自動續期訪問權限;當供應商不再接受授權時請重新連線。", + refresh: "重新整理", + dismiss: "關閉", + colConnector: "聯結器", + colConnection: "連線", + colDetails: "詳情", + colActions: "操作", + statusConnected: "已連線", + statusReconnect: "需要重新連線", + statusNotConnected: "未連線", + instanceDegraded: "已降級", + connectedBy: "{{at}} 由 {{user}} 連線", + connectedAt: "{{at}} 已連線", + scopes: "授權範圍:{{scopes}}", + tokenExpires: "訪問令牌在 {{at}} 前續期", + waiting: "請在開啟的視窗中完成登入。", + connect: "連線", + reconnect: "重新連線", + disconnect: "斷開連線", + connected: "已連線 {{name}}。", + disconnected: "已斷開 {{name}}。", + expired: "{{name}} 的登入未在時限內完成。請重新連線。", + popupBlocked: "瀏覽器攔截了登入視窗。請允許此站點彈出視窗後重試。", + disconnectTitle: "斷開 {{name}}?", + disconnectBody: "此處的連線將被移除,在重新連線之前,該聯結器不再代表本組織呼叫供應商。授權在供應商處仍然有效,直到過期或在供應商處被撤銷。", + disconnectConfirm: "斷開連線", + cancel: "取消", + loadFailed: "無法載入聯結器連線。", + retry: "重試", + emptyTitle: "沒有需要連線的聯結器", + emptyBody: "已安裝的應用中沒有需要管理員通過 OAuth 登入的聯結器。", + clientKeptWithSecret: "OAuth 客戶端 {{clientId}},已儲存金鑰", + clientKept: "OAuth 客戶端 {{clientId}},無金鑰", + clientTitle: "{{name}} 的 OAuth 客戶端", + clientIntro: "此聯結器使用貴組織為本部署在供應商處註冊的 OAuth 客戶端登入,註冊時使用下方的重定向 URI。請輸入該客戶端的 ID,若供應商簽發了金鑰也一併輸入,然後繼續前往供應商登入。", + clientStored: "已儲存的客戶端:{{clientId}}", + clientStoredSecret: "已隨其儲存客戶端金鑰,該金鑰永不顯示。", + clientStoredNoSecret: "未隨其儲存客戶端金鑰。", + clientResend: "替換會發送新的客戶端:請輸入其客戶端 ID,若供應商簽發了金鑰也一併輸入。", + clientId: "客戶端 ID", + clientSecret: "客戶端金鑰", + clientSecretHint: "公共客戶端請留空。金鑰隨連線加密儲存,之後不再顯示。", + clientContinue: "繼續登入", + redirectUriLabel: "重定向 URI", + redirectUriCopy: "複製", + redirectUriCopied: "已複製", + redirectUriHint: "繼續之前,請在供應商的 OAuth 客戶端中登記這個完全一致的 URI。", + redirectUriMissing: "此部署沒有固定的公開地址,因此沒有重定向 URI,連線會被拒絕。請在伺服器上設定 OS_BASE_URL(或 OS_AUTH_URL)。", + clientKeepHint: "重新連線將沿用此客戶端及其已儲存的金鑰,此處無需輸入。", + clientKeep: "沿用已儲存的客戶端", + clientReplace: "替換已儲存的客戶端", + }, + setupAssignments: { + positionsTitle: '崗位持有人', + positionsSubtitle: '選擇一個崗位,檢視誰持有它,並新增或移除持有人。崗位授予什麼,在崗位本身中定義。', + positionsList: '崗位', + positionsFilter: '篩選崗位', + positionsEmptyTitle: '沒有崗位', + positionsEmptyBody: '此環境尚未定義任何崗位。', + positionsPick: '選擇一個崗位以檢視其持有人。', + positionsLoadFailed: '無法讀取崗位。', + setsTitle: '權限集分配', + setsSubtitle: '選擇一個權限集,檢視誰直接或通過崗位持有它,並分配或取消分配使用者。權限集授予什麼,在權限集本身中定義。', + setsList: '權限集', + setsFilter: '篩選權限集', + setsEmptyTitle: '沒有權限集', + setsEmptyBody: '此環境尚未定義任何權限集。', + setsPick: '選擇一個權限集以檢視其使用者。', + setsLoadFailed: '無法讀取權限集。', + noMatch: '沒有與篩選條件匹配的項。', + notFound: '“{{name}}”不在此列表中。', + retry: '重試', + refresh: '重新整理', + }, + element: { + number: { + noObject: '未指定物件:請設定 dataSource.object。', + sourceUnresolved: '無法解析此指標的資料來源', + }, + image: { + noSource: '無圖片來源', + }, + recordPicker: { + sourceUnresolved: '無法解析此記錄選擇器的資料來源', + }, + calendar: { + sourceUnresolved: '無法解析此日曆的資料來源', + }, + chart: { + sourceUnresolved: '無法解析此圖表的資料來源', + }, + pivotTable: { + sourceUnresolved: '無法解析此資料透視表的資料來源', + }, + detailView: { + sourceUnresolved: '無法解析此詳情檢視的資料來源', + }, + relatedList: { + sourceUnresolved: '無法解析此相關列表的資料來源', + parentUnreadable: '無法讀取此相關列表的父記錄', + }, + form: { + sourceUnresolved: '無法解析此表單的資料來源', + }, + lineItems: { + sourceUnresolved: '無法解析此明細行面板的資料來源', + }, + gantt: { + sourceUnresolved: '無法解析此甘特圖的資料來源', + }, + grid: { + sourceUnresolved: '無法解析此表格的資料來源', + }, + board: { + sourceUnresolved: '無法解析此看板的資料來源', + }, + listView: { + sourceUnresolved: '無法解析此列表檢視的資料來源', + }, + map: { + sourceUnresolved: '無法解析此地圖的資料來源', + invalidCoordinatesExcluded: '因座標缺失或無效而未顯示在地圖上的記錄:{{excluded}}', + styleFailedConfigured: '地圖載入失敗({{reason}})。記錄已列在下方;請檢查此檢視設定的地圖樣式 URL。', + styleFailedDefault: '地圖載入失敗({{reason}})。記錄已列在下方;此處無法載入公共預設地圖瓦片,請聯絡管理員為此檢視設定地圖樣式。', + styleFailedDeployment: '地圖載入失敗({{reason}})。記錄已列在下方;此處無法載入為此部署設定的預設地圖樣式,請聯絡管理員檢查該樣式。', + styleFailedUnknownReason: '無法載入地圖樣式或瓦片', + webgl2Unavailable: '地圖載入失敗(此瀏覽器不支援地圖所需的 WebGL2)。記錄已列在下方;請開啟硬體加速,或使用支援 WebGL2 的瀏覽器檢視地圖。', + retry: '重試載入地圖', + listLabel: '位置', + listEmpty: '沒有可列出的位置。', + }, + timeline: { + sourceUnresolved: '無法解析此時間線的資料來源', + }, + component: { + sourceUnresolved: '無法解析此元件的資料來源', + noDataSource: '未解析到資料來源', + apiRefused: '此元件的 API 資料來源被拒絕', + apiUnreadable: '無法讀取此元件的 API 資料來源', + }, + metadataViewer: { + missingKey: '此檢視缺少 {{key}}。', + loading: '正在載入 {{name}}…', + loadFailed: '載入 {{name}} 失敗:{{message}}', + notFound: '未找到 {{name}}。', + stateMachine: '狀態機', + flow: '流程', + permission: '權限', + noStateMachine: '{{object}} 上沒有狀態機。', + noStateMachineNamed: '{{object}} 上沒有名為“{{name}}”的狀態機。', + lifecycle: '{{object}} 的生命週期', + field: '欄位 {{field}}', + initial: '初始', + final: '終態', + steps: '步驟:{{count}}', + noSteps: '沒有可顯示的步驟。', + untitledStep: '(步驟)', + hiddenSteps: '已隱藏技術步驟:{{count}} · 設定 {{setting}} 以顯示全部', + objectAccess: '物件訪問:{{count}}', + noPermissions: '未宣告物件權限。', + object: '物件', + unknownType: '未知的中繼資料檢視型別。應為 {{a}}、{{b}} 或 {{c}}。', + unknownTypeNamed: '未知的中繼資料檢視型別“{{type}}”。應為 {{a}}、{{b}} 或 {{c}}。', + }, + }, + reactPage: { + disabledTitle: 'React 頁面在此部署中已停用', + disabledBody: '{{kind}} 頁面會在應用中直接執行作者編寫的 JavaScript,因此除非部署將其開啟,否則處於關閉狀態。信任其頁面作者的運維人員可在伺服器上設定 {{flag}}(嵌入渲染器的宿主則呼叫 {{call}})。', + loadFailed: '無法載入 React 執行環境', + sourceRequired: '{{kind}} 頁面需要非空的 {{source}}。', + loading: '正在載入 React 執行環境…', + error: 'React 頁面錯誤', + }, +} as const; + +export default zhHant; diff --git a/ui/packages/i18n/src/provider.tsx b/ui/packages/i18n/src/provider.tsx index 0a26ba10933..c9577b42d5a 100644 --- a/ui/packages/i18n/src/provider.tsx +++ b/ui/packages/i18n/src/provider.tsx @@ -13,6 +13,7 @@ import { isBuiltInLanguage, loadBuiltInLocale, matchLanguageTag, + resolveBuiltInLanguage, } from './locales/registry.js'; /** @@ -702,19 +703,27 @@ export function I18nProvider({ // (which already merged the catalogue), or for a catalogue that was resident // when the instance was created, the write would add nothing and still // re-render every reader. + // + // The catalogue is written under its platform locale table TAG, the bundle + // `createI18n` keyed it by, never under the code the instance runs in + // (objectstack#30511). An instance in a stored alias (`zh`, `zh-TW`, `pt`) + // reads that bundle through `fallbackLng`; writing the catalogue under the + // alias would add a second copy of a catalogue that was already resident and + // re-render every reader for it, the very write the paragraph above forbids. useEffect(() => { const currentLang = i18nInstance.language || 'en'; - if (!isBuiltInLanguage(currentLang)) return; + const tag = resolveBuiltInLanguage(currentLang); + if (tag === null) return; let cancelled = false; - void loadBuiltInLocale(currentLang) + void loadBuiltInLocale(tag) .then((catalogue) => { if (cancelled || !catalogue) return; - const bundle: unknown = i18nInstance.getResourceBundle(currentLang, 'translation'); + const bundle: unknown = i18nInstance.getResourceBundle(tag, 'translation'); if (isNested(bundle)) { if (!mergeAddsKeys(bundle, catalogue)) return; - i18nInstance.addResourceBundle(currentLang, 'translation', catalogue, true, false); + i18nInstance.addResourceBundle(tag, 'translation', catalogue, true, false); } else { - i18nInstance.addResourceBundle(currentLang, 'translation', catalogue); + i18nInstance.addResourceBundle(tag, 'translation', catalogue); } }) .catch((err) => { @@ -835,15 +844,19 @@ export function I18nProvider({ // `changeLanguage`) — and that re-renders every reader once, in the // new language. An `added` event here would re-render each of them in // the OLD language first ({@link STORE_WRITES_RERENDER}). - const builtIn = await loadBuiltInLocale(lang).catch((err) => { + // + // The catalogue goes under its table tag, as on mount above: a switch + // to `zh-TW` reads the `zh-Hant` bundle through `fallbackLng`. + const tag = resolveBuiltInLanguage(lang); + const builtIn = tag === null ? null : await loadBuiltInLocale(tag).catch((err) => { console.warn(`[i18n] Failed to load the built-in catalogue for '${lang}':`, err); return null; }); - if (builtIn) { - if (i18nInstance.hasResourceBundle(lang, 'translation')) { - i18nInstance.addResourceBundle(lang, 'translation', builtIn, true, false, SILENT_WRITE); + if (tag !== null && builtIn) { + if (i18nInstance.hasResourceBundle(tag, 'translation')) { + i18nInstance.addResourceBundle(tag, 'translation', builtIn, true, false, SILENT_WRITE); } else { - i18nInstance.addResourceBundle(lang, 'translation', builtIn, false, false, SILENT_WRITE); + i18nInstance.addResourceBundle(tag, 'translation', builtIn, false, false, SILENT_WRITE); } } // Dynamic language pack loading (v2.0.7) diff --git a/ui/packages/i18n/tsconfig.test.json b/ui/packages/i18n/tsconfig.test.json index e2416b0c3c1..1517d5f652b 100644 --- a/ui/packages/i18n/tsconfig.test.json +++ b/ui/packages/i18n/tsconfig.test.json @@ -44,5 +44,5 @@ // Add it here the moment one appears — an ambient declaration is a program // input only when a pattern NAMES it, and the build gets that for free from // its `"include": ["src"]` while this project would not. - "include": ["src/**/*.test.ts", "src/**/*.test.tsx"] + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "scripts/**/*.d.mts"] } diff --git a/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx index a2a30c37b05..b17980ff998 100644 --- a/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx +++ b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx @@ -77,7 +77,7 @@ function renderAt(language: string, view: 'month' | 'week' = 'month') { } /** Read a pack value by dotted path, so a missing key surfaces as `undefined`. */ -function packValue(lang: 'en' | 'zh', dotted: string): unknown { +function packValue(lang: 'en' | 'zh-Hans', dotted: string): unknown { return dotted .split('.') .reduce((node, part) => (node as Record | undefined)?.[part], builtInLocales[lang]) @@ -119,7 +119,7 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => const notAString = WHOLE_LITERAL_LABELS.filter((k) => typeof packValue('en', k) !== 'string' || !packValue('en', k)) expect(notAString, `en lacks ${notAString.length} swept key(s)`).toEqual([]) - const agreeing = WHOLE_LITERAL_LABELS.filter((k) => packValue('zh', k) === packValue('en', k)) + const agreeing = WHOLE_LITERAL_LABELS.filter((k) => packValue('zh-Hans', k) === packValue('en', k)) expect(agreeing, `zh and en agree on ${agreeing.length} key(s), so those assertions would be vacuous`).toEqual([]) }) @@ -127,15 +127,15 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => // `calendar.today` predates this card. Its zh render is what makes the // English findings below attributable to the component rather than to a // provider that never switched language. - const { container } = renderAt('zh') + const { container } = renderAt('zh-Hans') const todayButton = container.querySelector('[aria-label]') expect(todayButton, 'nothing rendered at all').toBeTruthy() - expect(container.textContent).toContain(builtInLocales.zh.calendar.today) + expect(container.textContent).toContain(builtInLocales['zh-Hans'].calendar.today) expect(container.textContent).not.toContain(builtInLocales.en.calendar.today) }) it('no swept label renders its ENGLISH literal under zh', () => { - renderAt('zh') + renderAt('zh-Hans') const values = renderedLabelValues() expect(values.length, 'no labelled element rendered — the sweep would be vacuous').toBeGreaterThan(0) @@ -146,25 +146,25 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => }) it('every month-view label renders its zh pack value', () => { - renderAt('zh') + renderAt('zh-Hans') const values = renderedLabelValues() - const missing = MONTH_VIEW_LABELS.map((k) => packValue('zh', k) as string).filter( + const missing = MONTH_VIEW_LABELS.map((k) => packValue('zh-Hans', k) as string).filter( (chinese) => !values.some((v) => v === chinese), ) expect(missing, `${missing.length} month-view label(s) never reached the DOM in zh`).toEqual([]) }) it('the week view\'s own two resize labels come from the pack too', () => { - renderAt('zh', 'week') + renderAt('zh-Hans', 'week') const values = renderedLabelValues() for (const key of TIME_GRID_LABELS) { - expect(values, `${key} is absent from the week view`).toContain(packValue('zh', key) as string) + expect(values, `${key} is absent from the week view`).toContain(packValue('zh-Hans', key) as string) expect(values).not.toContain(packValue('en', key) as string) } }) it('the interpolated labels carry their data, not an English frame', () => { - renderAt('zh') + renderAt('zh-Hans') const values = renderedLabelValues() // `Current date: X` and `, N event(s)` were English frames wrapped // around localized data. The frames are what a pack must own. diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.sankeyFunnelLabels-13404.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.sankeyFunnelLabels-13404.test.tsx index 10f873ca698..25eeec1780b 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.sankeyFunnelLabels-13404.test.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.sankeyFunnelLabels-13404.test.tsx @@ -221,7 +221,7 @@ describe('objectui#13404 — the sankey footnote is translated', () => { it('zh reads the zh pack, with the counts filled in and the key drawn as a chip', () => { const { container } = withLocale('zh', THINNED); const note = noteOf(container)!; - const expected = builtInLocales.zh.chart.sankeyOmittedRows_other + const expected = builtInLocales['zh-Hans'].chart.sankeyOmittedRows_other .replace('{{shown}}', '1') .replace('{{total}}', '3') .replace('{{count}}', '2') diff --git a/ui/packages/plugin-charts/src/AdvancedChartImpl.scatterSeriesArity.test.tsx b/ui/packages/plugin-charts/src/AdvancedChartImpl.scatterSeriesArity.test.tsx index ce31f3c51c6..8c45ce49706 100644 --- a/ui/packages/plugin-charts/src/AdvancedChartImpl.scatterSeriesArity.test.tsx +++ b/ui/packages/plugin-charts/src/AdvancedChartImpl.scatterSeriesArity.test.tsx @@ -225,7 +225,7 @@ describe('objectui#7194 — the copy lives in the locale packs', () => {
, ); const text = arityRefusalOf(container)!.textContent ?? ''; - expect(text).toContain(builtInLocales.zh.chart.scatterOneMeasure); + expect(text).toContain(builtInLocales['zh-Hans'].chart.scatterOneMeasure); expect(text).not.toContain(EN_COPY); // The keys are data and are not translated. const codes = [...arityRefusalOf(container)!.querySelectorAll('code')].map((c) => c.textContent); diff --git a/ui/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts b/ui/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts index 08bface6d1d..b8f1a8c6ad7 100644 --- a/ui/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts +++ b/ui/packages/plugin-chatbot/src/__tests__/toolLabels-locale-parity-7481.test.ts @@ -43,6 +43,7 @@ import { describe, it, expect } from 'vitest'; import * as specSystem from '@objectstack/spec/system'; import { builtInLocales } from '@object-ui/i18n/locales'; import { humanizeToolName, toolTitleKey } from '../tool-display.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; type LocaleCode = keyof typeof builtInLocales; const LANGS = Object.keys(builtInLocales) as LocaleCode[]; @@ -91,8 +92,8 @@ const ttFrom = (lang: LocaleCode) => (key: string, fallback: string): string => }; describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)', () => { - it('covers all ten built-in packs', () => { - expect(LANGS).toHaveLength(10); + it('covers every built-in pack, one per platform locale', () => { + expect(LANGS).toEqual([...PLATFORM_LOCALE_TAGS]); }); it('reads a non-empty coverage set', () => { @@ -135,21 +136,21 @@ describe('chatbot.tool.* covers every tool the runtime can show (objectui#7481)' describe('humanizeToolName resolves the newer tools through the pack (objectui#7481)', () => { it('zh: the card`s own step reads Chinese, not `Get authoring rules`', () => { - expect(humanizeToolName('get_authoring_rules', ttFrom('zh'))).toBe('查阅编写规范'); - expect(humanizeToolName('get_authoring_rules', ttFrom('zh'))).not.toBe('Get authoring rules'); + expect(humanizeToolName('get_authoring_rules', ttFrom('zh-Hans'))).toBe('查阅编写规范'); + expect(humanizeToolName('get_authoring_rules', ttFrom('zh-Hans'))).not.toBe('Get authoring rules'); }); it('zh: the other four newly-mapped tools resolve too', () => { - const tt = ttFrom('zh'); + const tt = ttFrom('zh-Hans'); for (const name of AHEAD_OF_PIN) { - expect(humanizeToolName(name, tt), name).toBe(toolsOf('zh')![name]); + expect(humanizeToolName(name, tt), name).toBe(toolsOf('zh-Hans')![name]); } }); it('an UNKNOWN tool still degrades to the English title-caser', () => { // The optionality this whole seam was built on (cloud#1658): a custom or // third-party tool must be no worse off than before. - expect(humanizeToolName('forecast_revenue', ttFrom('zh'))).toBe('Forecast revenue'); + expect(humanizeToolName('forecast_revenue', ttFrom('zh-Hans'))).toBe('Forecast revenue'); expect(toolTitleKey('forecast_revenue')).toBe('chatbot.tool.forecast_revenue'); }); }); diff --git a/ui/packages/plugin-chatbot/src/__tests__/toolLabels-sentences-12724.test.ts b/ui/packages/plugin-chatbot/src/__tests__/toolLabels-sentences-12724.test.ts index 862ac2c70ea..f1c2af1d8b9 100644 --- a/ui/packages/plugin-chatbot/src/__tests__/toolLabels-sentences-12724.test.ts +++ b/ui/packages/plugin-chatbot/src/__tests__/toolLabels-sentences-12724.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest'; import { builtInLocales } from '@object-ui/i18n/locales'; import { humanizeToolName } from '../tool-display.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; type LocaleCode = keyof typeof builtInLocales; const LANGS = Object.keys(builtInLocales) as LocaleCode[]; @@ -29,7 +30,7 @@ const toolsOf = (lang: LocaleCode): Record => describe('chatbot.tool.* values are sentences, not tool ids (objectui#12724)', () => { it('reads every shipped pack, each with the same non-empty key set', () => { - expect(LANGS).toHaveLength(10); + expect(LANGS).toHaveLength(PLATFORM_LOCALE_TAGS.length); const enKeys = Object.keys(toolsOf('en')).sort(); expect(enKeys.length).toBeGreaterThan(0); for (const lang of LANGS) { @@ -45,6 +46,6 @@ describe('chatbot.tool.* values are sentences, not tool ids (objectui#12724)', ( }); it('zh: todo_write reads as the ruled sentence', () => { - expect(toolsOf('zh').todo_write).toBe('记下后续检查项'); + expect(toolsOf('zh-Hans').todo_write).toBe('记下后续检查项'); }); }); diff --git a/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx index 4b210a5a4f9..bdf405d8212 100644 --- a/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx @@ -80,7 +80,7 @@ describe('one word for the owner: detail.owner equals the platform label of owne // system-field fallback declares. const pairs: Array<[specLocale: string, uiPack: keyof typeof builtInLocales]> = [ ['en', 'en'], - ['zh-CN', 'zh'], + ['zh-CN', 'zh-Hans'], ['ja-JP', 'ja'], ['es-ES', 'es'], ]; @@ -95,7 +95,7 @@ describe('one word for the owner: detail.owner equals the platform label of owne }); it('the words the seat chose: 负责人 in zh, Owner in en', () => { - expect((builtInLocales.zh as any).detail.owner).toBe('负责人'); + expect((builtInLocales['zh-Hans'] as any).detail.owner).toBe('负责人'); expect(platformOwnerLabel('zh-CN')).toBe('负责人'); expect((builtInLocales.en as any).detail.owner).toBe('Owner'); expect(platformOwnerLabel('en')).toBe('Owner'); diff --git a/ui/packages/plugin-form/src/MasterDetailForm.lateCatalogue-12924.test.tsx b/ui/packages/plugin-form/src/MasterDetailForm.lateCatalogue-12924.test.tsx index 5a97a6f6d72..84fcbb2cd48 100644 --- a/ui/packages/plugin-form/src/MasterDetailForm.lateCatalogue-12924.test.tsx +++ b/ui/packages/plugin-form/src/MasterDetailForm.lateCatalogue-12924.test.tsx @@ -108,9 +108,9 @@ describe('MasterDetailForm re-labels derived boolean options when the catalogue const ds = dataSource(); // A zh session whose catalogue has not landed: the instance boots in zh // and, with no zh bundle, every key reads the `en` fallback. - const instance = createI18n({ defaultLanguage: 'zh', detectBrowserLanguage: false }); - instance.removeResourceBundle('zh', 'translation'); - expect(instance.language).toBe('zh'); + const instance = createI18n({ defaultLanguage: 'zh-Hans', detectBrowserLanguage: false }); + instance.removeResourceBundle('zh-Hans', 'translation'); + expect(instance.language).toBe('zh-Hans'); expect(instance.t('common.yes')).toBe('Yes'); render( @@ -132,7 +132,7 @@ describe('MasterDetailForm re-labels derived boolean options when the catalogue // not be measuring a late catalogue. await waitFor(() => expect(billable()).toEqual(['Yes', 'No'])); expect(screen.getByTestId('md-form-submit').textContent).toBe('Save'); - expect(gate.asked).toContain('zh'); + expect(gate.asked).toContain('zh-Hans'); // An unsaved edit on the first line, made before the catalogue lands. fireEvent.change(descriptions()[0], { target: { value: 'Freight, edited' } }); diff --git a/ui/packages/plugin-gantt/docs/verification/README.md b/ui/packages/plugin-gantt/docs/verification/README.md index 14398d8a0fa..28b429c04e2 100644 --- a/ui/packages/plugin-gantt/docs/verification/README.md +++ b/ui/packages/plugin-gantt/docs/verification/README.md @@ -337,7 +337,7 @@ Two fixes landed here: (the browser was `zh-CN`) — now they always match. 2. **The central locale packs were completed.** `@object-ui/i18n`'s built-in `gantt:` namespace ([`en.ts`](../../../i18n/src/locales/en.ts), - [`zh.ts`](../../../i18n/src/locales/zh.ts)) was stale — it predated Phases + [`zh-Hans.ts`](../../../i18n/src/locales/zh-Hans.ts)) was stale — it predated Phases 4–6, so apps on `I18nProvider` rendered **raw keys** (e.g. `gantt.viewMode.day`, `gantt.toolbar.criticalPath`) for the newer toolbar / view-mode / menu strings. The namespace now mirrors the plugin's diff --git a/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx b/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx index fead69e608e..baffe8b8d52 100644 --- a/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx +++ b/ui/packages/plugin-view/src/__tests__/ObjectView.gridDelete-10383.test.tsx @@ -359,7 +359,7 @@ describe('objectui#10383 — the affordance follows the same gate as the console * today's wording. */ describe('objectui#11695 — the row Delete names the row in zh', () => { - const ZH = builtInLocales.zh as unknown as { + const ZH = builtInLocales['zh-Hans'] as unknown as { objectActions: { deleteConfirmTitle: string; deleteConfirmButton: string }; }; diff --git a/ui/scripts/__tests__/check-eager-closure-budget.test.ts b/ui/scripts/__tests__/check-eager-closure-budget.test.ts index a7f32aa9862..17c362401b6 100644 --- a/ui/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/ui/scripts/__tests__/check-eager-closure-budget.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; // The structural locator for the prose attached to an exported constant, shared // with `vite-declared-lazy-views.test.ts` (objectui#7289). It was written here @@ -492,7 +493,8 @@ describe('chunk attribution (objectui#7399)', () => { // until objectui#7479. The groups are per-code now // (`i18n-locale-`), so a probe id that matches no catalogue file would // match no group and every case below would fail closed for the wrong reason. - const LOCALE_MODULE = moduleId('packages/i18n/src/locales/zh.ts'); + // The file is named for its platform locale tag since objectstack#30511. + const LOCALE_MODULE = moduleId('packages/i18n/src/locales/zh-Hans.ts'); const RESIDENT_LOCALE_MODULE = moduleId('packages/i18n/src/locales/en.ts'); const I18N_RUNTIME_MODULE = moduleId('packages/i18n/src/provider.tsx'); // objectstack#25922: the Studio designer namespace's two catalogues. The lazy @@ -526,7 +528,7 @@ describe('chunk attribution (objectui#7399)', () => { expect(groups.map((g) => g.name)).toEqual(expect.arrayContaining([ 'framework', 'i18n-locale-en', - 'i18n-locale-zh', + 'i18n-locale-zh-Hans', 'i18n-runtime', 'data-adapter', 'ui-components', @@ -560,7 +562,7 @@ describe('chunk attribution (objectui#7399)', () => { }); it.each([ - ['the lazy locale catalogue', LOCALE_MODULE, 'i18n-locale-zh'], + ['the lazy locale catalogue', LOCALE_MODULE, 'i18n-locale-zh-Hans'], ['the resident locale catalogue', RESIDENT_LOCALE_MODULE, 'i18n-locale-en'], ['the i18n runtime', I18N_RUNTIME_MODULE, 'i18n-runtime'], ['the lazy studio namespace catalogue', STUDIO_LAZY_MODULE, 'i18n-ns-studio-zh'], @@ -715,7 +717,11 @@ describe('chunk attribution (objectui#7399)', () => { * run without a full build. */ it('gives every catalogue a chunk of its OWN — a shared group would re-merge them', () => { - const CODES = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']; + // Every tag of the platform locale table (objectstack#30511): a tag the + // table gains with no group here reds below instead of falling to the + // eager runtime group. + const CODES = [...PLATFORM_LOCALE_TAGS]; + expect(CODES.length).toBeGreaterThan(1); const winners = new Map(); for (const code of CODES) { const claiming = claimants(moduleId(`packages/i18n/src/locales/${code}.ts`)); @@ -725,7 +731,7 @@ describe('chunk attribution (objectui#7399)', () => { winners.set(code, claiming[0].name); } expect([...winners.values()]).toEqual(CODES.map((code) => `i18n-locale-${code}`)); - // The property that matters, stated as itself: ten codes, ten DISTINCT + // The property that matters, stated as itself: N codes, N DISTINCT // chunk names. Any collision is the shared-group defect above. expect(new Set(winners.values()).size).toBe(CODES.length); }); diff --git a/ui/scripts/__tests__/check-eager-locale-catalogues.test.ts b/ui/scripts/__tests__/check-eager-locale-catalogues.test.ts index aef93bccba6..a39fd29e22b 100644 --- a/ui/scripts/__tests__/check-eager-locale-catalogues.test.ts +++ b/ui/scripts/__tests__/check-eager-locale-catalogues.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; // Plain-JS CI helper; its types are INFERRED from the .mjs source by // `tsconfig.scripts.json` (`allowJs`) — same arrangement as @@ -42,7 +43,7 @@ function reportWith(names: readonly string[]) { }; } -/** The ten emitted catalogue chunk files a healthy build writes. */ +/** The emitted catalogue chunk files a healthy build writes, one per code. */ const ALL_EMITTED = BUILT_IN_LANGUAGE_CODES.map( (code: string) => `${CATALOGUE_CHUNK_PREFIX}${code}-Hash1.js`, ); @@ -54,21 +55,31 @@ describe('the population this gate looks for', () => { * look for. That duplication is only safe while something reds when the two * disagree, and this is that something. */ - it('matches `BUILT_IN_LANGUAGE_CODES` in the i18n registry, code for code', () => { - const declared = /export const BUILT_IN_LANGUAGE_CODES[\s\S]*?\]\);/.exec(registrySource); + it('matches the platform locale table and the i18n registry, code for code', () => { + // The registry's `BUILT_IN_LANGUAGE_CODES` is the table's tags spread + // (objectstack#30511), so the table is the population to agree with. + const declared = /export const BUILT_IN_LANGUAGE_CODES[^\n]*\[\.\.\.PLATFORM_LOCALE_TAGS\]/.exec( + registrySource, + ); expect(declared).not.toBeNull(); + expect([...BUILT_IN_LANGUAGE_CODES]).toEqual([...PLATFORM_LOCALE_TAGS]); - // The registry spells the resident one as `DEFAULT_BUILT_IN_LANGUAGE` and - // spreads the other nine out of the loader table, so the codes are read - // from the two places they are actually written. - const resident = /export const DEFAULT_BUILT_IN_LANGUAGE = '([a-z-]+)';/.exec(registrySource); + // And the catalogues actually written: the resident one as + // `DEFAULT_BUILT_IN_LANGUAGE`, every other one as a loader-table entry, + // read from the two places they are spelled. + const resident = /export const DEFAULT_BUILT_IN_LANGUAGE = '([A-Za-z-]+)';/.exec(registrySource); expect(resident?.[1]).toBe(ACTIVE_CATALOGUE); - const loaderTable = /const LAZY_LOCALE_LOADERS[\s\S]*?\n {2}\}\);/.exec(registrySource); + const loaderTable = /const LAZY_LOCALE_LOADERS[\s\S]*?\n\}\);/.exec(registrySource); expect(loaderTable).not.toBeNull(); - const lazyCodes = [...(loaderTable?.[0] ?? '').matchAll(/^\s{4}([a-z-]+): \(\) => import\(/gm)].map( - (m) => m[1], - ); + const lazyCodes = [ + ...(loaderTable?.[0] ?? '').matchAll(/^\s{2}'?([A-Za-z-]+)'?: \(\) => import\('\.\/([A-Za-z-]+)\.js'\)/gm), + ].map((m) => { + // A catalogue file is named for its tag; a loader that imports another + // tag's file would chunk under the wrong `i18n-locale-` name. + expect(m[2]).toBe(m[1]); + return m[1]; + }); // Counter-probe: a regex that stopped matching would make the comparison // below `[resident] === [resident]` and agree with any registry at all. expect(lazyCodes.length).toBeGreaterThan(0); @@ -77,8 +88,10 @@ describe('the population this gate looks for', () => { }); it('reads a chunk name and a file name back to the same code', () => { - expect(catalogueCodeOfChunkName(`${CATALOGUE_CHUNK_PREFIX}zh`)).toBe('zh'); - expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-Bx9f1.js`)).toBe('zh'); + expect(catalogueCodeOfChunkName(`${CATALOGUE_CHUNK_PREFIX}zh-Hans`)).toBe('zh-Hans'); + expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-Hans-Bx9f1.js`)).toBe('zh-Hans'); + expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-Hant-Bx9f1.js`)).toBe('zh-Hant'); + expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}pt-BR-Bx9f1.js`)).toBe('pt-BR'); }); it('reads a hash that CONTAINS a hyphen — rolldown names chunks in base64url', () => { @@ -91,7 +104,11 @@ describe('the population this gate looks for', () => { // shipped: the suite could not tell the two spellings apart. expect(catalogueCodeOfFileName('assets/i18n-locale-de-cTrdGXD-.js')).toBe('de'); expect(catalogueCodeOfFileName('assets/i18n-locale-fr-CH3jagZ-.js')).toBe('fr'); - expect(catalogueCodeOfFileName('assets/i18n-locale-zh-Ixe-DTac.js')).toBe('zh'); + // The third measured name is the retired `zh` code's chunk: since + // objectstack#30511 the pack is `zh-Hans`, so that name is no code's at + // all, and the same hash on the renamed chunk reads as the new code. + expect(catalogueCodeOfFileName('assets/i18n-locale-zh-Ixe-DTac.js')).toBeNull(); + expect(catalogueCodeOfFileName('assets/i18n-locale-zh-Hans-Ixe-DTac.js')).toBe('zh-Hans'); // An underscore is in the same alphabet and was never exercised either. expect(catalogueCodeOfFileName('assets/i18n-locale-ja-Ab_c-1D2.js')).toBe('ja'); }); @@ -105,8 +122,8 @@ describe('the population this gate looks for', () => { expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}${code}-Ab-cD.js`)).toBe(code); } // A hash has to actually follow the code. - expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh.js`)).toBeNull(); - expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-.js`)).toBeNull(); + expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-Hans.js`)).toBeNull(); + expect(catalogueCodeOfFileName(`assets/${CATALOGUE_CHUNK_PREFIX}zh-Hans-.js`)).toBeNull(); }); it('claims nothing it should not — the matcher must be able to say no', () => { @@ -135,14 +152,14 @@ describe('the verdict', () => { const verdict = evaluateEagerCatalogues( reportWith([ `${CATALOGUE_CHUNK_PREFIX}${ACTIVE_CATALOGUE}`, - `${CATALOGUE_CHUNK_PREFIX}zh`, + `${CATALOGUE_CHUNK_PREFIX}zh-Hant`, `${CATALOGUE_CHUNK_PREFIX}ru`, ]), ALL_EMITTED, ); expect(verdict.status).toBe('fail'); - expect(verdict.eager).toEqual(['ru', 'zh']); - expect(verdict.message).toContain('ru, zh'); + expect(verdict.eager).toEqual(['ru', 'zh-Hant']); + expect(verdict.message).toContain('ru, zh-Hant'); // ⛔ The remedy must never read as "raise a ceiling": this failure is about // composition, and a byte ceiling cannot fix it. expect(verdict.message).toContain('Raising a byte ceiling does not address this'); @@ -187,10 +204,10 @@ describe('the counter-probes — a gate that cannot run must be LOUDER than one it('errors when a catalogue was emitted under no chunk of its own', () => { const verdict = evaluateEagerCatalogues( reportWith([`${CATALOGUE_CHUNK_PREFIX}${ACTIVE_CATALOGUE}`]), - ALL_EMITTED.filter((f: string) => !f.includes(`${CATALOGUE_CHUNK_PREFIX}zh-`)), + ALL_EMITTED.filter((f: string) => !f.includes(`${CATALOGUE_CHUNK_PREFIX}zh-Hant-`)), ); expect(verdict.status).toBe('error'); - expect(verdict.message).toContain('zh'); + expect(verdict.message).toContain('zh-Hant'); expect(verdict.message).toContain('matching nothing agrees with everything'); }); diff --git a/ui/scripts/__tests__/check-i18n-en-drift.test.ts b/ui/scripts/__tests__/check-i18n-en-drift.test.ts index 0fa24e2cfae..319a66aad5f 100644 --- a/ui/scripts/__tests__/check-i18n-en-drift.test.ts +++ b/ui/scripts/__tests__/check-i18n-en-drift.test.ts @@ -30,7 +30,7 @@ import { globMatches, jobBlock, repoPath, rootCi, triggerBlock, uiFilterGlobs, u /** * objectui#3650 — the behaviour test for `scripts/check-i18n-en-drift.mjs`. * - * The gate answers "did the nine translation packs follow the `en` string this + * The gate answers "did the translation packs follow the `en` string this * PR changed?", which no other i18n guard in this repo can: parity compares KEY * SETS, the call-site guard checks that a key EXISTS, and both were green * through objectui#3582 and objectui#3625 — eight packs serving a retired @@ -91,7 +91,11 @@ function leafValues(node: unknown, prefix = '', into = new Map( type Nested = { [key: string]: string | Nested }; -/** A pack module source, the way the real ones are written. */ +/** + * A pack module source, the way the real ones are written: a const, then + * `export default` of it. The const is not named after the pack, because a + * table tag (`zh-Hans`, `pt-BR`) is not an identifier. + */ function packSource(lang: string, tree: Nested): string { const render = (node: Nested, indent: string): string => Object.entries(node) @@ -101,7 +105,7 @@ function packSource(lang: string, tree: Nested): string { : `${indent}${key}: {\n${render(value, `${indent} `)}\n${indent}},`, ) .join('\n'); - return `const ${lang} = {\n${render(tree, ' ')}\n} as const;\n\nexport default ${lang};\n`; + return `const pack = {\n${render(tree, ' ')}\n} as const;\n\nexport default pack;\n`; } /** @@ -130,7 +134,7 @@ function designerSource(en: Record, zh: Record): const DESIGNER_DEFAULT = designerSource({ 'engine.fx.a': 'A' }, { 'engine.fx.a': '\u7532' }); /** - * Ten packs from one `lang -> tree` map, PLUS the designer table (objectui#8834). + * Every pack from one `lang -> tree` map, PLUS the designer table (objectui#8834). * * The designer table is not optional in a fixture repo. Since that second * population landed, the gate reads `i18n.ts` on both sides of every run, so a @@ -145,7 +149,7 @@ function packFiles(trees: Record, designer: string = DESIGNER_DE }; } -/** `lang -> tree` for all ten locales, built per locale. */ +/** `lang -> tree` for every locale, built per locale. */ function treesFor(make: (lang: string) => Nested): Record { return Object.fromEntries(LOCALES.map((lang): [string, Nested] => [lang, make(lang)])); } @@ -229,22 +233,25 @@ function runGateExpectingFailure( * The two sentences at the centre of objectui#3582 and objectui#3625, verbatim * from those issues. `en` moves to the read-only wording; the eight packs keep * their translations of the retired duplicate-to-customize wording. `zh` follows - * `en`, as it really did. + * `en`, as it really did — today that is `zh-Hans`, and the `zh-Hant` pack + * generated from it (objectstack#30511), so both follow. */ const RETIRED: Record = { en: 'System view — duplicate to customize.', - zh: '系统视图 — 复制一份来自定义。', + 'zh-Hans': '系统视图 — 复制一份来自定义。', + 'zh-Hant': '系統檢視 — 複製一份來自定義。', ja: 'システムビュー — 複製してカスタマイズしてください。', ko: '시스템 보기 — 사용자 지정하려면 복제하세요.', de: 'Systemansicht — zum Anpassen duplizieren.', fr: 'Vue système — dupliquer pour personnaliser.', es: 'Vista del sistema — duplique para personalizar.', - pt: 'Exibição do sistema — duplique para personalizar.', + 'pt-BR': 'Exibição do sistema — duplique para personalizar.', ru: 'Системное представление — скопируйте для настройки.', ar: 'عرض النظام — انسخ للتخصيص.', }; const EN_READONLY = 'System view — defined in code, read-only.'; const ZH_READONLY = '系统视图 — 由代码定义,只读。'; +const ZH_HANT_READONLY = '系統檢視 — 由程式碼定義,只讀。'; function reconstructed3625(): { root: string; base: string; head: string } { const before = packFiles( @@ -256,9 +263,13 @@ function reconstructed3625(): { root: string; base: string; head: string } { view: { readonlyTooltip: EN_READONLY }, common: { save: 'save-en' }, }), - [packPath('zh')]: packSource('zh', { + [packPath('zh-Hans')]: packSource('zh-Hans', { view: { readonlyTooltip: ZH_READONLY }, - common: { save: 'save-zh' }, + common: { save: 'save-zh-Hans' }, + }), + [packPath('zh-Hant')]: packSource('zh-Hant', { + view: { readonlyTooltip: ZH_HANT_READONLY }, + common: { save: 'save-zh-Hant' }, }), }; const { root, shas } = repoWithCommits([before, after]); @@ -340,12 +351,12 @@ describe('findDrift — an en value that changed without its translations', () = expect(counters.followedPacks).toBe(0); }); - it('is silent when the same key changed in all ten packs', () => { + it('is silent when the same key changed in every pack', () => { const before = packsOf(uniform((lang) => `old-${lang}`)); const after = packsOf(uniform((lang) => `new-${lang}`)); const { findings, counters } = findDrift(before, after); expect(findings).toEqual([]); - expect(counters.followedPacks).toBe(9); + expect(counters.followedPacks).toBe(FOLLOWERS.length); }); it('reports only the packs that stood still, not the ones that moved', () => { @@ -357,7 +368,8 @@ describe('findDrift — an en value that changed without its translations', () = ar: { greeting: 'new-ar' }, }); const { findings } = findDrift(before, after); - expect(findings[0].unfollowed).toEqual(['zh', 'ko', 'de', 'fr', 'es', 'pt', 'ru']); + // In table order: the platform locale table's, not an alphabet's. + expect(findings[0].unfollowed).toEqual(['zh-Hans', 'zh-Hant', 'es', 'fr', 'pt-BR', 'ru', 'de', 'ko']); }); it('leaves added and removed en keys to all-locales-key-parity', () => { @@ -376,18 +388,19 @@ describe('findDrift — an en value that changed without its translations', () = }); it('skips — and counts — a key a pack does not define', () => { - // The four `console.ai.*` outbound-message keys live in en and zh only, ON - // PURPOSE (see all-locales-key-parity.test.ts / outbound-agent-messages.test.ts). - // Demanding the other eight "follow" a key they must not define would put - // this gate in direct contradiction with the one that owns key sets. + // The gate-only `console.ai.*` outbound-message keys live in en and zh-Hans + // only, ON PURPOSE (see all-locales-key-parity.test.ts / + // outbound-agent-messages.test.ts). Demanding every other pack "follow" a key + // it must not define would put this gate in direct contradiction with the + // one that owns key sets. const trees = treesFor((lang): Nested => - lang === 'en' || lang === 'zh' ? { outbound: `old-${lang}` } : { unrelated: `x-${lang}` }, + lang === 'en' || lang === 'zh-Hans' ? { outbound: `old-${lang}` } : { unrelated: `x-${lang}` }, ); const before = packsOf(trees); - const after = packsOf({ ...trees, en: { outbound: 'new-en' }, zh: { outbound: 'new-zh' } }); + const after = packsOf({ ...trees, en: { outbound: 'new-en' }, 'zh-Hans': { outbound: 'new-zh-Hans' } }); const { findings, counters } = findDrift(before, after); expect(findings).toEqual([]); - expect(counters.absentInPack).toBe(8); + expect(counters.absentInPack).toBe(FOLLOWERS.length - 1); }); it('does not exempt a typography-only edit', () => { @@ -410,8 +423,9 @@ describe('the objectui#3625 commit, reconstructed in a real git repo', () => { expect(findings[0].key).toBe('view.readonlyTooltip'); expect(findings[0].enBefore).toBe(RETIRED.en); expect(findings[0].enAfter).toBe(EN_READONLY); - // zh followed and is absent; the eight that did not are all named. - expect(findings[0].unfollowed).toEqual(['ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']); + // zh-Hans and zh-Hant followed and are absent; the eight that did not are + // all named, in table order. + expect(findings[0].unfollowed).toEqual(['es', 'ar', 'fr', 'pt-BR', 'ru', 'de', 'ja', 'ko']); }); it('fails the build, and the message says which packs and what the sentence became', () => { @@ -420,7 +434,7 @@ describe('the objectui#3625 commit, reconstructed in a real git repo', () => { expect(code).toBe(1); expect(output).toContain('view.readonlyTooltip'); expect(output).toContain(EN_READONLY); - expect(output).toContain('unchanged in: ja, ko, de, fr, es, pt, ru, ar'); + expect(output).toContain('unchanged in: es, ar, fr, pt-BR, ru, de, ja, ko'); }); it('refuses to judge packs too small to be the real ones', () => { @@ -436,7 +450,7 @@ describe('the objectui#3625 commit, reconstructed in a real git repo', () => { expect(output).toContain('expected at least 2000'); }); - it('goes green when the same commit translates all nine packs', () => { + it('goes green when the same commit translates every follower pack', () => { const before = packFiles(treesFor((lang) => ({ view: { readonlyTooltip: RETIRED[lang] } }))); const after = packFiles( treesFor((lang) => ({ view: { readonlyTooltip: `${RETIRED[lang]} [read-only]` } })), @@ -444,7 +458,66 @@ describe('the objectui#3625 commit, reconstructed in a real git repo', () => { const { root, shas } = repoWithCommits([before, after]); const { code, output } = runGate(root, [...FIXTURE_FLOOR, '--base', shas[0], '--head', shas[1]]); expect(code).toBe(0); - expect(output).toContain('Every changed en value was followed by all nine translation packs.'); + expect(output).toContain(`Every changed en value was followed by all ${FOLLOWERS.length} translation packs.`); + }); +}); + +// -- a follower pack the base never had (objectstack#30511) -------------------- + +describe('a follower pack the base commit does not have', () => { + // The shape of the change that keyed the packs by the platform locale table: + // the base carries `zh.ts` and `pt.ts` and no `zh-Hant.ts`; the head carries + // the table's tags. Every key of those three packs is new in the change — a + // key-set fact, all-locales-key-parity's — so the before side reads them as + // empty rather than throwing. + const NEW_HERE = ['zh-Hans', 'zh-Hant', 'pt-BR']; + const oldLayout = (trees: Record): Record => { + const files = packFiles(trees); + for (const lang of NEW_HERE) delete files[packPath(lang)]; + return { + ...files, + 'packages/i18n/src/locales/zh.ts': packSource('zh', trees['zh-Hans']), + 'packages/i18n/src/locales/pt.ts': packSource('pt', trees['pt-BR']), + }; + }; + const same = () => treesFor((lang) => ({ greeting: `same-${lang}` })); + + it('reads it as empty, names it, and still judges every pack the base had', () => { + const before = oldLayout(treesFor((lang) => ({ greeting: `old-${lang}` }))); + const after = packFiles(treesFor((lang) => ({ greeting: lang === 'en' ? 'new-en' : `old-${lang}` }))); + const { root, shas } = repoWithCommits([before, after]); + const { findings, counters, newAtBase } = analyze(root, { base: shas[0], head: shas[1] }); + expect(newAtBase).toEqual(NEW_HERE); + // Their keys are absent on the before side: skipped, not drift ... + expect(counters.absentInPack).toBe(NEW_HERE.length); + // ... and every pack the base did have is still held to the en change. + expect(findings).toHaveLength(1); + expect(findings[0].unfollowed).toEqual(FOLLOWERS.filter((lang) => !NEW_HERE.includes(lang))); + }); + + it('says so in the CLI summary', () => { + const { root, shas } = repoWithCommits([oldLayout(same()), packFiles(same())]); + const { code, output } = runGate(root, [...FIXTURE_FLOOR, '--base', shas[0], '--head', shas[1]]); + expect(code).toBe(0); + expect(output).toContain('zh-Hans, zh-Hant, pt-BR did not exist at the base commit'); + }); + + it('CONTROL: a base that has every pack names none', () => { + const { root, shas } = repoWithCommits([packFiles(same())]); + expect(analyze(root, { base: shas[0], head: shas[0] }).newAtBase).toEqual([]); + }); + + it('never excuses en at the base', () => { + const withoutEn = packFiles(same()); + delete withoutEn[packPath('en')]; + const { root, shas } = repoWithCommits([withoutEn, packFiles(same())]); + expect(() => analyze(root, { base: shas[0], head: shas[1] })).toThrow(/cannot read .*en\.ts/); + }); + + it('never excuses a pack missing at HEAD', () => { + const { root, shas } = repoWithCommits([packFiles(same())]); + fs.rmSync(path.join(root, packPath('zh-Hant'))); + expect(() => analyze(root, { base: shas[0] })).toThrow(/zh-Hant\.ts does not exist in the working tree/); }); }); @@ -900,8 +973,8 @@ describe('resolving the commit to compare against', () => { // found no changed en value, and printed a confident green (measured: exit 0, // with `(--base …)` in the summary line replaced by the candidate it actually // used, so the log gave the reader nothing to notice). Restoring the - // fallthrough turns this case green again — exit 0 and the "followed by all - // nine" line below — which is the reverse verification for the fix. + // fallthrough turns this case green again — exit 0 and the "followed by all" + // line below — which is the reverse verification for the fix. // // "The base you named is missing" and "you named no base" are different // facts, and only the second may be answered by guessing. Ported verbatim in @@ -1054,7 +1127,7 @@ describe('the gate is wired to run', () => { // The root ci.yml carries no `paths-ignore` on any trigger: it starts on // every pull_request, every queue build, every push to main and the hourly // schedule. What decides whether `type-check-ui` then runs is the `filter` - // job's `ui:` paths filter, which the job's `if:` reads, so the ten locale + // job's `ui:` paths filter, which the job's `if:` reads, so the locale // packs, the ledger and the designer table must each match one of its // globs. (Until objectstack#25712 this pinned objectui's own `push` // `paths-ignore` list and its in-job `Decide whether this change needs a diff --git a/ui/scripts/__tests__/check-lint-rule-coverage.test.ts b/ui/scripts/__tests__/check-lint-rule-coverage.test.ts index 53efc3b0c5c..7942d5b660b 100644 --- a/ui/scripts/__tests__/check-lint-rule-coverage.test.ts +++ b/ui/scripts/__tests__/check-lint-rule-coverage.test.ts @@ -587,14 +587,20 @@ describe('this repository', () => { expect(row.vacuousMatches.length, `ledger row '${row.glob}' declares nothing any more`).toBeGreaterThan(0); expect(row.ruleBearingMatches, `ledger row '${row.glob}' over-claims`).toEqual([]); } - // Predicate 2, same two properties. Its population is three files today -- + // Predicate 2, same two properties. Its population is four files today -- // the vitest config objectui#8337 was filed about, and the declaration twins - // `scripts/ui-root.d.mts` and `scripts/workspace-root.d.mts` that package - // tests (compiled with `allowJs: false`) need to import those helpers typed - // (objectstack#25685, objectui#13472, objectstack#25723) -- and a green here - // means all three are DECLARED rather than invisible. + // `scripts/ui-root.d.mts`, `scripts/workspace-root.d.mts` and + // `packages/i18n/scripts/zh-hant.d.mts` that package tests (compiled with + // `allowJs: false`) need to import those modules typed (objectstack#25685, + // objectui#13472, objectstack#25723, objectstack#30511) -- and a green here + // means all four are DECLARED rather than invisible. expect(extensionProbeCollapse(result.reach)).toBeNull(); - expect(result.unreached).toEqual(['scripts/ui-root.d.mts', 'scripts/workspace-root.d.mts', 'vitest.config.mts']); + expect(result.unreached).toEqual([ + 'packages/i18n/scripts/zh-hant.d.mts', + 'scripts/ui-root.d.mts', + 'scripts/workspace-root.d.mts', + 'vitest.config.mts', + ]); // objectui#8369: this run is only a reading of the tree git tracks if the // filter was there and could answer both ways. expect(result.git.available).toBe(true); @@ -648,7 +654,7 @@ describe('this repository', () => { for (const row of VACUOUS_GROUPS) { expect(row.glob, 'a row must be a glob').toBeTruthy(); expect(row.reason.length, `row '${row.glob}' needs a reason, not a bare glob`).toBeGreaterThan(40); - expect(row.card, `row '${row.glob}' must name the card that owns it`).toMatch(/objectui#\d+/); + expect(row.card, `row '${row.glob}' must name the card that owns it`).toMatch(/(objectui|objectstack)#\d+/); } // Rows are globs on purpose -- a per-path ledger of this population would be // over a hundred rows that nobody would ever shrink. @@ -659,7 +665,7 @@ describe('this repository', () => { expect(UNREACHED_GROUPS.length).toBeGreaterThan(0); for (const row of UNREACHED_GROUPS) { expect(row.reason.length, `row '${row.glob}' needs a reason, not a bare path`).toBeGreaterThan(40); - expect(row.card, `row '${row.glob}' must name the card that owns it`).toMatch(/objectui#\d+/); + expect(row.card, `row '${row.glob}' must name the card that owns it`).toMatch(/(objectui|objectstack)#\d+/); // The asymmetry with VACUOUS_GROUPS, pinned because it is deliberate: a // `**/*.mts` row would waive the next `.mts` file, which is the only // thing this predicate exists to catch. diff --git a/ui/scripts/__tests__/git-paths-nested-layout.test.ts b/ui/scripts/__tests__/git-paths-nested-layout.test.ts index 2a2df765154..833e7b5c156 100644 --- a/ui/scripts/__tests__/git-paths-nested-layout.test.ts +++ b/ui/scripts/__tests__/git-paths-nested-layout.test.ts @@ -74,7 +74,7 @@ function layout(prefix: string): { tree: string; base: string; head: string } { } describe('check:i18n-drift over the change that IMPORTS the tree (objectstack#25728)', () => { - it('reads an empty before side when the tree does not exist at the base, and still throws when only a pack is missing', () => { + it('reads an empty before side when the tree does not exist at the base, and still throws when only the en pack is missing', () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'git-paths-import-')); tempRoots.push(repo); const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim(); @@ -103,12 +103,23 @@ describe('check:i18n-drift over the change that IMPORTS the tree (objectstack#25 expect(result.findings).toEqual([]); expect(result.counters.addedEnKeys).toBe(1); - // The tree exists at the base but a pack does not: a broken input, never an empty side. + // The tree exists at the base but `en` does not: a broken input, never an + // empty side. + fs.rmSync(path.join(tree, packPath('en'))); + git('add', '-A'); + git('commit', '-q', '-m', 'lose the en pack'); + const lostEn = git('rev-parse', 'HEAD'); + expect(() => analyze(tree, { base: lostEn, head: imported })).toThrow(/cannot read/); + + // A follower pack the base does not have is a language the platform locale + // table gained (objectstack#30511): it reads as empty there, and is named. + git('checkout', '-q', imported, '--', '.'); fs.rmSync(path.join(tree, packPath('ja'))); git('add', '-A'); - git('commit', '-q', '-m', 'lose a pack'); - const lost = git('rev-parse', 'HEAD'); - expect(() => analyze(tree, { base: lost, head: imported })).toThrow(/cannot read/); + git('commit', '-q', '-m', 'a base without the ja pack'); + const noJa = git('rev-parse', 'HEAD'); + const gained = analyze(tree, { base: noJa, head: imported }); + expect(gained.newAtBase).toEqual(['ja']); }); }); diff --git a/ui/scripts/check-eager-locale-catalogues.mjs b/ui/scripts/check-eager-locale-catalogues.mjs index 955480e5917..2ad0391af1c 100644 --- a/ui/scripts/check-eager-locale-catalogues.mjs +++ b/ui/scripts/check-eager-locale-catalogues.mjs @@ -71,26 +71,30 @@ import { isEntrypoint } from './invoked-as.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); /** - * Every language code `@object-ui/i18n` ships a catalogue for. + * Every language code `@object-ui/i18n` ships a catalogue for: the tags of the + * platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`), in + * its order, which the registry's `BUILT_IN_LANGUAGE_CODES` spreads + * (objectstack#30511). * - * ⚠️ Duplicated from `BUILT_IN_LANGUAGE_CODES` in - * `packages/i18n/src/locales/registry.ts` on purpose: this file must state its + * ⚠️ Written out here on purpose rather than imported: this file must state its * own population, or a code deleted from the registry would silently leave this * gate with nothing to look for and a green tick over it. - * `scripts/__tests__/check-eager-locale-catalogues.test.ts` fails when the two - * lists stop agreeing, which is the drift check a shared import cannot give. + * `scripts/__tests__/check-eager-locale-catalogues.test.ts` fails when this + * list, the table and the registry's loaders stop agreeing, which is the drift + * check a shared import cannot give. */ export const BUILT_IN_LANGUAGE_CODES = Object.freeze([ 'en', - 'zh', - 'ja', - 'ko', - 'de', - 'fr', + 'zh-Hans', + 'zh-Hant', 'es', - 'pt', - 'ru', 'ar', + 'fr', + 'pt-BR', + 'ru', + 'de', + 'ja', + 'ko', ]); /** @@ -102,7 +106,7 @@ export const ACTIVE_CATALOGUE = 'en'; /** * The chunk-name prefix `apps/console/vite.config.ts` gives each catalogue's - * `advancedChunks` group. Ten groups, ten single-module chunks; see that file + * `advancedChunks` group. One group per code, each a single-module chunk; see that file * for why it is not one group. */ export const CATALOGUE_CHUNK_PREFIX = 'i18n-locale-'; @@ -111,7 +115,7 @@ const DIST_DIR = path.join(repoRoot, 'apps', 'console', 'dist'); const REPORT_PATH = path.join(DIST_DIR, 'eager-closure.json'); const ASSETS_DIR = path.join(DIST_DIR, 'assets'); -/** `i18n-locale-zh-Bx9f1.js` -> `zh`; anything else -> `null`. */ +/** `i18n-locale-zh-Hans` -> `zh-Hans`; anything else -> `null`. */ export function catalogueCodeOfChunkName(name) { if (typeof name !== 'string' || !name.startsWith(CATALOGUE_CHUNK_PREFIX)) return null; const code = name.slice(CATALOGUE_CHUNK_PREFIX.length); @@ -119,7 +123,7 @@ export function catalogueCodeOfChunkName(name) { } /** - * `assets/i18n-locale-zh-Bx9f1.js` -> `zh`; anything else -> `null`. + * `assets/i18n-locale-zh-Hans-Bx9f1.js` -> `zh-Hans`; anything else -> `null`. * * ⛔ The hash segment is NOT constrained to hyphen-free text, and the premise * that it could be was measured false. Rolldown names a chunk with a diff --git a/ui/scripts/check-i18n-dead-keys.mjs b/ui/scripts/check-i18n-dead-keys.mjs index 90ef65bf2c9..29e1066036b 100644 --- a/ui/scripts/check-i18n-dead-keys.mjs +++ b/ui/scripts/check-i18n-dead-keys.mjs @@ -168,7 +168,7 @@ * import edge: * * grep -rn --include=*.ts --include=*.tsx -E \ - * "import \{[^}]*\b(en|zh|builtInLocales|getLoadedBuiltInLocales|ja|ko|de|fr|es|pt|ru|ar)\b[^}]*\}" \ + * "import \{[^}]*\b(en|zh|zhHant|builtInLocales|getLoadedBuiltInLocales|ja|ko|de|fr|es|pt|ru|ar)\b[^}]*\}" \ * packages apps examples e2e \ * | grep "@object-ui/i18n" | grep -v '^packages/i18n/' * @@ -271,10 +271,11 @@ * - `packages/app-shell/src/utils/flowResponse.ts` — reads ONE key, the * record form's permission line, for a failed flow run whose write node was * refused, through `getLoadedBuiltInLocales()` and never a static pack - * import. Three chains, each written straight off the call so the - * derivation can follow them: the active tag and its base language are - * RUNTIME-indexed (opaque rows, class 1 below, like `outboundAgentText`), - * and the `en` fallback is spelled in full and resolves to that leaf. It is + * import. Two chains, each written straight off the call so the + * derivation can follow them: the table tag the active locale reads as + * (`resolveBuiltInLanguage`, objectstack#30511) is RUNTIME-indexed (an + * opaque row, class 1 below, like `outboundAgentText`), and the `en` + * fallback is spelled in full and resolves to that leaf. It is * a TWO-segment key, under the property-chain leg's three-segment floor, * so that leg is blind to it; the full-key probe sees it only because the * same dotted key is spelled in this file's own comments. ⇒ covered BY @@ -464,6 +465,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { EXCLUDED_TRANSLATORS, analyze, collectEnKeys, collectSourceFiles } from './check-i18n-call-site-keys.mjs'; import { isEntrypoint } from './invoked-as.mjs'; @@ -807,7 +809,7 @@ export function derivePackObjectImporters(root) { '--include=*.tsx', ...[...TEXT_SWEEP_SKIP_DIRS].flatMap((dir) => ['--exclude-dir', dir]), '-E', - String.raw`import \{[^}]*\b(en|zh|builtInLocales|getLoadedBuiltInLocales|ja|ko|de|fr|es|pt|ru|ar)\b[^}]*\}`, + String.raw`import \{[^}]*\b(en|zh|zhHant|builtInLocales|getLoadedBuiltInLocales|ja|ko|de|fr|es|pt|ru|ar)\b[^}]*\}`, '--', ...dirs, ]; @@ -840,8 +842,17 @@ export function derivePackObjectImporters(root) { /** The named exports of the pack package that ARE a locale pack object — the * same set the header's importer grep alternates over, held here so the walk - * below and that recipe cannot drift apart in what counts as a pack. */ -const PACK_EXPORT_NAMES = new Set(['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']); + * below and that recipe cannot drift apart in what counts as a pack. These are + * export IDENTIFIERS (`zh` is the `zh-Hans` pack, `zhHant` the `zh-Hant` one, + * `pt` the `pt-BR` one), never locale tags: see {@link PACK_MAP_TAGS}. */ +const PACK_EXPORT_NAMES = new Set(['en', 'zh', 'zhHant', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']); + +/** The keys of the locale-tag MAP ({@link PACK_MAP_EXPORT_NAME}, and the + * accessor's result): the platform locale table's tags (objectstack#30511), so + * `builtInLocales['zh-Hans']` opens with a tag exactly as `builtInLocales.en` + * does. Read from the table, not listed, so a tag the table gains is stripped + * as a tag on the run that first sees it. */ +const PACK_MAP_TAGS = new Set(PLATFORM_LOCALE_TAGS); /** The pack export that is a MAP of locale tag -> pack object rather than a * pack itself. A chain off it opens with a locale tag, which is not part of @@ -1300,7 +1311,7 @@ export function classifyPackObjectRead(read, packKeys, fileText) { // A chain off the locale-tag MAP opens with the tag, which is not a key // segment. Strip exactly one, and only when it really is a tag. const chain = - read.pack === PACK_MAP_EXPORT_NAME && PACK_EXPORT_NAMES.has(read.chain[0]) ? read.chain.slice(1) : read.chain; + read.pack === PACK_MAP_EXPORT_NAME && PACK_MAP_TAGS.has(read.chain[0]) ? read.chain.slice(1) : read.chain; if (chain.length === 0) return opaque; const key = chain.join('.'); diff --git a/ui/scripts/check-i18n-en-drift.mjs b/ui/scripts/check-i18n-en-drift.mjs index 14bd7d04195..2ddf10a9810 100644 --- a/ui/scripts/check-i18n-en-drift.mjs +++ b/ui/scripts/check-i18n-en-drift.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * When an `en` string CHANGES, the other nine packs must change with it. + * When an `en` string CHANGES, every other pack must change with it. * * Run: node scripts/check-i18n-en-drift.mjs (also `pnpm check:i18n-drift`) * Exit: 0 = every changed `en` value was followed (or waived), 1 = it was not @@ -31,7 +31,7 @@ * The second case is the one that fixes the design. No gate can decide whether * a Japanese sentence still MEANS what the English one now means; asking for * that is asking for a translator. What a gate can decide is the **event**: the - * `en` value for this key changed in this PR and the nine translations did not. + * `en` value for this key changed in this PR and the translations did not. * That is the invariant both defects violated at birth, and it is checkable * exactly once — in the PR that edits `en`, while the author still knows what * the sentence is supposed to say. A day later it is archaeology. @@ -44,7 +44,7 @@ * landed on `main` after it forked. * * A key is "drifted" when it exists in `en` on BOTH sides with DIFFERENT values. - * For each drifted key, each of the nine packs must also show a different value + * For each drifted key, each follower pack must also show a different value * across the same two commits. Granularity is the key — not the sentence, not * the namespace, not the file. Two facts follow, and both are deliberate: * @@ -129,7 +129,7 @@ * * ## Why the packs are parsed from source rather than imported * - * The "before" side is a blob inside git, not a file on disk, and the ten packs + * The "before" side is a blob inside git, not a file on disk, and the packs * are 1.5 MB of TypeScript. `git show :` hands us the text and the * TypeScript AST turns it into `key -> string` with no build step, no loader, * and no temp files — the same trick, for the same reason, as `collectEnKeys` @@ -151,7 +151,17 @@ * whether the tree itself (`:./`) exists; only when it does not is the * before side empty, and the CLI says so. Standalone the tree is the repository * root, which exists at every commit, so this never fires there; a tree that - * exists at the base with a pack missing still THROWS, as it always did. + * exists at the base with its `en` pack missing still THROWS, as it always did. + * + * ## A base commit without one FOLLOWER pack (objectstack#30511) + * + * The packs are the platform locale table's tags, so a language the table + * gains arrives as a pack file the base never had — `zh-Hant` in #30511, which + * also renamed `zh` and `pt` to their tags `zh-Hans` and `pt-BR`. Such a pack is + * read as EMPTY at the base, so each of its keys is absent on the before side — + * the key-set fact `findDrift` already skips — and the CLI names the packs. The + * excuse is the before side's only, and never `en`'s: a pack missing at HEAD, + * or an `en` missing at the base, still throws. See {@link readPacks}. */ import ts from 'typescript'; @@ -159,6 +169,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { DESIGNER_TABLE, DESIGNER_TABLE_PAIRS, @@ -169,10 +180,21 @@ import { revPath } from './workspace-root.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); -/** The ten built-in packs, `en` first. Mirrors `packages/i18n/src/locales/index.ts`. */ -export const LOCALES = ['en', 'zh', 'ja', 'ko', 'de', 'fr', 'es', 'pt', 'ru', 'ar']; +/** + * The built-in packs: one per tag of the platform locale table + * (`PLATFORM_LOCALES` in `@objectstack/spec/system`), in its order, `en` first + * — the same population `packages/i18n/src/locales/index.ts` types its + * `builtInLocales` against (objectstack#30511). Read from the table rather than + * listed, so a language the table gains is a pack this gate reads on the run + * that first sees it; a pack file missing at HEAD then fails loudly in + * {@link readPacks}. + */ +export const LOCALES = Object.freeze([...PLATFORM_LOCALE_TAGS]); +if (LOCALES[0] !== 'en') { + throw new Error(`the platform locale table no longer opens with en (got ${LOCALES[0]}); this gate compares against en`); +} -/** The nine packs that must follow `en`. */ +/** The packs that must follow `en`: every other table tag. */ export const FOLLOWERS = LOCALES.filter((lang) => lang !== 'en'); /** Repo-relative path of a pack source. */ @@ -338,14 +360,27 @@ function gitQuiet(root, args) { } /** - * The ten packs at `ref`, or from the working tree when `ref` is null. + * Every pack at `ref`, or from the working tree when `ref` is null. * * The working tree is the default "after" side on purpose: an agent editing * `en.ts` gets the answer before committing, not after pushing. * + * A missing pack THROWS — with one exception, and only when the caller asks for + * it by passing `absentAtRef` (the BEFORE side, `analyze`): a FOLLOWER pack the + * ref does not have, at a ref whose `en` pack was read. That is a language the + * platform locale table gained after `ref` (objectstack#30511), or a pack file + * renamed to its table tag there; either way every key it holds is new in the + * change, which is a key-set fact `all-locales-key-parity.test.ts` owns, not + * drift. It reads as an empty pack and its tag is pushed onto `absentAtRef` so + * the CLI can say so. ⛔ `en` is never excused: a ref that is not in this clone, + * or a tree that moved, still throws on `en` before any follower is reached. + * + * @param {string} root + * @param {string | null} ref + * @param {string[] | null} [absentAtRef] * @returns {Map>} lang -> (key -> value) */ -export function readPacks(root, ref) { +export function readPacks(root, ref, absentAtRef = null) { const packs = new Map(); for (const lang of LOCALES) { const rel = packPath(lang); @@ -357,7 +392,14 @@ export function readPacks(root, ref) { text = readFileSync(full, 'utf8'); } else { text = gitQuiet(root, ['show', revPath(ref, rel)]); - if (text === null) throw new Error(`cannot read ${label} — is ${ref} in this clone?`); + if (text === null) { + if (absentAtRef !== null && lang !== 'en' && packs.has('en')) { + absentAtRef.push(lang); + packs.set(lang, new Map()); + continue; + } + throw new Error(`cannot read ${label} — is ${ref} in this clone?`); + } } packs.set(lang, parsePack(text, label)); } @@ -446,7 +488,7 @@ export function resolveBaseRef(root, { explicit = null, env = process.env } = {} * * `followers` is a parameter rather than the module constant so a SECOND * population can reuse this comparison unchanged — the designer table below - * has exactly one follower. It defaults to `FOLLOWERS`, so the ten-pack call + * has exactly one follower. It defaults to `FOLLOWERS`, so the locale-pack call * site is byte-identical to what it was. * * @param {Map>} before @@ -498,7 +540,7 @@ export function findDrift(before, after, followers = FOLLOWERS) { /** * The ledger, in two independent sections. * - * `waivers` covers the ten locale packs; `designerWaivers` covers the designer + * `waivers` covers the locale packs; `designerWaivers` covers the designer * table population added by objectui#8834. They are separate maps rather than * one because `checkLedger` validates every entry against the `en` values of * ITS population — a designer key is not a pack key, and one shared map would @@ -588,10 +630,12 @@ export function applyLedger(findings, ledger, enAfter) { */ export function analyze(root, { base, head = null }) { const treeAtBase = treeExistsAt(root, base); - const before = treeAtBase ? readPacks(root, base) : new Map(LOCALES.map((lang) => [lang, new Map()])); + /** Follower packs the base does not have — see {@link readPacks}. */ + const newAtBase = []; + const before = treeAtBase ? readPacks(root, base, newAtBase) : new Map(LOCALES.map((lang) => [lang, new Map()])); const after = readPacks(root, head); const { findings, counters } = findDrift(before, after); - return { before, after, findings, counters, treeAtBase }; + return { before, after, findings, counters, treeAtBase, newAtBase }; } /** @@ -763,7 +807,7 @@ if (invokedDirectly) { } const head = argOf('--head'); - const { after, findings, counters, treeAtBase } = analyze(root, { base: base.ref, head }); + const { after, findings, counters, treeAtBase, newAtBase } = analyze(root, { base: base.ref, head }); const enAfter = after.get('en'); // Guard against a refactor quietly emptying the comparison: with no keys, every @@ -808,14 +852,18 @@ if (invokedDirectly) { } console.log( - `Compared the ten locale packs at ${base.ref.slice(0, 9)} (${base.how}) with ` + + `Compared the ${LOCALES.length} locale packs at ${base.ref.slice(0, 9)} (${base.how}) with ` + `${head ?? 'the working tree'}: ${counters.changedEnKeys} en value(s) changed ` + `(${counters.addedEnKeys} key(s) added, ${counters.removedEnKeys} removed — those are ` + `all-locales-key-parity's), ${counters.followedPacks} pack value(s) followed, ` + `${counters.absentInPack} pack/key pair(s) skipped because the pack does not define the key, ` + `${waived.length} finding(s) waived by the ledger` + (treeAtBase - ? '.' + ? newAtBase.length > 0 + ? ` — ${newAtBase.join(', ')} did not exist at the base commit (a language the platform locale ` + + 'table gained, or a pack renamed to its table tag), so every key in those pack(s) is new here ' + + 'and nothing was compared for them.' + : '.' : ' — this tree does not exist at the base commit (the change imports it), so every key is new ' + 'here and there was no before side to compare.'), ); @@ -845,8 +893,8 @@ if (invokedDirectly) { counters.changedEnKeys === 0 ? 'No en value changed in this range.' : waived.length === 0 - ? 'Every changed en value was followed by all nine translation packs.' - : `Every changed en value was followed by all nine translation packs, except ${waived.length} ` + + ? `Every changed en value was followed by all ${FOLLOWERS.length} translation packs.` + : `Every changed en value was followed by all ${FOLLOWERS.length} translation packs, except ${waived.length} ` + `covered by a waiver in ${LEDGER_PATH}: ${waived.map((f) => f.key).join(', ')}.`, ); console.log( @@ -868,7 +916,7 @@ if (invokedDirectly) { console.error(` unchanged in: ${finding.unfollowed.join(', ')}`); } console.error( - '\nTranslate the new text in each pack listed, IN THIS PR. The nine packs going one\n' + + '\nTranslate the new text in each pack listed, IN THIS PR. The packs going one\n' + 'release out of date is how objectui#3582 and objectui#3625 happened: eight packs kept\n' + 'serving a retired sentence, one of them as an idiomatic translation that every other\n' + 'gate reads as perfectly healthy. Nothing downstream can find it again.\n' + diff --git a/ui/scripts/check-lint-rule-coverage.mjs b/ui/scripts/check-lint-rule-coverage.mjs index b98252c45b3..1ede07e8657 100644 --- a/ui/scripts/check-lint-rule-coverage.mjs +++ b/ui/scripts/check-lint-rule-coverage.mjs @@ -623,6 +623,17 @@ export const UNREACHED_GROUPS = [ 'does not take.', card: 'objectui#13472', }, + { + glob: 'packages/i18n/scripts/zh-hant.d.mts', + reason: + 'The hand-written declaration twin of `packages/i18n/scripts/zh-hant.mjs`, the converter that generates ' + + 'the Traditional Chinese pack; the package test `src/__tests__/zh-hant-generated.test.ts` (compiled with ' + + '`allowJs: false`) imports it typed through this twin and pins the twin\'s names to the module\'s real ' + + 'exports. It holds `export declare` lines and no executable code. Declared rather than repaired for the ' + + 'same reason as the rows above: reaching a `.d.mts` means widening a rule-bearing `files` glob to ' + + '`.mts`, a rule STRENGTH decision this gate reports and does not take.', + card: 'objectstack#30511', + }, ]; /**