From 34c9ccbdd4c831a7846c2d769a3f163c0f4f53f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:35:36 +0000 Subject: [PATCH 01/17] feat(i18n)!: read the platform locale table, add a generated zh-Hant pack, key packs by table tag The Console's language packs are now the platform locale table (PLATFORM_LOCALES in @objectstack/spec/system): one pack per tag, keyed by the tag (zh-Hans, zh-Hant, pt-BR). zh-Hant is generated from zh-Hans by scripts/gen-zh-hant.mjs (OpenCC s2twp, pinned at 1.3.1, with the www.objectos.ai overrides plus five straddle repairs found reading the pack). Stored and browser codes keep resolving through the table's read aliases: resolveBuiltInLanguage (new export) reads zh/zh-CN as zh-Hans, zh-TW/zh-HK as zh-Hant, pt as pt-BR, then each shorter prefix; createI18n's fallbackLng maps every alias to its tag. The app-shell splash, outbound agent text and flow permission line read the snapshot by tag. The parity, outbound, eager-catalogue and en-drift checks read the table; en-drift reads a follower pack absent at the base as new. Claude-Session: https://claude.ai/code/session_01L6bhgThgWPADcpU1xXCBsw Co-authored-by: Claude --- pnpm-lock.yaml | 11 + .../__tests__/i18nAfterSignIn-12034.test.tsx | 2 +- .../src/pages/SharedRecordPage-26767.test.tsx | 2 +- .../__tests__/AcceptInvitationRoute.test.tsx | 4 +- .../LoginPage.ssoLabels-12762.test.tsx | 2 +- ...loginFormLabels-everyMember-12762.test.tsx | 2 +- .../registerRefusalCodes-10998.test.tsx | 2 +- ...nsolePagesLocale-secondPass-13460.test.tsx | 2 +- .../consolePagesLocale-13460.test.tsx | 2 +- .../flowRunsClientDiagnostic-25916.test.tsx | 2 +- .../SettingsField.chipLocale-12821.test.tsx | 2 +- ui/apps/console/vite.config.ts | 23 +- .../app-shell/src/chrome/LoadingScreen.tsx | 16 +- .../src/console/ai/outboundAgentText.ts | 20 +- .../defaultLoginSsoPhoneLabels-12762.test.tsx | 4 +- .../loginRefusalCodes-11058.test.tsx | 4 +- .../registerRefusalCodes-11030.test.tsx | 4 +- .../acceptInvitationInviter-25886.test.tsx | 2 +- .../__tests__/orgErrorMessage-4474.test.ts | 2 +- ...ctionRuntime.apiFlowRefusal-27487.test.tsx | 2 +- ...eActionRuntime.flowRowGuard-13397.test.tsx | 2 +- ...ObjectActions.deleteCascade-28472.test.tsx | 2 +- ...eObjectActions.deleteDialog-11695.test.tsx | 2 +- .../launcherPlanApprovalMarker-11666.test.tsx | 2 +- .../__tests__/consoleServerAction.test.tsx | 2 +- .../src/utils/__tests__/flowResponse.test.ts | 4 +- .../app-shell/src/utils/flowResponse.ts | 12 +- ...ailView.deleteConfirmDialog-11001.test.tsx | 2 +- ...ilView.reactionWriteFailure-10899.test.tsx | 4 +- .../src/views/RecordFormPage.i18n.test.tsx | 4 +- ...achmentsPanel.deleteConfirm-12248.test.tsx | 2 +- ...sionPanel.eligibleApprovers-12781.test.tsx | 2 +- .../renderer-copy-locale-25916.test.tsx | 2 +- .../share-dialog-locale-13460.test.tsx | 2 +- ui/packages/i18n/README.md | 74 +- ui/packages/i18n/package.json | 7 +- ui/packages/i18n/scripts/gen-zh-hant.mjs | 58 + ui/packages/i18n/scripts/zh-hant.d.mts | 39 + ui/packages/i18n/scripts/zh-hant.mjs | 366 ++ .../__tests__/all-locales-key-parity.test.ts | 36 +- ...fieldDesigner-formula-retired-6310.test.ts | 3 +- ...ner-validation-rules-retired-11434.test.ts | 3 +- .../approvalOverride-zh-wording-13402.test.ts | 2 +- .../__tests__/auth-namespace-3546.test.tsx | 19 +- ...AggregateLabels-locale-parity-7258.test.ts | 5 +- .../common-search-retired-4392.test.ts | 7 +- .../confirmNoteSeparate-11591.test.ts | 13 +- .../console-copy-system-noun-12956.test.ts | 2 +- .../__tests__/console-namespace-3546.test.tsx | 9 +- .../__tests__/consoleNoRecordId-13397.test.ts | 2 +- .../__tests__/count-families-11445.test.ts | 3 +- ...board-emptyState-keys-retired-7125.test.ts | 3 +- .../dead-key-batch-retired-4730.test.ts | 3 +- .../dead-pack-keys-retired-8754.test.ts | 5 +- .../src/__tests__/ellipsis-glyph-3878.test.ts | 2 +- .../src/__tests__/en-zh-key-parity.test.ts | 2 +- .../__tests__/entry-locale-shape-7479.test.ts | 19 +- ...fallback-placeholder-spelling-3512.test.ts | 3 +- .../gantt-count-interpolation-4157.test.ts | 3 +- .../gantt-quickfilter-locale-parity.test.ts | 5 +- .../gridImportSavedMapping-i18n-3920.test.ts | 34 +- .../groupingPartialRetired-10881.test.ts | 2 +- ui/packages/i18n/src/__tests__/i18n.test.ts | 14 +- .../invitation-screens-27521.test.ts | 3 +- .../makerEditChips-v1-scope-7709.test.ts | 17 +- .../makerEmptyState-v1-scope-8329.test.ts | 22 +- .../makerStartChips-v1-scope-1984.test.ts | 17 +- ...arketplace-failedHint-retired-5504.test.ts | 3 +- ...arketplace-preview-namespace-3546.test.tsx | 35 +- ...chPlaceholder-manifestJargon-10969.test.ts | 5 +- ...bjectView-config-keys-retired-4730.test.ts | 5 +- .../objectView-value-language-3582.test.ts | 6 +- .../organization-namespace-3546.test.tsx | 16 +- ...ganizations-picker-vocabulary-5215.test.ts | 5 +- .../__tests__/outbound-agent-messages.test.ts | 27 +- .../perm-home-namespace-3546.test.tsx | 13 +- .../__tests__/plural-categories-11432.test.ts | 3 +- .../provider-load-rerender-10382.test.tsx | 4 +- .../raw-key-call-sites-3546.test.tsx | 9 +- .../record-delete-permanent-27823.test.ts | 3 +- .../recordSelectionRefusal-13384.test.ts | 4 +- .../region-locale-fallback-11326.test.tsx | 227 +- .../report-editor-retired-4145.test.ts | 5 +- .../residue-namespaces-3546.test.tsx | 47 +- .../searchItemsAvailable-plural-9664.test.ts | 5 +- ...showEmptyRelated-plural-base-3863.test.tsx | 10 +- .../studioAccessGrantor-27521.test.ts | 6 +- ...tarea-charactercount-locale-parity.test.ts | 3 +- .../untranslated-identity-4376.test.ts | 2 +- ...lidation-list-joiner-locale-parity.test.ts | 6 +- ...viewReadonlyTooltip-semantics-3625.test.ts | 16 +- .../src/__tests__/zh-hant-generated.test.ts | 96 + ui/packages/i18n/src/i18n.ts | 63 +- ui/packages/i18n/src/index.ts | 23 +- ui/packages/i18n/src/locales/index.ts | 74 +- .../i18n/src/locales/{pt.ts => pt-BR.ts} | 0 ui/packages/i18n/src/locales/registry.ts | 169 +- .../i18n/src/locales/{zh.ts => zh-Hans.ts} | 6 +- ui/packages/i18n/src/locales/zh-Hant.ts | 5163 +++++++++++++++++ ...darView.a11yLabelsLocalized-10104.test.tsx | 2 +- ...hartImpl.sankeyFunnelLabels-13404.test.tsx | 2 +- ...ancedChartImpl.scatterSeriesArity.test.tsx | 2 +- .../RecordMetaFooter.ownerOnce-28868.test.tsx | 2 +- .../ObjectView.gridDelete-10383.test.tsx | 2 +- .../check-eager-closure-budget.test.ts | 16 +- .../check-eager-locale-catalogues.test.ts | 59 +- ui/scripts/check-eager-locale-catalogues.mjs | 34 +- ui/scripts/check-i18n-dead-keys.mjs | 29 +- ui/scripts/check-i18n-en-drift.mjs | 90 +- 109 files changed, 6736 insertions(+), 517 deletions(-) create mode 100644 ui/packages/i18n/scripts/gen-zh-hant.mjs create mode 100644 ui/packages/i18n/scripts/zh-hant.d.mts create mode 100644 ui/packages/i18n/scripts/zh-hant.mjs create mode 100644 ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts rename ui/packages/i18n/src/locales/{pt.ts => pt-BR.ts} (100%) rename ui/packages/i18n/src/locales/{zh.ts => zh-Hans.ts} (99%) create mode 100644 ui/packages/i18n/src/locales/zh-Hant.ts 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/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/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..054bc182df9 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 @@ -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)', () => { 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.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/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__/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/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/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 02051758ea7..38469538b9c 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx @@ -181,7 +181,7 @@ describe('RecordFormPage title resolves from the locale packs (#3469)', () => { // 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' }); - expect(title()).toBe(String(builtInLocales.zh.form.createTitle).replace('{{object}}', 'Contacts')); + expect(title()).toBe(String(builtInLocales['zh-Hans'].form.createTitle).replace('{{object}}', 'Contacts')); expect(title()).toBe('新建Contacts'); }); }); @@ -283,7 +283,7 @@ describe('the ADR-0105 write-target badge resolves from the locale packs (#3517) // reverting to English. const badge = await badgeIn('zh'); 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.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/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/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 b9deb68bea3..7900105a41d 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 } 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', () => { 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..71ca938c79b 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,7 +37,7 @@ 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 zh from '../locales/zh-Hans'; /** The card's config — not a built-in code, so only the app bundle is in play. */ const ZH_CN = { defaultLanguage: 'zh-CN', detectBrowserLanguage: false } as const; @@ -303,7 +303,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( 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 eae9daced63..9f0038a8fed 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; } /** @@ -231,7 +268,7 @@ export function createI18n(config: I18nConfig = {}): I18nInstance { // so the discovery fetch never runs and the splash never appears. instance.use(initReactI18next).init({ lng, - fallbackLng: fallbackLanguage, + fallbackLng: fallbackLanguagesFor(fallbackLanguage), resources: mergedResources, interpolation: { escapeValue: false, // React already escapes 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/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx index a2a30c37b05..159a8f5bf49 100644 --- a/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx +++ b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx @@ -130,7 +130,7 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => const { container } = renderAt('zh') 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) }) 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-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx index 4b210a5a4f9..60914f80e60 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 @@ -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-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/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' + From c8578107527c934871b12b3b4857047c3450de67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:35:36 +0000 Subject: [PATCH 02/17] feat(i18n)!: read the platform locale table, add a generated zh-Hant pack, key packs by table tag The Console's language packs are now the platform locale table (PLATFORM_LOCALES in @objectstack/spec/system): one pack per tag, keyed by the tag (zh-Hans, zh-Hant, pt-BR). zh-Hant is generated from zh-Hans by scripts/gen-zh-hant.mjs (OpenCC s2twp, pinned at 1.3.1, with the www.objectos.ai overrides, four straddle repairs and one phrase override found reading the pack). Stored and browser codes keep resolving through the table's read aliases: resolveBuiltInLanguage (new export) reads zh/zh-CN as zh-Hans, zh-TW/zh-HK as zh-Hant, pt as pt-BR, then each shorter prefix; createI18n's fallbackLng maps every alias to its tag. The app-shell splash, outbound agent text and flow permission line read the snapshot by tag. The parity, outbound, eager-catalogue and en-drift checks read the table; en-drift reads a follower pack absent at the base as new. Claude-Session: https://claude.ai/code/session_01L6bhgThgWPADcpU1xXCBsw Co-authored-by: Claude --- pnpm-lock.yaml | 11 + .../__tests__/i18nAfterSignIn-12034.test.tsx | 2 +- .../src/pages/SharedRecordPage-26767.test.tsx | 2 +- .../__tests__/AcceptInvitationRoute.test.tsx | 4 +- .../LoginPage.ssoLabels-12762.test.tsx | 2 +- ...loginFormLabels-everyMember-12762.test.tsx | 2 +- .../registerRefusalCodes-10998.test.tsx | 2 +- ...nsolePagesLocale-secondPass-13460.test.tsx | 2 +- .../consolePagesLocale-13460.test.tsx | 2 +- .../flowRunsClientDiagnostic-25916.test.tsx | 2 +- .../SettingsField.chipLocale-12821.test.tsx | 2 +- ui/apps/console/vite.config.ts | 23 +- .../app-shell/src/chrome/LoadingScreen.tsx | 16 +- .../src/console/ai/outboundAgentText.ts | 20 +- .../defaultLoginSsoPhoneLabels-12762.test.tsx | 4 +- .../loginRefusalCodes-11058.test.tsx | 4 +- .../registerRefusalCodes-11030.test.tsx | 4 +- .../acceptInvitationInviter-25886.test.tsx | 2 +- .../__tests__/orgErrorMessage-4474.test.ts | 2 +- ...ctionRuntime.apiFlowRefusal-27487.test.tsx | 2 +- ...eActionRuntime.flowRowGuard-13397.test.tsx | 2 +- ...ObjectActions.deleteCascade-28472.test.tsx | 2 +- ...eObjectActions.deleteDialog-11695.test.tsx | 2 +- .../launcherPlanApprovalMarker-11666.test.tsx | 2 +- .../__tests__/consoleServerAction.test.tsx | 2 +- .../src/utils/__tests__/flowResponse.test.ts | 4 +- .../app-shell/src/utils/flowResponse.ts | 12 +- ...ailView.deleteConfirmDialog-11001.test.tsx | 2 +- ...ilView.reactionWriteFailure-10899.test.tsx | 4 +- .../src/views/RecordFormPage.i18n.test.tsx | 4 +- ...achmentsPanel.deleteConfirm-12248.test.tsx | 2 +- ...sionPanel.eligibleApprovers-12781.test.tsx | 2 +- .../renderer-copy-locale-25916.test.tsx | 2 +- .../share-dialog-locale-13460.test.tsx | 2 +- ui/packages/i18n/README.md | 74 +- ui/packages/i18n/package.json | 7 +- ui/packages/i18n/scripts/gen-zh-hant.mjs | 58 + ui/packages/i18n/scripts/zh-hant.d.mts | 39 + ui/packages/i18n/scripts/zh-hant.mjs | 366 ++ .../__tests__/all-locales-key-parity.test.ts | 36 +- ...fieldDesigner-formula-retired-6310.test.ts | 3 +- ...ner-validation-rules-retired-11434.test.ts | 3 +- .../approvalOverride-zh-wording-13402.test.ts | 2 +- .../__tests__/auth-namespace-3546.test.tsx | 19 +- ...AggregateLabels-locale-parity-7258.test.ts | 5 +- .../common-search-retired-4392.test.ts | 7 +- .../confirmNoteSeparate-11591.test.ts | 13 +- .../console-copy-system-noun-12956.test.ts | 2 +- .../__tests__/console-namespace-3546.test.tsx | 9 +- .../__tests__/consoleNoRecordId-13397.test.ts | 2 +- .../__tests__/count-families-11445.test.ts | 3 +- ...board-emptyState-keys-retired-7125.test.ts | 3 +- .../dead-key-batch-retired-4730.test.ts | 3 +- .../dead-pack-keys-retired-8754.test.ts | 5 +- .../src/__tests__/ellipsis-glyph-3878.test.ts | 2 +- .../src/__tests__/en-zh-key-parity.test.ts | 2 +- .../__tests__/entry-locale-shape-7479.test.ts | 19 +- ...fallback-placeholder-spelling-3512.test.ts | 3 +- .../gantt-count-interpolation-4157.test.ts | 3 +- .../gantt-quickfilter-locale-parity.test.ts | 5 +- .../gridImportSavedMapping-i18n-3920.test.ts | 34 +- .../groupingPartialRetired-10881.test.ts | 2 +- ui/packages/i18n/src/__tests__/i18n.test.ts | 14 +- .../invitation-screens-27521.test.ts | 3 +- .../makerEditChips-v1-scope-7709.test.ts | 17 +- .../makerEmptyState-v1-scope-8329.test.ts | 22 +- .../makerStartChips-v1-scope-1984.test.ts | 17 +- ...arketplace-failedHint-retired-5504.test.ts | 3 +- ...arketplace-preview-namespace-3546.test.tsx | 35 +- ...chPlaceholder-manifestJargon-10969.test.ts | 5 +- ...bjectView-config-keys-retired-4730.test.ts | 5 +- .../objectView-value-language-3582.test.ts | 6 +- .../organization-namespace-3546.test.tsx | 16 +- ...ganizations-picker-vocabulary-5215.test.ts | 5 +- .../__tests__/outbound-agent-messages.test.ts | 27 +- .../perm-home-namespace-3546.test.tsx | 13 +- .../__tests__/plural-categories-11432.test.ts | 3 +- .../provider-load-rerender-10382.test.tsx | 4 +- .../raw-key-call-sites-3546.test.tsx | 9 +- .../record-delete-permanent-27823.test.ts | 3 +- .../recordSelectionRefusal-13384.test.ts | 4 +- .../region-locale-fallback-11326.test.tsx | 227 +- .../report-editor-retired-4145.test.ts | 5 +- .../residue-namespaces-3546.test.tsx | 47 +- .../searchItemsAvailable-plural-9664.test.ts | 5 +- ...showEmptyRelated-plural-base-3863.test.tsx | 10 +- .../studioAccessGrantor-27521.test.ts | 6 +- ...tarea-charactercount-locale-parity.test.ts | 3 +- .../untranslated-identity-4376.test.ts | 2 +- ...lidation-list-joiner-locale-parity.test.ts | 6 +- ...viewReadonlyTooltip-semantics-3625.test.ts | 16 +- .../src/__tests__/zh-hant-generated.test.ts | 96 + ui/packages/i18n/src/i18n.ts | 63 +- ui/packages/i18n/src/index.ts | 23 +- ui/packages/i18n/src/locales/index.ts | 74 +- .../i18n/src/locales/{pt.ts => pt-BR.ts} | 0 ui/packages/i18n/src/locales/registry.ts | 169 +- .../i18n/src/locales/{zh.ts => zh-Hans.ts} | 6 +- ui/packages/i18n/src/locales/zh-Hant.ts | 5163 +++++++++++++++++ ...darView.a11yLabelsLocalized-10104.test.tsx | 2 +- ...hartImpl.sankeyFunnelLabels-13404.test.tsx | 2 +- ...ancedChartImpl.scatterSeriesArity.test.tsx | 2 +- .../RecordMetaFooter.ownerOnce-28868.test.tsx | 2 +- .../ObjectView.gridDelete-10383.test.tsx | 2 +- .../check-eager-closure-budget.test.ts | 16 +- .../check-eager-locale-catalogues.test.ts | 59 +- ui/scripts/check-eager-locale-catalogues.mjs | 34 +- ui/scripts/check-i18n-dead-keys.mjs | 29 +- ui/scripts/check-i18n-en-drift.mjs | 90 +- 109 files changed, 6736 insertions(+), 517 deletions(-) create mode 100644 ui/packages/i18n/scripts/gen-zh-hant.mjs create mode 100644 ui/packages/i18n/scripts/zh-hant.d.mts create mode 100644 ui/packages/i18n/scripts/zh-hant.mjs create mode 100644 ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts rename ui/packages/i18n/src/locales/{pt.ts => pt-BR.ts} (100%) rename ui/packages/i18n/src/locales/{zh.ts => zh-Hans.ts} (99%) create mode 100644 ui/packages/i18n/src/locales/zh-Hant.ts 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/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/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..054bc182df9 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 @@ -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)', () => { 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.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/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__/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/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/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 02051758ea7..38469538b9c 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx @@ -181,7 +181,7 @@ describe('RecordFormPage title resolves from the locale packs (#3469)', () => { // 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' }); - expect(title()).toBe(String(builtInLocales.zh.form.createTitle).replace('{{object}}', 'Contacts')); + expect(title()).toBe(String(builtInLocales['zh-Hans'].form.createTitle).replace('{{object}}', 'Contacts')); expect(title()).toBe('新建Contacts'); }); }); @@ -283,7 +283,7 @@ describe('the ADR-0105 write-target badge resolves from the locale packs (#3517) // reverting to English. const badge = await badgeIn('zh'); 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.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/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/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 b9deb68bea3..7900105a41d 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 } 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', () => { 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..71ca938c79b 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,7 +37,7 @@ 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 zh from '../locales/zh-Hans'; /** The card's config — not a built-in code, so only the app bundle is in play. */ const ZH_CN = { defaultLanguage: 'zh-CN', detectBrowserLanguage: false } as const; @@ -303,7 +303,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( 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 eae9daced63..9f0038a8fed 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; } /** @@ -231,7 +268,7 @@ export function createI18n(config: I18nConfig = {}): I18nInstance { // so the discovery fetch never runs and the splash never appears. instance.use(initReactI18next).init({ lng, - fallbackLng: fallbackLanguage, + fallbackLng: fallbackLanguagesFor(fallbackLanguage), resources: mergedResources, interpolation: { escapeValue: false, // React already escapes 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/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx index a2a30c37b05..159a8f5bf49 100644 --- a/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx +++ b/ui/packages/plugin-calendar/src/CalendarView.a11yLabelsLocalized-10104.test.tsx @@ -130,7 +130,7 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => const { container } = renderAt('zh') 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) }) 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-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx b/ui/packages/plugin-detail/src/__tests__/RecordMetaFooter.ownerOnce-28868.test.tsx index 4b210a5a4f9..60914f80e60 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 @@ -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-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/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' + From bf4fb90541f4f4744e7f71e328d1db7a865067f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 14:54:07 +0000 Subject: [PATCH 03/17] fix(ui): read every pack by its platform locale tag outside the i18n package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suites outside ui/packages/i18n that hard-coded ten packs, a zh / pt key, a 'zh' pack lookup or the menu's 中文 entry now read the platform locale table or the tag. Three readers keyed by the old codes are fixed with them. The provider wrote a booted alias's catalogue (zh, zh-TW, pt) a second time under the alias, an added event that re-rendered every reader and refetched every chart for a catalogue already resident; it now writes under the tag. A stored zh-TW locale took the first offered zh variant (zh-Hans); pickRenderableLocale now takes the first offered code the table reads as the same language first. The page-tab label dictionary served its Simplified rows to every non-exact zh session; a zh-Hant, zh-HK or zh-MO session reads its Traditional rows. The en-drift fixtures write a const that is not named by the tag, and a new describe pins the base-absent follower rule. The generated zh-Hant pack is recorded as not driver-managed in the merge-driver ledger, and the converter's declaration twin joins the package's test program. The changeset declares the change. Claude-Session: https://claude.ai/code/session_01L6bhgThgWPADcpU1xXCBsw Co-authored-by: Claude --- ...0511-console-packs-platform-locale-tags.md | 28 ++++ scripts/regen-artifacts.mjs | 15 ++ .../StudioRoute.accessNotice-12035.test.tsx | 2 +- .../registerRefusalCodes-10998.test.tsx | 8 +- .../LoadingScreen.bootCopy-12187.test.tsx | 3 +- .../__tests__/appTileOpenedLine-12601.test.ts | 7 +- ...ketplacePage.sameOriginHint-11726.test.tsx | 8 +- ...placeRowActions.installedAppsPins.test.tsx | 23 ++-- .../__tests__/CreateWorkspaceDialog.test.tsx | 8 +- .../members-role-gating-4475.test.tsx | 7 +- .../hooks/__tests__/userLocale-10059.test.tsx | 46 ++++++- .../app-shell/src/hooks/useUserLocale.ts | 22 ++- .../LocaleSwitcher.appLocales.test.tsx | 27 ++-- .../LocaleSwitcher.persistence.test.tsx | 7 +- ...aleSwitcher.unsavedEditsAsk-13254.test.tsx | 23 ++-- ...bjectDataPage.filterChipI18n-9159.test.tsx | 18 +-- .../src/views/RecordFormPage.i18n.test.tsx | 9 +- ...cisionPanel.decidedSnapshot-27502.test.tsx | 5 +- ...valDecisionPanel.flowOrigin-12491.test.tsx | 6 +- .../ApprovalDecisionPanel.test.tsx | 11 +- ...page-tabs-builtin-label-i18n-4645.test.tsx | 56 +++++++- .../src/renderers/layout/containers.tsx | 22 ++- ...ocationField.placeholderI18n-8149.test.tsx | 2 +- .../LocationField.residueI18n-6888.test.tsx | 18 +-- .../numberBadInput.i18n-8148.test.tsx | 22 +-- .../provider-load-rerender-10382.test.tsx | 51 ++++++- ui/packages/i18n/src/provider.tsx | 33 +++-- ui/packages/i18n/tsconfig.test.json | 2 +- ...darView.a11yLabelsLocalized-10104.test.tsx | 18 +-- .../toolLabels-locale-parity-7481.test.ts | 15 +- .../toolLabels-sentences-12724.test.ts | 5 +- .../RecordMetaFooter.ownerOnce-28868.test.tsx | 2 +- ...terDetailForm.lateCatalogue-12924.test.tsx | 8 +- .../__tests__/check-i18n-en-drift.test.ts | 129 ++++++++++++++---- 34 files changed, 489 insertions(+), 177 deletions(-) create mode 100644 .changeset/30511-console-packs-platform-locale-tags.md 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/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/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/auth/__tests__/registerRefusalCodes-10998.test.tsx b/ui/apps/console/src/pages/auth/__tests__/registerRefusalCodes-10998.test.tsx index 054bc182df9..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) { @@ -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/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/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__/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/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/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/RecordFormPage.i18n.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.i18n.test.tsx index 38469538b9c..923d3d3f1e7 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,7 +181,7 @@ 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' }); expect(title()).toBe(String(builtInLocales['zh-Hans'].form.createTitle).replace('{{object}}', 'Contacts')); expect(title()).toBe('新建Contacts'); }); @@ -244,8 +245,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); }); }); @@ -281,7 +282,7 @@ 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-Hans'].form.createTargetOrg).replace('{{org}}', 'Acme Inc'), ); 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.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/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index f2c8df73f59..8d464fa5453 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__/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/src/__tests__/provider-load-rerender-10382.test.tsx b/ui/packages/i18n/src/__tests__/provider-load-rerender-10382.test.tsx index 71ca938c79b..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 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). */ @@ -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/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 159a8f5bf49..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,7 +127,7 @@ 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-Hans'].calendar.today) @@ -135,7 +135,7 @@ describe('objectui#10104 — CalendarView a11y labels come from the pack', () => }) 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-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 60914f80e60..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'], ]; 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/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 From 9070a29f2f799b5c07bd3a67df75a99b5855084c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:44:59 +0000 Subject: [PATCH 04/17] test(fields): boot the read-only boolean word cases in zh-Hans The suite types its language by the keys of builtInLocales, which are now the platform locale table's tags, so its zh cases name the zh-Hans pack. Claude-Session: https://claude.ai/code/session_01L6bhgThgWPADcpU1xXCBsw Co-authored-by: Claude --- .../__tests__/booleanValueLabel.locale-11689.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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', () => { From 9571f5e2de155132bdea81135bafeb2e9d60eb1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 16:12:07 +0000 Subject: [PATCH 05/17] test(ui): the ui script suites read the renamed packs and the converter's twin A doc link and a test comment named the old zh.ts path; they name zh-Hans.ts. The nested-layout drift case pinned that a pack missing at the base throws; under the follower rule only en does, and a follower the base lacks reads as empty and is named, so the case pins both. The lint-rule-coverage ledger declares the converter's declaration twin, packages/i18n/scripts/zh-hant.d.mts, beside the two script twins it already declares, and its tests accept an objectstack card on a row. Claude-Session: https://claude.ai/code/session_01L6bhgThgWPADcpU1xXCBsw Co-authored-by: Claude --- .../HomeItemTypeLabel.parity.test.tsx | 2 +- .../plugin-gantt/docs/verification/README.md | 2 +- .../check-lint-rule-coverage.test.ts | 22 ++++++++++++------- .../__tests__/git-paths-nested-layout.test.ts | 21 +++++++++++++----- ui/scripts/check-lint-rule-coverage.mjs | 11 ++++++++++ 5 files changed, 43 insertions(+), 15 deletions(-) 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/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/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-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', + }, ]; /** From db9b3494782eb75814a882e9c95cdad1553a1b3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:39:54 +0000 Subject: [PATCH 06/17] test(i18n): read the marketplace namespace's catalogues by table tag The marketplace shape, lazy-load and console gate suites name the zh-Hans / zh-Hant / pt-BR catalogues and packs; a stored zh-TW reader is pinned to the Traditional catalogue; the freshness suite holds every zh-Hant target the generator writes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ...rketplacePage.namespaceGate-30588.test.tsx | 15 +++-- .../marketplace-namespace-lazy.test.tsx | 30 ++++++--- .../__tests__/marketplace-namespace.test.ts | 47 ++++++++------ .../src/__tests__/zh-hant-generated.test.ts | 62 +++++++++++++------ .../__tests__/check-i18n-en-drift.test.ts | 2 +- 5 files changed, 104 insertions(+), 52 deletions(-) diff --git a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.namespaceGate-30588.test.tsx b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.namespaceGate-30588.test.tsx index 6862792a878..50852fc62ec 100644 --- a/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.namespaceGate-30588.test.tsx +++ b/ui/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.namespaceGate-30588.test.tsx @@ -94,13 +94,14 @@ import { NotificationBanners } from '@object-ui/components'; import { builtInLocales } from '@object-ui/i18n/locales'; import { BUILT_IN_LANGUAGE_CODES, registerBuiltInLocale } from '@object-ui/i18n/locales/registry'; import en from '@object-ui/i18n/locales/en'; -import zh from '@object-ui/i18n/locales/zh'; +import zhHans from '@object-ui/i18n/locales/zh-Hans'; +import zhHant from '@object-ui/i18n/locales/zh-Hant'; import ja from '@object-ui/i18n/locales/ja'; import ko from '@object-ui/i18n/locales/ko'; import de from '@object-ui/i18n/locales/de'; import fr from '@object-ui/i18n/locales/fr'; import es from '@object-ui/i18n/locales/es'; -import pt from '@object-ui/i18n/locales/pt'; +import ptBr from '@object-ui/i18n/locales/pt-BR'; import ru from '@object-ui/i18n/locales/ru'; import ar from '@object-ui/i18n/locales/ar'; import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; @@ -108,7 +109,9 @@ import { MarketplacePage } from '../MarketplacePage'; import { MarketplacePackagePage } from '../MarketplacePackagePage'; import { rememberSampleData, resetSampleDataSessionForTesting } from '../sampleDataNotice'; -const PACKS: Record> = { en, zh, ja, ko, de, fr, es, pt, ru, ar }; +const PACKS: Record> = { + en, 'zh-Hans': zhHans, 'zh-Hant': zhHant, ja, ko, de, fr, es, 'pt-BR': ptBr, ru, ar, +}; for (const code of BUILT_IN_LANGUAGE_CODES) registerBuiltInLocale(code, PACKS[code]); const ENGLISH = builtInLocales.en.marketplace; @@ -177,7 +180,7 @@ function renderRecording(page: ReactElement, language: string) { describe('the marketplace pages wait for their strings (objectstack#30588)', () => { it('zh-CN: the catalogue page shows the load skeleton, then Chinese, and never English or a raw key', async () => { - const zhMarketplace = builtInLocales.zh.marketplace; + const zhMarketplace = builtInLocales['zh-Hans'].marketplace; const view = renderRecording(, 'zh-CN'); // The first frame holds no namespace text yet — in no language, and not // as a key — only the skeleton. @@ -252,7 +255,7 @@ describe('the marketplace pages wait for their strings (objectstack#30588)', () } const view = renderRecording(, 'zh-CN'); - expect((await screen.findAllByText(builtInLocales.zh.marketplace.back)).length).toBeGreaterThan(0); + expect((await screen.findAllByText(builtInLocales['zh-Hans'].marketplace.back)).length).toBeGreaterThan(0); // The switch sends the frame back to its skeleton until the Japanese // catalogue lands, which unmounts the page body. That is not a leave. @@ -261,7 +264,7 @@ describe('the marketplace pages wait for their strings (objectstack#30588)', () }); expect((await screen.findAllByText(builtInLocales.ja.marketplace.back)).length).toBeGreaterThan(0); const beforeLeaving = (await view.shown()).join('\n'); - for (const pack of [builtInLocales.en, builtInLocales.zh, builtInLocales.ja]) expect(beforeLeaving).not.toContain(banner(pack)); + for (const pack of [builtInLocales.en, builtInLocales['zh-Hans'], builtInLocales.ja]) expect(beforeLeaving).not.toContain(banner(pack)); // Leaving the page is: the banner follows, in the console's language. await act(async () => { diff --git a/ui/packages/i18n/src/__tests__/marketplace-namespace-lazy.test.tsx b/ui/packages/i18n/src/__tests__/marketplace-namespace-lazy.test.tsx index b22f9613f09..1dc8d5b35d0 100644 --- a/ui/packages/i18n/src/__tests__/marketplace-namespace-lazy.test.tsx +++ b/ui/packages/i18n/src/__tests__/marketplace-namespace-lazy.test.tsx @@ -19,7 +19,7 @@ * The DOM setup (`vitest.setup.i18n-catalogues.ts`) calls * `registerBuiltInLocales()`, which registers every language COMPLETE — * namespace included — so a page would never need a fetch, which is the - * all-ten door's promise and not what a console visitor gets. This file + * all-packs door's promise and not what a console visitor gets. This file * re-registers each language as its pack ALONE, at module scope: the registry * a console that awaited `preloadBootstrapLocale()` holds. A `dom` project * isolates each file's module graph, so nothing here reaches another file, @@ -32,17 +32,19 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { I18nProvider, useI18nContext, useObjectTranslation } from '../provider.js'; import { BUILT_IN_LANGUAGE_CODES, registerBuiltInLocale } from '../locales/registry.js'; import enPack from '../locales/en.js'; -import zhPack from '../locales/zh.js'; +import zhHansPack from '../locales/zh-Hans.js'; +import zhHantPack from '../locales/zh-Hant.js'; import jaPack from '../locales/ja.js'; import koPack from '../locales/ko.js'; import dePack from '../locales/de.js'; import frPack from '../locales/fr.js'; import esPack from '../locales/es.js'; -import ptPack from '../locales/pt.js'; +import ptBrPack from '../locales/pt-BR.js'; import ruPack from '../locales/ru.js'; import arPack from '../locales/ar.js'; import enNs from '../namespaces/marketplace/en.js'; -import zhNs from '../namespaces/marketplace/zh.js'; +import zhNs from '../namespaces/marketplace/zh-Hans.js'; +import zhHantNs from '../namespaces/marketplace/zh-Hant.js'; import jaNs from '../namespaces/marketplace/ja.js'; import { isMarketplaceCatalogueLoaded, @@ -58,8 +60,8 @@ vi.mock('../namespaces/marketplace/ko.js', async (importOriginal) => { }); const PACKS: Record> = { - en: enPack, zh: zhPack, ja: jaPack, ko: koPack, de: dePack, - fr: frPack, es: esPack, pt: ptPack, ru: ruPack, ar: arPack, + en: enPack, 'zh-Hans': zhHansPack, 'zh-Hant': zhHantPack, ja: jaPack, ko: koPack, de: dePack, + fr: frPack, es: esPack, 'pt-BR': ptBrPack, ru: ruPack, ar: arPack, }; for (const code of BUILT_IN_LANGUAGE_CODES) registerBuiltInLocale(code, PACKS[code]); @@ -114,7 +116,7 @@ const EN_TITLE = enNs.marketplace.title; describe('marketplace namespace — lazy', () => { it('a Chinese page waits, then renders Chinese from its first render', async () => { - expect(isMarketplaceCatalogueLoaded('zh')).toBe(false); + expect(isMarketplaceCatalogueLoaded('zh-Hans')).toBe(false); expect(isMarketplaceCatalogueLoaded('en')).toBe(false); renderIn('zh-CN'); @@ -128,9 +130,21 @@ describe('marketplace namespace — lazy', () => { expect(seen.length).toBeGreaterThan(0); expect(seen.filter((entry) => entry !== `zh-CN|${ZH_TITLE}`)).toEqual([]); // What it fetched: its own catalogue and the fallback, nothing else. - expect(isMarketplaceCatalogueLoaded('zh')).toBe(true); + expect(isMarketplaceCatalogueLoaded('zh-Hans')).toBe(true); expect(isMarketplaceCatalogueLoaded('en')).toBe(true); expect(isMarketplaceCatalogueLoaded('ja')).toBe(false); + expect(isMarketplaceCatalogueLoaded('zh-Hant')).toBe(false); + }); + + it('a Traditional Chinese reader (a stored zh-TW) gets the zh-Hant catalogue, not the Simplified one', async () => { + renderIn('zh-TW'); + const title = zhHantNs.marketplace.title; + // Lit control: the two catalogues really differ on the probe key. + expect(title).not.toBe(ZH_TITLE); + expect(await screen.findByText(title)).toBeTruthy(); + expect(screen.getByTestId('refresh').textContent).toBe(zhHantNs.marketplace.refresh); + expect(seen.filter((entry) => entry !== `zh-TW|${title}`)).toEqual([]); + expect(isMarketplaceCatalogueLoaded('zh-Hant')).toBe(true); }); it('a language switch waits again for the new language', async () => { diff --git a/ui/packages/i18n/src/__tests__/marketplace-namespace.test.ts b/ui/packages/i18n/src/__tests__/marketplace-namespace.test.ts index 501b1e216d8..59cc5a8adeb 100644 --- a/ui/packages/i18n/src/__tests__/marketplace-namespace.test.ts +++ b/ui/packages/i18n/src/__tests__/marketplace-namespace.test.ts @@ -1,15 +1,16 @@ /** * The `marketplace` namespace (objectstack#30588): its shape, and how it sits - * beside the ten packs it was cut out of. + * beside the packs it was cut out of: one catalogue per platform locale table + * tag (objectstack#30511), keyed by the tag as the packs are. * - * 1. LAZY BY CONSTRUCTION. `namespaces/marketplace/index.ts` reaches its ten - * catalogues through ten literal `import()`s and nothing else — no static + * 1. LAZY BY CONSTRUCTION. `namespaces/marketplace/index.ts` reaches its + * catalogues through one literal `import()` each and nothing else — no static * import, no template-literal specifier — so each is a chunk of its own * and none is in the console's first load. * 2. CUT, NOT COPIED. Every catalogue holds only the `marketplace` group and * the probe key the page frame waits on; no pack holds the probe key, and * no leaf is in both a pack and its catalogue. - * 3. THE ALL-TEN DOOR IS COMPLETE. `locales/index.ts` hands out each language + * 3. THE ALL-PACKS DOOR IS COMPLETE. `locales/index.ts` hands out each language * as its pack merged with its catalogue, so the parity suites that walk * `builtInLocales` still walk these keys; the package entry's `en` is the * resident pack alone. @@ -30,23 +31,25 @@ import { en as entryEn } from '../index.js'; import { BUILT_IN_LANGUAGE_CODES } from '../locales/registry.js'; import { builtInLocales } from '../locales/index.js'; import enPack from '../locales/en.js'; -import zhPack from '../locales/zh.js'; +import zhHansPack from '../locales/zh-Hans.js'; +import zhHantPack from '../locales/zh-Hant.js'; import jaPack from '../locales/ja.js'; import koPack from '../locales/ko.js'; import dePack from '../locales/de.js'; import frPack from '../locales/fr.js'; import esPack from '../locales/es.js'; -import ptPack from '../locales/pt.js'; +import ptBrPack from '../locales/pt-BR.js'; import ruPack from '../locales/ru.js'; import arPack from '../locales/ar.js'; import enNs from '../namespaces/marketplace/en.js'; -import zhNs from '../namespaces/marketplace/zh.js'; +import zhHansNs from '../namespaces/marketplace/zh-Hans.js'; +import zhHantNs from '../namespaces/marketplace/zh-Hant.js'; import jaNs from '../namespaces/marketplace/ja.js'; import koNs from '../namespaces/marketplace/ko.js'; import deNs from '../namespaces/marketplace/de.js'; import frNs from '../namespaces/marketplace/fr.js'; import esNs from '../namespaces/marketplace/es.js'; -import ptNs from '../namespaces/marketplace/pt.js'; +import ptBrNs from '../namespaces/marketplace/pt-BR.js'; import ruNs from '../namespaces/marketplace/ru.js'; import arNs from '../namespaces/marketplace/ar.js'; import { @@ -63,12 +66,12 @@ const INDEX_PATH = join(HERE, '..', 'namespaces', 'marketplace', 'index.ts'); type Tree = Record; const PACKS: Record = { - en: enPack, zh: zhPack, ja: jaPack, ko: koPack, de: dePack, - fr: frPack, es: esPack, pt: ptPack, ru: ruPack, ar: arPack, + en: enPack, 'zh-Hans': zhHansPack, 'zh-Hant': zhHantPack, ja: jaPack, ko: koPack, de: dePack, + fr: frPack, es: esPack, 'pt-BR': ptBrPack, ru: ruPack, ar: arPack, }; const CATALOGUES: Record = { - en: enNs, zh: zhNs, ja: jaNs, ko: koNs, de: deNs, - fr: frNs, es: esNs, pt: ptNs, ru: ruNs, ar: arNs, + en: enNs, 'zh-Hans': zhHansNs, 'zh-Hant': zhHantNs, ja: jaNs, ko: koNs, de: deNs, + fr: frNs, es: esNs, 'pt-BR': ptBrNs, ru: ruNs, ar: arNs, }; function leaves(node: unknown, prefix = ''): string[] { @@ -109,12 +112,14 @@ describe('marketplace namespace — lazy by construction', () => { }); it('statically imports no catalogue (nothing of it rides in with the page frame)', () => { - expect(staticSpecifiers.filter((s) => /^\.\/[a-z]{2}\.js$/.test(s))).toEqual([]); + // A catalogue specifier is `./.js`: a language, or a language and a + // script or region subtag (`./zh-Hant.js`, `./pt-BR.js`). + expect(staticSpecifiers.filter((s) => /^\.\/[a-z]{2}(?:-[A-Za-z]+)?\.js$/.test(s))).toEqual([]); // Lit control: the static imports were read. expect(staticSpecifiers).toContain('../../locales/registry.js'); }); - it('ships the ten built-in languages', () => { + it('ships one catalogue per built-in language (the platform locale table)', () => { expect([...MARKETPLACE_LANGUAGE_CODES].sort()).toEqual([...BUILT_IN_LANGUAGE_CODES].sort()); }); }); @@ -139,7 +144,7 @@ describe('marketplace namespace — cut out of the packs, not copied', () => { }); }); -describe('marketplace namespace — the all-ten door hands out complete languages', () => { +describe('marketplace namespace — the all-packs door hands out complete languages', () => { it.each(BUILT_IN_LANGUAGE_CODES.map((code) => [code]))('%s: pack keys and namespace keys are both there', (code) => { const complete = (builtInLocales as unknown as Record)[code]; const expected = [...leaves(PACKS[code]), ...leaves(CATALOGUES[code])].sort(); @@ -157,10 +162,14 @@ describe('marketplace namespace — the all-ten door hands out complete language describe('marketplace namespace — language resolution and the loader', () => { it.each([ - ['zh-CN', 'zh'], - ['zh', 'zh'], - ['zh-TW', 'zh'], - ['pt-BR', 'pt'], + ['zh-CN', 'zh-Hans'], + ['zh', 'zh-Hans'], + ['zh-Hans', 'zh-Hans'], + ['zh-TW', 'zh-Hant'], + ['zh-HK', 'zh-Hant'], + ['zh-Hant', 'zh-Hant'], + ['pt', 'pt-BR'], + ['pt-BR', 'pt-BR'], ['ar-EG', 'ar'], ['en-US', 'en'], ['de', 'de'], diff --git a/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts b/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts index 973c323e216..c1a25edda76 100644 --- a/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts +++ b/ui/packages/i18n/src/__tests__/zh-hant-generated.test.ts @@ -1,13 +1,16 @@ // 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). + * Every committed zh-Hant catalogue is exactly what the generator writes from + * its committed zh-Hans source (objectstack#30511, ruling F of + * objectstack#29349): the pack, and each pack-shaped lazy namespace's + * catalogue (`ZH_HANT_TARGETS`). * - * `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: + * `src/locales/zh-Hant.ts` and `src/namespaces/marketplace/zh-Hant.ts` are not + * hand-written: `scripts/gen-zh-hant.mjs` converts their `zh-Hans.ts` sources + * with the rules in `scripts/zh-hant.mjs` (OpenCC `s2twp`, pinned, plus the + * www.objectos.ai overrides). Two edits can leave a 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; @@ -15,7 +18,7 @@ * 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. + * never a hand edit of a `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). * @@ -31,33 +34,56 @@ import { CONVERSION_CASES, GATE_ONLY_KEYS, GENERATED_HEADER, + NAMESPACE_GENERATED_HEADER, + ZH_HANT_TARGETS, conversionFailures, - generateZhHantPack, s2t, } from '../../scripts/zh-hant.mjs'; import zhHans from '../locales/zh-Hans.js'; import zhHant from '../locales/zh-Hant.js'; +import zhHansMarketplace from '../namespaces/marketplace/zh-Hans.js'; +import zhHantMarketplace from '../namespaces/marketplace/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'; +const regenerate = (target: string) => + `run \`pnpm --filter @object-ui/i18n gen:zh-hant\` and commit ${target}`; -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); +describe('every zh-Hant catalogue is generated, and current', () => { + it('generates the pack and the marketplace namespace catalogue', () => { + // Counter-probe: an emptied target list would make the cases below vacuous. + expect(ZH_HANT_TARGETS.map(({ target }) => target)).toEqual([ + 'src/locales/zh-Hant.ts', + 'src/namespaces/marketplace/zh-Hant.ts', + ]); }); - it('opens with the generated marker, so nobody edits it by hand', () => { + it.each(ZH_HANT_TARGETS.map((entry) => [entry.target, entry]))( + '%s is byte-for-byte what the generator writes from its zh-Hans source', + (target, { source, generate }) => { + const committed = read(target); + const generated = generate(read(source)); + // A boolean first, so a stale catalogue fails with the repair rather than + // with a 5,000-line diff; the equality below is what a reviewer then reads. + expect(committed === generated, `${target} is stale: ${regenerate(target)}`).toBe(true); + expect(committed).toBe(generated); + }, + ); + + it('each opens with the generated marker, so nobody edits it by hand', () => { expect(read('src/locales/zh-Hant.ts').startsWith(GENERATED_HEADER)).toBe(true); + expect(read('src/namespaces/marketplace/zh-Hant.ts').startsWith(NAMESPACE_GENERATED_HEADER)).toBe(true); expect(GENERATED_HEADER).toContain('@generated'); + expect(NAMESPACE_GENERATED_HEADER).toContain('@generated'); + }); + + it('converts the namespace catalogue rather than copying it', () => { + expect(zhHansMarketplace.marketplace.refresh).toBe('刷新'); + expect(zhHantMarketplace.marketplace.refresh).toBe(s2t(zhHansMarketplace.marketplace.refresh)); + expect(zhHantMarketplace.marketplace.refresh).not.toBe(zhHansMarketplace.marketplace.refresh); }); it('answers every pinned conversion case', () => { diff --git a/ui/scripts/__tests__/check-i18n-en-drift.test.ts b/ui/scripts/__tests__/check-i18n-en-drift.test.ts index ca988119ad2..c845f92e9c8 100644 --- a/ui/scripts/__tests__/check-i18n-en-drift.test.ts +++ b/ui/scripts/__tests__/check-i18n-en-drift.test.ts @@ -530,7 +530,7 @@ describe('a language is its pack plus its namespace partial packs', () => { Object.fromEntries(LOCALES.map((lang) => [`${NS}${lang}.ts`, packSource(lang, trees[lang])])); it('names one partial pack per language per namespace', () => { - expect(namespacePackPaths('zh')).toEqual(['packages/i18n/src/namespaces/marketplace/zh.ts']); + expect(namespacePackPaths('zh-Hans')).toEqual(['packages/i18n/src/namespaces/marketplace/zh-Hans.ts']); }); it('a group MOVED from the pack to its namespace is no drift and no key-set change', () => { From f198d32b609af264d99da9faf7131af6b563f993 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:51:07 +0000 Subject: [PATCH 07/17] fix(app-shell): pick a stored locale on the platform's one locale ladder pickRenderableLocale now calls resolveLocaleAmong from @objectstack/spec/system, the ladder the server's resolveLocale runs, instead of keeping a copy of it with its own table step. The table's read aliases already name the script-subtag spellings, so a zh-Hant-TW row still renders Traditional; a variant the table reads as the language itself (es-ES) is now taken ahead of the first offered variant, as the server takes it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- .../hooks/__tests__/userLocale-10059.test.tsx | 16 ++++-- .../app-shell/src/hooks/useUserLocale.ts | 51 +++++++------------ 2 files changed, 29 insertions(+), 38 deletions(-) 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 d627f7a2e49..4d2dde43f5b 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 @@ -248,9 +248,11 @@ describe('pickRenderableLocale', () => { }); /** - * The console resolves a stored tag in the server's `resolveLocale` order - * (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. + * The console resolves a stored tag on the platform's one locale ladder, + * `resolveLocaleAmong` (exact → case → platform table language → base + * language → variant), the server's `resolveLocale` order, 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 @@ -315,7 +317,13 @@ describe('pickRenderableLocale follows the server resolveLocale order', () => { }); it('takes the first offered variant when several share a base', () => { - expect(pickRenderableLocale('es', ['en', 'es-MX', 'es-ES'])).toBe('es-MX'); + expect(pickRenderableLocale('es', ['en', 'es-MX', 'es-AR'])).toBe('es-MX'); + }); + + it('takes the variant the platform locale table reads as the language before the first variant', () => { + // `es-ES` is a read alias of the table's `es`, so the table step answers + // it ahead of the variant step, as the server's ladder does. + expect(pickRenderableLocale('es', ['en', 'es-MX', 'es-ES'])).toBe('es-ES'); }); 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 a3f250c4cd7..ca2ff6187b4 100644 --- a/ui/packages/app-shell/src/hooks/useUserLocale.ts +++ b/ui/packages/app-shell/src/hooks/useUserLocale.ts @@ -83,9 +83,9 @@ import { LOCALE_STORAGE_KEY, readStoredLanguage, resolveBootstrapLanguage, - resolveBuiltInLanguage, useObjectTranslation, } from '@object-ui/i18n'; +import { resolveLocaleAmong } from '@objectstack/spec/system'; import { useAdapter, useDataInvalidation, extractWriteErrorMessage } from '@object-ui/react'; import { ObjectStackAdapter } from '@object-ui/data-objectstack'; import { useAfterFirstPaint } from './sharedUserFeeds.js'; @@ -373,24 +373,26 @@ const LOCALE_FIELD = 'locale'; * locales ∩ what this renderer can resolve) — the same list the globe menu and * the profile card render, deliberately not a second opinion about what ships. * - * The match order is the server's `resolveLocale` (`packages/core` - * `fallbacks/memory-i18n.ts`), the one the platform's i18n routes already - * resolve a requested locale with, so the console and the server agree on - * which language a stored tag means: + * The match order IS the platform's one locale ladder, `resolveLocaleAmong` + * (`@objectstack/spec/system`), which the server's `resolveLocale` and every + * i18n route run too, so the console and the server agree on which language a + * stored tag means: * * 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 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; + * 3. the same platform locale table language, through the table's read + * aliases (`zh-TW` → `zh-Hant`, `zh-HK` → `zh-TW`, `zh` → `zh-CN`, + * `pt` → `pt-BR`): a row stored before the table keeps its meaning, and a + * Traditional row is never handed the first Simplified variant offered; * 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. + * 5. the first offered variant of that base language (`es` → `es-MX`), in + * the order the list offers them — after step 3, so a variant the table + * reads as the language itself (`es-ES`) is taken first. + * + * The table's read aliases name the script-subtag spellings too + * (`zh-Hant-TW`, `zh-Hans-CN`), so step 3 reads them by script; a code the + * table does not name is not guessed at, here as on the server. * * A match answers the OFFERED spelling, never the stored one: the list is what * the provider can switch to. @@ -404,26 +406,7 @@ export function pickRenderableLocale( offerable: readonly string[] | null, ): string | null { if (!offerable || offerable.length === 0) return null; - // 1. Exact. - if (offerable.includes(tag)) return tag; - // 2. Case-insensitive. - const lower = tag.toLowerCase(); - const caseMatch = offerable.find((code) => code.toLowerCase() === lower); - if (caseMatch) return caseMatch; - // 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; - // 5. Variant of the base language — first offered wins. - const variantMatch = offerable.find((code) => code.split('-')[0].toLowerCase() === base); - return variantMatch ?? null; + return resolveLocaleAmong(tag, offerable) ?? null; } /** From 65bded64794fa749392e4b4ffe919e585c82cc41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 05:51:36 +0000 Subject: [PATCH 08/17] docs(changeset): the marketplace namespace and the shared locale ladder Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- .changeset/30511-console-packs-platform-locale-tags.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/30511-console-packs-platform-locale-tags.md b/.changeset/30511-console-packs-platform-locale-tags.md index ad5f78beb20..a592a9e43b1 100644 --- a/.changeset/30511-console-packs-platform-locale-tags.md +++ b/.changeset/30511-console-packs-platform-locale-tags.md @@ -1,5 +1,6 @@ --- '@object-ui/i18n': patch +'@object-ui/app-shell': patch '@object-ui/components': patch '@object-ui/console': patch --- @@ -16,6 +17,8 @@ Skills-docs: follow-up owed — `skills/objectui/guides/i18n.md` (its "Built-in - **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. +- **The marketplace namespace follows.** The lazy `marketplace` namespace keys its catalogues by the same tags (`@object-ui/i18n/namespaces/marketplace`'s `MARKETPLACE_LANGUAGE_CODES` and `resolveMarketplaceLanguage` answer `zh-Hans`, `zh-Hant`, `pt-BR`), ships a `zh-Hant` catalogue generated by the same command, and resolves a stored code through the table as the packs do, so a `zh-TW` reader's marketplace pages are Traditional too. +- **One locale ladder.** The Console picks which offered language renders a user's stored `sys_user.locale` with `resolveLocaleAmong` from `@objectstack/spec/system`, the order the server's `resolveLocale` uses, instead of a copy of it: a deployment offering both scripts renders a `zh-TW` row in its Traditional spelling, and a variant the table reads as the language itself (`es-ES` for `es`) is taken ahead of the first offered variant, as the server takes it. - **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. From d06324827aeafef444abf52dd0238a5bfa20ce32 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 06:49:48 +0000 Subject: [PATCH 09/17] test: read main's new locale cases by table tag The docs chrome and membership filter chip cases that main added read a pack as builtInLocales.zh; they read zh-Hans (and the docs chrome also zh-Hant) now. The chip cases keep booting the page in the stored zh code, so they also pin that the alias still renders Simplified. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- .../console/src/pages/docs-portal.bookLocale-26690.test.tsx | 2 +- .../src/views/ObjectDataPage.filterChipI18n-9159.test.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx index 3b14fb8fe31..c97211d3164 100644 --- a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -155,7 +155,7 @@ describe('objectstack#26690 — the docs sidebar serves the request locale', () describe("objectstack#30554 — the docs chrome's own words follow the session language", () => { const ENTRY = '/apps/com.example.crm/docs/manual/crm_start_login'; - for (const language of ['zh', 'ja', 'es'] as const) { + for (const language of ['zh-Hans', 'zh-Hant', 'ja', 'es'] as const) { it(`a ${language} reader sees the home link and the Uncategorized title in ${language}`, async () => { const pack = builtInLocales[language].help; // Guard: the expectations are real translations, not the English words. 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 cc6ca5c3608..dcca76ca87f 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 @@ -274,19 +274,19 @@ describe('the membership and negation chips (objectstack#30743)', () => { ['nin', 'filterBuilder.operators.not_in'], ])('`[%s]` renders the zh label of `%s` beside the members, joined', (suffix, key) => { renderAt('zh', `filter[${FIELD}][${suffix}]=alice&filter[${FIELD}][${suffix}]=bob`); - expect(chipOperatorText()).toBe(`${packLabelOf('zh', key)} alice, bob`); + expect(chipOperatorText()).toBe(`${packLabelOf('zh-Hans', key)} alice, bob`); }); it('`[ne]` renders the zh "does not equal" label beside the comparand', () => { renderAt('zh', `filter[${FIELD}][ne]=alice`); const rendered = chipOperatorText(); - expect(rendered).toBe(`${packLabelOf('zh', 'filterBuilder.operators.not_equals')} alice`); + expect(rendered).toBe(`${packLabelOf('zh-Hans', 'filterBuilder.operators.not_equals')} alice`); expect(rendered).not.toContain('filterBuilder.operators'); }); it('CONTROL: the en pack differs, so the label half really went through `t`', () => { renderAt('en', `filter[${FIELD}][in]=alice`); expect(chipOperatorText()).toBe(`${packLabelOf('en', 'filterBuilder.operators.in')} alice`); - expect(packLabelOf('zh', 'filterBuilder.operators.in')).not.toBe(packLabelOf('en', 'filterBuilder.operators.in')); + expect(packLabelOf('zh-Hans', 'filterBuilder.operators.in')).not.toBe(packLabelOf('en', 'filterBuilder.operators.in')); }); }); From 7e8ce6e98dad3c915bf7b72ae311ab65695d666c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 07:59:43 +0000 Subject: [PATCH 10/17] chore(i18n): regenerate zh-Hant after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/packages/i18n/src/locales/zh-Hant.ts | 25 +++++++++++++++++++ .../src/namespaces/marketplace/zh-Hant.ts | 15 +++++++++++ 2 files changed, 40 insertions(+) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index f7c49924415..fcee88d59fa 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -1384,6 +1384,7 @@ const zhHant = { daysAgo_other: '{{count}}天前', // Activity feed actors systemActor: '系統', + impersonatedActor: '{{admin}} 代{{user}}操作', unknownUser: '未知使用者', // Record meta footer (audit provenance)。createdBy/updatedBy 後面跟的是 // 操作者("建立人 Dev Admin · 5 分鐘前");created/updated 是無操作者時的 @@ -4005,6 +4006,30 @@ const zhHant = { cloudWorkspaceTimezoneNotice: { seeded: '環境時區在建立時已按你的瀏覽器設為 {{zone}},可在「設定 → 本地化」中修改。', }, + deploymentNotice: { + title: '這臺伺服器把你的資料和金鑰存放在它自己的磁碟上', + titleLow: '完成此部署的設定', + intro: '沒有人為此生產部署指定這些內容的存放位置。請把下面的設定交給負責執行伺服器的人,這樣更換伺服器時不會丟失任何東西。', + finding: { + databaseLocalFile: '你的業務資料是這臺伺服器上的一個檔案({{file}})。更換伺服器或其磁碟會丟失這些資料。', + authSecretGenerated: '登入金鑰是在這臺伺服器上生成的。一旦丟失,所有人都會被登出。', + dataKeyFile: '用於加密已儲存密碼和 API 金鑰的金鑰是這臺伺服器上的一個檔案({{file}})。一旦丟失,這些內容將無法再讀取。', + fileStorageLocal: '上傳的檔案儲存在這臺伺服器的磁碟上。', + emailNotConfigured: '尚未設定郵件服務,因此邀請和密碼重置不會通過郵件傳送。', + }, + copy: { + button: '複製設定', + database: '你的資料庫,例如 postgres://user:pass@db-host:5432/app,或 file:/path/on/a/kept/volume/app.db。目前資料在 {{file}} 中:切換前請先遷移過去。', + carryOver: '沿用當前值:{{file}} 的內容', + newValue: '一個較長的隨機值,每次重啟都保持不變,例如以下命令的輸出:openssl rand -hex 32', + }, + copied: '已複製', + hide: '隱藏', + hideNoAuditLog: '此部署沒有審計日誌,因此無法隱藏該提示。', + hideFailed: '無法隱藏該提示,請重試。', + hiddenLine: '部署提示已隱藏,其所述情況仍然存在。', + showAgain: '重新顯示', + }, aiApprovals: { title: 'AI 審批', description: 'AI 智慧體提出的操作,需要人工稽核後才會執行。', diff --git a/ui/packages/i18n/src/namespaces/marketplace/zh-Hant.ts b/ui/packages/i18n/src/namespaces/marketplace/zh-Hant.ts index 2fa43b219ff..2604cd6aef7 100644 --- a/ui/packages/i18n/src/namespaces/marketplace/zh-Hant.ts +++ b/ui/packages/i18n/src/namespaces/marketplace/zh-Hant.ts @@ -126,6 +126,21 @@ const zhHant = { unmanagedSuccess: '已將 "{{name}}" 的 v{{version}} 複製到本執行環境,成為它自己的中繼資料,位於基礎包 {{base}} 中。副本可編輯,且不再接收升級。', unmanagedStrippedLocks: '軟體包鎖定了以下條目。副本可編輯,因此這些鎖未被複制:', unmanagedIgnored: '本執行環境不支援非託管安裝,因此 "{{name}}" 以託管方式安裝(以程式碼形式註冊),而不是被複制。請先升級執行環境,然後解除安裝該軟體包,再以非託管副本重新安裝。', + // objectstack#30325 — the confirm in front of a local Install: what the + // install adds, read from the manifest it installs (MarketplacePackagePage). + preview: { + title: "安裝 {{name}}?", + reinstallTitle: "重新安裝 {{name}}?", + intro: "安裝會向此執行環境加入以下內容:", + reading: "正在讀取此軟體包將加入的內容…", + unreadable: "安裝前無法讀取此軟體包將加入的內容。安裝結果會說明載入了什麼。", + objects: "物件", + permissionSets: "權限集", + sampleRecords: "示例記錄", + atMost: "最多 {{count}}", + sampleNote: "示例記錄隨應用一起載入,安裝後可在本頁清除。", + suggestsEveryone: "安裝後會建議將「{{set}}」授予所有已登入使用者(Everyone 崗位)。在你接受之前不會授予任何權限。", + }, }, // objectui#12146 — the install dialog opened as an UPDATE of the copy // this environment holds (MarketplacePackagePage). From e94c13c8addf1a3fda0d6a436caa65cc572c93f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 08:51:30 +0000 Subject: [PATCH 11/17] fix(i18n): narrow a marketplace catalogue code to a table tag before indexing its loader Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/packages/i18n/src/namespaces/marketplace/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/i18n/src/namespaces/marketplace/index.ts b/ui/packages/i18n/src/namespaces/marketplace/index.ts index 4e9cb76bfb9..57f9423cc65 100644 --- a/ui/packages/i18n/src/namespaces/marketplace/index.ts +++ b/ui/packages/i18n/src/namespaces/marketplace/index.ts @@ -126,7 +126,7 @@ export const MARKETPLACE_LANGUAGE_CODES: readonly string[] = Object.freeze(Objec const own = (record: object, key: string): boolean => Object.prototype.hasOwnProperty.call(record, key); -const hasMarketplaceCatalogue = (code: string): boolean => own(MARKETPLACE_LOADERS, code); +const hasMarketplaceCatalogue = (code: string): code is PlatformLocaleTag => own(MARKETPLACE_LOADERS, code); /** * The tag of the catalogue that serves `lang`, read the way the packs resolve — From f7e9df2dc7d58acf9fe82a92da1028ae691fc904 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 09:44:25 +0000 Subject: [PATCH 12/17] chore(i18n): regenerate zh-Hant after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/packages/i18n/src/locales/zh-Hant.ts | 63 ++++++++++++++++++------- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index fcee88d59fa..6bb99866fe6 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -1982,22 +1982,53 @@ const zhHant = { clientAddressDirect: '伺服器判定的你的地址:{{address}}。不包含該地址的列表無法在此儲存。此伺服器不信任代理轉發頭,若部署在代理之後,這裡顯示的是代理的地址。', clientAddressViaProxy: '伺服器判定的你的地址:{{address}},取自此伺服器所信任代理的轉發頭。不包含該地址的列表無法在此儲存。', clientAddressUnknown: '伺服器無法確定你的地址,因此無法在此儲存非空的列表。', - }, - workspaceTimezonePrompt: { - title: '設定系統時區', - description: '本系統仍在使用平台預設時區 {{current}}。系統時區決定公式中“今天”的含義、報表如何按日期分組,以及日期和時間如何顯示,對系統內所有人生效。下方已填入你的瀏覽器時區。', - laterHint: '之後可隨時在“設定”頁面中修改。', - decline: '保持預設', - confirm: '設定時區', - saved: '系統時區已設定為 {{zone}}', - currencyTitle: '設定預設貨幣', - currencyDescription: '本組織尚未設定預設貨幣,金額暫不顯示幣種。下方已填入您瀏覽器所在地區的貨幣:{{name}}({{code}})。', - currencyConfirm: '設定貨幣', - currencySaved: '預設貨幣已設定為 {{currency}}', - bothTitle: '設定系統時區和預設貨幣', - bothConfirm: '儲存', - bothSaved: '系統時區已設定為 {{zone}},預設貨幣已設定為 {{currency}}', - notNow: '暫不設定', + invalidFormat: '{{setting}}的格式不正確。', + invalidFormatHelp: '{{setting}}的格式不正確。{{help}}', + invalidFormatPick: '請從列表中選擇{{setting}}。', + }, + firstRunChecklist: { + title: '完成初始設定', + description: '同事開始使用之前,先設定好這幾項。每一項之後都可以在 Setup 中修改。', + progress: '已完成 {{done}}/{{total}}', + dismiss: '隱藏清單', + set: '設定', + change: '修改', + view: '檢視', + confirm: '確認', + cancel: '取消', + prefilled: '尚未設定的項已按你的瀏覽器給出建議。確認之前不會儲存任何內容。', + organizationName: { + title: '為組織命名', + todo: '組織仍在使用建立時的名稱“{{name}}”。', + action: '重新命名', + }, + languageCountry: { + title: '預設語言和國家/地區', + todo: '當前為{{language}}、{{country}}。請確認,或改選其他值。', + saved: '預設語言和國家/地區已儲存', + }, + timezone: { + title: '系統時區', + todo: '仍為平台預設時區 {{current}}。系統時區決定公式中“今天”的含義,以及報表如何按日期分組。', + saved: '系統時區已設定為 {{zone}}', + }, + currency: { + title: '預設貨幣', + todo: '尚未設定,金額暫不顯示幣種。', + saved: '預設貨幣已設定為 {{currency}}', + }, + mail: { + title: '郵件傳送', + todo: '尚未配置,邀請、重置密碼等郵件暫時無法送達。', + done: '已配置,可以傳送郵件。', + action: '去配置', + }, + invite: { + title: '邀請同事', + todo: '目前只有你一位成員。', + done: '已有同事加入或已發出邀請。', + action: '邀請', + }, }, error: { connectionFailed: '無法連線到伺服器', From 143ef31d942d572c46448b42728389caf0e1aafe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:25:27 +0000 Subject: [PATCH 13/17] fix(ui-scripts): the en-drift gate's en-first assertion runs inside a declaration, so importing the gate runs nothing Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/scripts/check-i18n-en-drift.mjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/scripts/check-i18n-en-drift.mjs b/ui/scripts/check-i18n-en-drift.mjs index 5b2ab3b0084..8a732422108 100644 --- a/ui/scripts/check-i18n-en-drift.mjs +++ b/ui/scripts/check-i18n-en-drift.mjs @@ -190,9 +190,14 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); * 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`); +export const LOCALES = Object.freeze(enFirstTableTags(PLATFORM_LOCALE_TAGS)); + +/** The table's tags, refused unless `en` opens them: this gate compares against `en`. */ +function enFirstTableTags(tags) { + if (tags[0] !== 'en') { + throw new Error(`the platform locale table no longer opens with en (got ${tags[0]}); this gate compares against en`); + } + return [...tags]; } /** The packs that must follow `en`: every other table tag. */ From c1f166cc40e17e5a1c069137a6ffa32897da33fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:33:30 +0000 Subject: [PATCH 14/17] chore(regen): record the marketplace namespace's generated zh-Hant catalogue beside the pack's Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- scripts/regen-artifacts.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 1c7626fbe40..44f073174d5 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -928,6 +928,18 @@ export const NOT_DRIVER_MANAGED = Object.freeze([ + '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`.', }, + // objectstack#30511: the marketplace namespace's Traditional Chinese catalogue, same command. + { + path: 'ui/packages/i18n/src/namespaces/marketplace/zh-Hant.ts', + gen: 'gen:zh-hant', + owner: '@object-ui/i18n', + why: + 'generated WHOLE from the namespace\'s `zh-Hans.ts` by the same `gen:zh-hant` run that writes ' + + 'the pack above, and not routed for the same reason: no `check:` script proves its currency. ' + + 'The proof is the same package test, which walks every generated target. Line-aligned with ' + + 'its source, so 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. */ From 82771dc08b191df9d209ad3e22eafc90eae28e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:28:21 +0000 Subject: [PATCH 15/17] chore(i18n): regenerate zh-Hant after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/packages/i18n/src/locales/zh-Hant.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index 6bb99866fe6..ff9f6dde1c8 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -3695,6 +3695,7 @@ const zhHant = { // objectui#13461 — the top bar's avatar button, which holds only the avatar. accountMenu: '賬戶選單', profile: '個人資料', + connectAiAssistant: '連線 AI 助手', logout: '退出登入', preferences: '偏好設定', theme: '主題', @@ -4010,6 +4011,13 @@ const zhHant = { body: '擴充 → 聯結器 → + 新增 → 貼上 JSON 配置:', hint: '也可手動新增:傳輸型別選 Streamable HTTP 並填寫此 URL。如果你的版本不支援瀏覽器登入,請新增請求頭 Authorization: Bearer (在下方建立)。', }, + client: { + title: '你用的是哪個 AI 助手?', + hint: '選擇你的助手,只看它的步驟。本瀏覽器會記住你的選擇。', + }, + advanced: { + title: '高階:腳本與 CI', + }, apiKey: { title: 'API 金鑰', badge: '無頭場景', From 33799312453d6e23a4562b23e028cc6a9bc105f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 14:55:27 +0000 Subject: [PATCH 16/17] chore(i18n): regenerate zh-Hant after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- ui/packages/i18n/src/locales/zh-Hant.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ui/packages/i18n/src/locales/zh-Hant.ts b/ui/packages/i18n/src/locales/zh-Hant.ts index ff9f6dde1c8..668ea0b36d0 100644 --- a/ui/packages/i18n/src/locales/zh-Hant.ts +++ b/ui/packages/i18n/src/locales/zh-Hant.ts @@ -2459,6 +2459,28 @@ const zhHant = { passwordsMore: '更多條目已省略——請使用下載。', passwordsDownload: '下載 CSV', }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: '此人手上的記錄', + loading: '正在統計此人手上的記錄…', + countFailed: '無法統計此人手上的記錄:{{message}}', + noClient: '此處無法統計此人手上的記錄。', + none: '此人手上沒有你可讀取的記錄。', + summary: '你可讀取的記錄中有 {{total}} 條在此人手上:擁有 {{owned}} 條,被指派 {{assigned}} 條。', + row: '{{object}} · {{field}}', + notTransferable: '該欄位可填多人,無法在此交接。', + truncated: '並非所有使用者欄位都已統計,此人手上可能還有更多記錄。', + unreadable: '以下物件你無權讀取,未計入:{{objects}}', + handoverTo: '交接給', + handoverHint: '可選。封禁不必等交接完成。', + handover: '交接', + handingOver: '正在交接…', + handoverNext: '交接下一批', + targetBanned: '此人已被封禁,請選擇一位在職使用者。', + transferRefused: '交接被拒絕:{{message}}', + report: '已交接 {{updated}} 條,{{failed}} 條未能移交,仍有 {{remaining}} 條在此人手上。', + failure: '{{object}} {{id}}:{{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { @@ -2768,6 +2790,8 @@ const zhHant = { title: '審計日誌', subtitle: '只讀的系統活動與記錄變更,最新的在前。', openAsList: '以列表開啟', + openEntryPage: '開啟條目頁', + openEntryPageHint: '條目頁按時間倒序列出同一記錄的全部變更。', openPresetAsList: '以列表開啟 {{label}}', presets: '預設', presetOtherSource: '此檢視讀取的是其他資料來源。', From e0985fea7ac1c5771baf6dc1fc55c44bdc3eefed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 15:08:12 +0000 Subject: [PATCH 17/17] test(console): the audit-log entry-page pin reads the Simplified pack by its table tag Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibyWSiXh6B2sMVHfXYC --- .../src/pages/system/AuditLogPage.entryPage-30748.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx b/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx index a729483892e..7058b24f695 100644 --- a/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx +++ b/ui/apps/console/src/pages/system/AuditLogPage.entryPage-30748.test.tsx @@ -199,7 +199,7 @@ describe('AuditLogPage — the drawer opens the row\'s record page', () => { }); it('under zh, the link and the hint read the zh pack', async () => { - const zhAudit = builtInLocales.zh.console.system.auditLog; + const zhAudit = builtInLocales['zh-Hans'].console.system.auditLog; // Guard: real zh rows, not English copied over. expect(zhAudit.openEntryPage).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPage); expect(zhAudit.openEntryPageHint).not.toBe(builtInLocales.en.console.system.auditLog.openEntryPageHint);