From cb28b7c53b660722d40737aa176d361600a1cb77 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:17:35 +0000 Subject: [PATCH 1/4] fix(i18n): an edit in a Chinese script the label lacks adds its own entry The shared write limb no longer owns the other script's entry: a zh-Hant edit over { en, zh } adds zh-Hant and keeps the Simplified text, and a clear there removes nothing. Reading is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .changeset/31074-i18n-cross-script-edit.md | 10 ++++ .../i18n/src/__tests__/clearLocalized.test.ts | 20 ++++++++ .../i18n/src/__tests__/setLocalized.test.ts | 50 +++++++++++++++++-- ui/packages/i18n/src/pickLocalized.ts | 18 ++++++- 4 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .changeset/31074-i18n-cross-script-edit.md diff --git a/.changeset/31074-i18n-cross-script-edit.md b/.changeset/31074-i18n-cross-script-edit.md new file mode 100644 index 00000000000..e34b9009d15 --- /dev/null +++ b/.changeset/31074-i18n-cross-script-edit.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': patch +--- + +fix(i18n): an edit in a Chinese script the label lacks adds its own entry instead of overwriting the other script + +Clause-②: no +Metadata-protocol: none + +`setLocalized` and `clearLocalized` no longer treat the other Chinese script's entry as the editor's own. A `zh-Hant` (or `zh-TW`, `zh-HK`) edit of a label written as `{ en: 'Customer', zh: '客户' }` now adds a `zh-Hant` entry and keeps the Simplified text; before, it overwrote `zh` with Traditional text. A clear in that case removes nothing, because the box was showing the other script's text as a fallback. A Simplified editor over a Traditional-only map is handled the same way. Reading is unchanged: `pickLocalized` still shows the other script's entry until one of the reader's own script exists. Every other language, and every map that has an entry of the editor's script, writes exactly as before. diff --git a/ui/packages/i18n/src/__tests__/clearLocalized.test.ts b/ui/packages/i18n/src/__tests__/clearLocalized.test.ts index a3fc7846ac6..bf5b13f26de 100644 --- a/ui/packages/i18n/src/__tests__/clearLocalized.test.ts +++ b/ui/packages/i18n/src/__tests__/clearLocalized.test.ts @@ -128,3 +128,23 @@ describe('clearLocalized — a script-split language clears the entry of its own }); }); }); + +/** + * #31074 — a clear in a script the map lacks removes nothing: the box showed + * the other script's text as a borrowed fallback, and clearing a borrowed + * string must not delete the locale it was borrowed from. + */ +describe('clearLocalized — a clear in a script the map lacks keeps the other script (#31074)', () => { + it.each([ + [{ en: 'Customer', zh: '客户' }, 'zh-Hant'], + [{ en: 'Customer', 'zh-CN': '客户' }, 'zh-TW'], + [{ en: 'Customer', 'zh-TW': '客戶' }, 'zh-CN'], + ] as const)('%j cleared in %s is returned unchanged', (map, language) => { + expect(clearLocalized(map, language)).toEqual(map); + }); + + it('an entry of the editor\'s own script is still cleared', () => { + expect(clearLocalized({ en: 'Customer', zh: '客户' }, 'zh-CN')).toEqual({ en: 'Customer' }); + expect(clearLocalized({ en: 'Customer', 'zh-TW': '客戶' }, 'zh-HK')).toEqual({ en: 'Customer' }); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/setLocalized.test.ts b/ui/packages/i18n/src/__tests__/setLocalized.test.ts index 7f8709dc3dc..aee10503dc6 100644 --- a/ui/packages/i18n/src/__tests__/setLocalized.test.ts +++ b/ui/packages/i18n/src/__tests__/setLocalized.test.ts @@ -222,10 +222,10 @@ describe('setLocalized — a script-split language writes the entry of its own s }); /** Byte-identical controls: `[map, language, the key main writes]`. */ + // The three one-script Chinese rows that stood here (`{ en, 'zh-CN' }` at + // `zh-Hant`, `{ en, zh }` at `zh-TW`, `{ en, 'zh-HK' }` at `zh-Hans`) pinned + // a cross-script overwrite; #31074 moved them to the block below. const UNCHANGED: ReadonlyArray, string, string]> = [ - [{ en: 'Customer', 'zh-CN': '客户' }, 'zh-Hant', 'zh-CN'], - [{ en: 'Customer', zh: '客户' }, 'zh-TW', 'zh'], - [{ en: 'Customer', 'zh-HK': '客戶' }, 'zh-Hans', 'zh-HK'], [{ 'pt-PT': 'PT', 'pt-BR': 'BR' }, 'pt', 'pt-PT'], [{ 'en-GB': 'GB', 'en-US': 'US' }, 'en', 'en-GB'], [{ 'es-MX': 'MX', 'es-ES': 'ES' }, 'es', 'es-MX'], @@ -237,3 +237,47 @@ describe('setLocalized — a script-split language writes the entry of its own s expect(written[key]).toBe('WRITTEN'); }); }); + +/** + * #31074 — an edit in a script the map lacks ADDS its own script's entry. The + * other script's text is a borrowed display fallback for this editor, the way + * `en` is for `fr`: `{ en, zh }` edited in `zh-Hant` used to overwrite the + * Simplified `zh` text with Traditional. The read side is unchanged, so the + * editor saw the Simplified text before the edit and sees its own after it. + */ +describe('setLocalized — an edit in a script the map lacks adds its own entry (#31074)', () => { + /** `[map, language, what the map reads in that language before the edit]` */ + const ADDS: ReadonlyArray, string, string]> = [ + [{ en: 'Customer', zh: '客户' }, 'zh-Hant', '客户'], + [{ en: 'Customer', 'zh-CN': '客户' }, 'zh-Hant', '客户'], + [{ en: 'Customer', zh: '客户' }, 'zh-TW', '客户'], + [{ en: 'Customer', 'zh-Hans': '客户' }, 'zh-HK', '客户'], + [{ en: 'Customer', 'zh-HK': '客戶' }, 'zh-Hans', '客戶'], + [{ en: 'Customer', 'zh-TW': '客戶' }, 'zh', '客戶'], + [{ en: 'Customer', 'zh-TW': '客戶' }, 'zh-CN', '客戶'], + // A script subtag the platform table does not name is not guessed at to be + // the editor's own. + [{ en: 'Customer', 'zh-Hant-US': '客戶' }, 'zh-Hant', '客戶'], + ]; + + it.each(ADDS)('%j edited in %s adds that tag and keeps every entry', (map, language, before) => { + expect(pickLocalized(map, language)).toBe(before); + const written = setLocalized(map, language, 'WRITTEN') as Record; + expect(written).toEqual({ ...map, [language]: 'WRITTEN' }); + expect(pickLocalized(written, language)).toBe('WRITTEN'); + }); + + /** `[map, language, the key the edit replaces]`: an entry of the editor's own script is still owned. */ + const OWN_SCRIPT: ReadonlyArray, string, string]> = [ + [{ en: 'Customer', zh: '客户' }, 'zh-CN', 'zh'], + [{ en: 'Customer', zh: '客户' }, 'zh-Hans', 'zh'], + [{ en: 'Customer', 'zh-CN': '客户' }, 'zh', 'zh-CN'], + [{ en: 'Customer', 'zh-TW': '客戶' }, 'zh-HK', 'zh-TW'], + [{ en: 'Customer', 'zh-Hant': '客戶' }, 'zh-TW', 'zh-Hant'], + ]; + + it.each(OWN_SCRIPT)('%j edited in %s still replaces %s', (map, language, key) => { + const written = setLocalized(map, language, 'WRITTEN') as Record; + expect(written).toEqual({ ...map, [key]: 'WRITTEN' }); + }); +}); diff --git a/ui/packages/i18n/src/pickLocalized.ts b/ui/packages/i18n/src/pickLocalized.ts index 37b88c07aeb..5e52a57a5a8 100644 --- a/ui/packages/i18n/src/pickLocalized.ts +++ b/ui/packages/i18n/src/pickLocalized.ts @@ -126,7 +126,8 @@ export function pickLocalized(value: unknown, language: string | undefined | nul * * It deliberately follows only the first three limbs (exact tag, base * language, region-qualified sibling — one {@link localizedEntryKey} call, the - * very function the read side calls) and then STOPS. `pickLocalized` continues + * very function the read side calls) and then STOPS (one script check aside, + * below). `pickLocalized` continues * into `default` -> `en` -> first value, and those last three limbs must never * be write targets: they are DISPLAY fallbacks that hand back *another* * locale's string. Following them would mean an author editing in `fr`, shown @@ -139,6 +140,16 @@ export function pickLocalized(value: unknown, language: string | undefined | nul * would leave the edited-away `zh-CN` string in place — invisible in the panel * that just "changed" it, still live for any consumer that asks for `zh-CN`. * + * One more stop, where the platform table splits the language by script + * ({@link splitScriptTagOf}: `zh` today): the entry those limbs pick must mean + * the editor's own script. A map with only the other script's entry is shown + * to the editor as a borrowed fallback, the same as `en` is to `fr`, so an + * edit in `zh-Hant` over `{ en, zh }` or `{ en, 'zh-CN' }` ADDS `zh-Hant` and + * the Simplified text survives. A key whose script the table cannot name + * (`zh-Hant-US`) is not guessed at to be the editor's own either. The read + * side is unchanged: that `zh-Hant` reader still sees the `zh` text until a + * Traditional entry exists. + * * Own properties only, `string` values only, for the same reason every * `pickLocalized` limb narrowed that way in objectui#3907: a locale tag that * names an `Object.prototype` member is not an entry, so it cannot be the entry @@ -151,7 +162,10 @@ function localeWriteKey( const lang = (language || 'en').trim() || 'en'; const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(o, key) && typeof o[key] === 'string'; - return localizedEntryKey(lang, has, Object.keys(o)) ?? lang; + const key = localizedEntryKey(lang, has, Object.keys(o)); + if (key === undefined) return lang; + const script = splitScriptTagOf(lang); + return script === undefined || platformLocaleTagOf(key) === script ? key : lang; } /** From c4b41a13796158dc9ff256cfd7f990830802f4fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:17:35 +0000 Subject: [PATCH 2/4] fix(app-shell): Studio designs a Traditional Chinese session as zh-Hant useMetadataLocale resolves the session language through the console's own resolveBuiltInLanguage, so the nav designer reads and edits the entry the sidebar shows. Chrome falls back to the Simplified catalogue; three strict zh-CN chrome comparisons move to isZhLocale. navItemLabel's rename and clear go through the shared write limb. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .../metadata-admin/AssignedUsersSection.tsx | 3 +- .../metadata-admin/PositionHoldersSection.tsx | 3 +- .../views/metadata-admin/ResourceEditPage.tsx | 4 +- .../src/views/metadata-admin/i18n.ts | 32 +++++-- .../navItemLabel.inherit-11196.test.ts | 30 ++++++- .../metadata-admin/previews/navItemLabel.ts | 58 ++++++------- ...etadataLocale.sessionScript-31074.test.tsx | 84 +++++++++++++++++++ 7 files changed, 171 insertions(+), 43 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/useMetadataLocale.sessionScript-31074.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx index 398c2e1111e..a2d00dc3503 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx @@ -86,6 +86,7 @@ import { usePermissions } from '@object-ui/permissions'; import { extractWriteErrorMessage, useAdapter, useMetadata } from '@object-ui/react'; import { Plus, X, Users, Loader2, AlertCircle } from 'lucide-react'; import { useMetadataLocale } from './i18n.js'; +import { isZhLocale } from './schema-field-label.js'; import { membershipOrganization, readOrganizationMemberIds, type MemberFilter } from './organization-members.js'; /** The object a permission set's direct grants are rows of: ADD creates one, REMOVE deletes one. */ @@ -129,7 +130,7 @@ interface AssignedRow { * them: a space in English, none after a CJK full stop. */ function useCopy() { - const zh = useMetadataLocale() === 'zh-CN'; + const zh = isZhLocale(useMetadataLocale()); return React.useMemo( () => zh diff --git a/ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx b/ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx index 7283c48e2e9..000bf97ae9e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx @@ -61,6 +61,7 @@ import { usePermissions } from '@object-ui/permissions'; import { extractWriteErrorMessage, useAdapter, useMetadata } from '@object-ui/react'; import { AlertCircle, Loader2, Plus, UserCheck, X } from 'lucide-react'; import { useMetadataLocale } from './i18n.js'; +import { isZhLocale } from './schema-field-label.js'; import { membershipOrganization, readOrganizationMemberIds, type MemberFilter } from './organization-members.js'; export interface PositionHoldersSectionProps { @@ -107,7 +108,7 @@ const PICKER_EMAIL_FIELD = 'email'; const refusalSentence = (err: unknown): string => extractWriteErrorMessage(err) ?? ''; function useCopy() { - const zh = useMetadataLocale() === 'zh-CN'; + const zh = isZhLocale(useMetadataLocale()); return React.useMemo( () => zh diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 10e6a13467f..e0202b27e2e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -147,7 +147,7 @@ import { useRegisterAssistantEditor, type AssistantEditorContext } from '../../a import { useRegisteredMetadataInspector } from './inspector-registry.js'; import { useRegisteredMetadataDefaultInspector } from './default-inspector-registry.js'; import { InspectorReadOnlyReason } from './inspectors/_shared.js'; -import { useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js'; +import { isZhLocale, useMetadataLocale, t, tFormat, translateMetadataType, translateSchemaIssue, translateValidationMessage } from './i18n.js'; import { JsonSourceEditor } from '../item-tools/JsonSourceEditor.js'; import { validateMetadataDraft, hasClientValidator, type DraftMode } from './clientValidation.js'; import { @@ -3056,7 +3056,7 @@ function MetadataResourceEditPageImpl({ rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-amber-800 underline hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100" > - {locale === 'zh-CN' ? '查看文档' : 'View docs'} → + {isZhLocale(locale) ? '查看文档' : 'View docs'} → )} {layered?.packageId && ( diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index f40c3b69798..983df23be2c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -58,7 +58,7 @@ * `ui/scripts/__tests__/check-i18n-dead-keys.test.ts`. */ -import { useObjectTranslation } from '@object-ui/i18n'; +import { resolveBuiltInLanguage, useObjectTranslation } from '@object-ui/i18n'; // The designer strings' new home. Its `en` catalogue is resident; its other // catalogues are fetched on demand, and `useMetadataLocale()` re-renders its // caller when one lands. ⚠️ `ui/scripts/check-i18n-dead-keys.mjs` reads the @@ -74,7 +74,15 @@ import type { MetadataOverlayScope, } from '@object-ui/data-objectstack'; -export type SupportedLocale = 'en-US' | 'zh-CN'; +/** + * The designer locale. `'zh-Hant'` is a Traditional Chinese session: Studio + * reads and writes the Traditional entry of an inline label in it, and its + * chrome strings fall back to the namespace's `zh` (Simplified) catalogue, + * which is the only Chinese one it ships (`resolveStudioLanguage`). Compare a + * value with `isZhLocale`, never `=== 'zh-CN'`, when the question is "Chinese + * chrome?". + */ +export type SupportedLocale = 'en-US' | 'zh-CN' | 'zh-Hant'; const TYPE_LABELS_EN: Record = { // Data @@ -931,9 +939,23 @@ export function detectLocale(): SupportedLocale { return 'en-US'; } +/** + * The designer locale for an i18n `language` — {@link useMetadataLocale}'s + * answer, as a pure function. + */ +export function designerLocaleOf(language: string | undefined): SupportedLocale { + if (!/^zh/i.test(language ?? '')) return 'en-US'; + return resolveBuiltInLanguage(language ?? '') === 'zh-Hant' ? 'zh-Hant' : 'zh-CN'; +} + /** * React hook — the Studio/metadata-admin locale derived from the app's ACTIVE - * i18n language, collapsed to one of the two locales this catalog ships. + * i18n language: `'zh-Hant'` for a session the console serves in Traditional + * Chinese (`zh-Hant`, `zh-TW`, `zh-HK`, … through `resolveBuiltInLanguage`, + * the console's own resolution), `'zh-CN'` for every other Chinese session, + * `'en-US'` otherwise. The Traditional value exists for the label entries an + * author reads and edits (the nav designer shows and renames the entry the + * sidebar shows); its chrome strings are the Simplified ones. * * Prefer this over {@link detectLocale} in components: `detectLocale()` reads * `navigator.language`, which never changes when the user switches languages @@ -944,7 +966,7 @@ export function detectLocale(): SupportedLocale { * * ⛔ Never hand this value to `Intl` (`Intl.*Format`, `toLocale*String`) as the * locale a date, time or number is formatted in. It picks one of this file's - * TWO string tables, so it is `'en-US'` for every language that is not zh — a + * string tables, so it is `'en-US'` for every language that is not zh — a * de-DE session would read its faces in US English (objectui#10232). Faces * format with `useDisplayLocale()` from `@object-ui/i18n`, the channel every * date and number renderer resolves through; keep this value for `t()` / @@ -952,7 +974,7 @@ export function detectLocale(): SupportedLocale { */ export function useMetadataLocale(): SupportedLocale { const { language } = useObjectTranslation(); - const locale: SupportedLocale = /^zh/i.test(language) ? 'zh-CN' : 'en-US'; + const locale = designerLocaleOf(language); // Starts the fetch of the `studio` namespace catalogue this locale reads and // re-renders the caller when it lands, so a moved key does not stay on the // `en` answer the first render gave. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.inherit-11196.test.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.inherit-11196.test.ts index 17b6da16b8f..bb92fdbcec9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.inherit-11196.test.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.inherit-11196.test.ts @@ -195,8 +195,8 @@ describe('#30910 — renamedLabel and clearedLabel change the entry the resolver // Neither the exact tag nor the bare language: the exact tag is added, a sibling region is not owned. [{ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }, 'zh-Hant', { en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶', 'zh-Hant': 'RENAMED' }], [{ en: 'Customer', 'zh-TW': '客戶' }, 'zh-Hant', { en: 'Customer', 'zh-TW': '客戶', 'zh-Hant': 'RENAMED' }], - // The bare language with no sibling of the locale's script: the bare language, as before. - [{ en: 'Customer', zh: '客户' }, 'zh-Hant', { en: 'Customer', zh: 'RENAMED' }], + // The bare language of the locale's own script: the bare language, as before. + // (`{ en, zh }` renamed in `zh-Hant`, which wrote `zh` here, moved to the #31074 block.) [{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-CN', { en: 'Customer', zh: 'RENAMED', 'zh-TW': '客戶' }], // Languages the table names once. [{ en: 'Accounts', 'en-GB': 'Accounts (GB)' }, 'en-US', { en: 'RENAMED', 'en-GB': 'Accounts (GB)' }], @@ -207,3 +207,29 @@ describe('#30910 — renamedLabel and clearedLabel change the entry the resolver expect(renamedLabel(map as I18nLabel, 'RENAMED', locale)).toEqual(expected); }); }); + +/** + * #31074 — a rename in a script the map lacks adds the locale's own entry + * (the shared write limb, `setLocalized`), and a clear there removes nothing: + * a Traditional designer over `{ en, zh }` used to overwrite the Simplified + * text. The entry is shown in the other script until then, as the sidebar + * shows it. + */ +describe('#31074 — a rename in a script the map lacks adds its own entry', () => { + const CROSS: ReadonlyArray, string]> = [ + [{ en: 'Customer', zh: '客户' }, 'zh-Hant'], + [{ en: 'Customer', 'zh-CN': '客户' }, 'zh-Hant'], + [{ en: 'Customer', zh: '客户' }, 'zh-TW'], + [{ en: 'Customer', 'zh-Hant': '客戶' }, 'zh-CN'], + ]; + + it.each(CROSS)('%j renamed in %s adds that tag, keeps the other script, and shows the new text', (map, locale) => { + const next = renamedLabel(map as I18nLabel, 'RENAMED', locale) as Record; + expect(next).toEqual({ ...map, [locale]: 'RENAMED' }); + expect(navItemLabelText(next as I18nLabel, locale)).toBe('RENAMED'); + }); + + it.each(CROSS)('%j cleared in %s removes nothing', (map, locale) => { + expect(clearedLabel(map as I18nLabel, locale)).toEqual(map); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.ts index 698171232e4..c6d3acb1cd6 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/navItemLabel.ts @@ -26,6 +26,7 @@ */ import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; +import { clearLocalized, setLocalized } from '@object-ui/i18n'; import { resolveNavItemLabel, type NavTargetLabelResolver } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; @@ -116,16 +117,19 @@ export function navEntryLabelText( * `{ zh, 'zh-TW' }`, because bare `zh` means `zh-Hans`) — so the editor shows * the new text straight after the edit. A map with none of these gains an * entry under the exact tag; an entry the resolver only reached as another - * language's fallback (`default`, `en` under `zh-CN`, a sibling region) is left - * as it was. Because the new entry is under the exact + * language's fallback (`default`, `en` under `zh-CN`, a sibling region, or the + * other Chinese script's entry: `zh` under `zh-Hant`) is left as it was. The + * entry is chosen by the shared write limb (`setLocalized`, see + * {@link ownsAnEntry}). Because the new entry is under the exact * tag, the next edit finds it first: an editor that writes on every keystroke * adds that entry once, never one per keystroke. */ export function renamedLabel(cur: I18nLabel | undefined, next: string, locale: string): I18nLabel { if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return next; const map: Record = cur; - const key = ownEntryKey(map, locale) ?? locale.trim(); - return { ...map, [key]: next }; + const tag = locale.trim(); + if (!ownsAnEntry(map, tag)) return { ...map, [tag]: next }; + return setLocalized(map, tag, next) as Record; } /** @@ -142,37 +146,27 @@ export function renamedLabel(cur: I18nLabel | undefined, next: string, locale: s */ export function clearedLabel(cur: I18nLabel | undefined, locale: string): I18nLabel | undefined { if (!cur || typeof cur !== 'object' || Array.isArray(cur)) return undefined; - const map: Record = { ...cur }; - const key = ownEntryKey(map, locale); - if (key !== undefined) delete map[key]; - return Object.values(map).some((v) => typeof v === 'string') ? map : undefined; + const tag = locale.trim(); + const map = ownsAnEntry(cur, tag) ? clearLocalized(cur, tag) : cur; + return map !== undefined && Object.values(map).some((v) => typeof v === 'string') + ? (map as Record) + : undefined; } /** - * The map entry an edit in `locale` owns: the exact tag, else the entry - * `resolveI18nLabel` reads in place of the bare language, else the bare - * language; `undefined` when the map has neither the tag nor the language. - * - * Which entry the resolver reads is asked of the resolver itself, over a map - * of each usable key to its own name, so this module keeps no copy of the - * resolver's limbs. With the bare language present the resolver reads it, - * unless it prefers a sibling of the locale's own script (`zh-TW` under - * `zh-Hant`, where bare `zh` means `zh-Hans`); then the sibling is the entry - * on screen and the one an edit owns. A sibling the resolver reaches only - * because the bare language is ABSENT stays another region's fallback, as - * before: the edit adds the exact tag. + * Whether an edit in `tag` owns an entry of `map` at all: the exact tag or the + * bare language is present. Which entry it then owns is the shared write limb's + * answer (`setLocalized` / `clearLocalized` in `@object-ui/i18n`), the entry + * the resolver shows: the exact tag, else the bare language, else, where the + * resolver prefers it, a sibling of the locale's own script (`zh-TW` under + * `zh-Hant` in `{ zh, 'zh-TW' }`). Where the platform table splits the language + * by script, an entry of the OTHER script is never owned: a `zh-Hant` edit over + * `{ en, zh }` adds `zh-Hant` and keeps the Simplified text. A sibling the + * resolver reaches only because the bare language is ABSENT stays another + * region's fallback, as before: the edit adds the exact tag. So this module + * keeps no copy of either side's limbs. */ -function ownEntryKey(map: Record, locale: string): string | undefined { - const tag = locale.trim(); +function ownsAnEntry(map: Record, tag: string): boolean { const own = (key: string) => Object.prototype.hasOwnProperty.call(map, key) && typeof map[key] === 'string'; - if (own(tag)) return tag; - const base = tag.split('-')[0]; - if (!own(base)) return undefined; - const keyOfEachEntry: Record = Object.fromEntries( - Object.keys(map) - .filter(own) - .map((key) => [key, key]), - ); - const read = resolveI18nLabel(keyOfEachEntry, locale); - return read !== undefined && read !== base && read.split('-')[0] === base ? read : base; + return own(tag) || own(tag.split('-')[0]); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/useMetadataLocale.sessionScript-31074.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/useMetadataLocale.sessionScript-31074.test.tsx new file mode 100644 index 00000000000..975273189c5 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/useMetadataLocale.sessionScript-31074.test.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #31074 — Studio's designer locale follows the session's Chinese script. + * + * `useMetadataLocale()` collapsed every `zh*` language to `'zh-CN'`, so a + * Traditional Chinese session designed in Simplified: the nav designer listed + * `{ en, zh: '简体项目', 'zh-TW': '繁體專案' }` as 简体项目 while the sidebar of + * the same session showed 繁體專案, and a rename there overwrote the Simplified + * `zh` text. The hook now resolves the session language through the console's + * own resolution (`resolveBuiltInLanguage`), so a session the console serves + * in `zh-Hant` designs as `'zh-Hant'`. + * + * Pinned through the real hook under a real `I18nProvider`, and through the + * two things a designer does with its answer: read the label, rename it. + * Chrome is the other half: the studio namespace ships one Chinese catalogue, + * so a Traditional designer reads Simplified chrome, never English. + */ + +import * as React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import type { I18nLabel } from '@objectstack/spec/ui'; + +import { isZhLocale, t, useMetadataLocale } from './i18n'; +import { navItemLabelText, renamedLabel } from './previews/navItemLabel'; + +afterEach(cleanup); + +const MAP: I18nLabel = { en: 'Proj EN', zh: '简体项目', 'zh-TW': '繁體專案' }; + +function Probe() { + const locale = useMetadataLocale(); + return ( + + {navItemLabelText(MAP, locale)} + + ); +} + +function designerLocaleIn(lang: string): string { + render( + + + , + ); + return screen.getByTestId('probe').getAttribute('data-locale') ?? ''; +} + +describe('useMetadataLocale follows the session script (#31074)', () => { + it.each(['zh-Hant', 'zh-TW', 'zh-HK'])('a %s session designs as zh-Hant and reads the Traditional entry', (lang) => { + expect(designerLocaleIn(lang)).toBe('zh-Hant'); + expect(screen.getByTestId('probe').textContent).toBe('繁體專案'); + }); + + it('a rename in that designer locale replaces zh-TW and leaves zh untouched', () => { + const locale = designerLocaleIn('zh-Hant'); + expect(renamedLabel(MAP, '改名後', locale)).toEqual({ en: 'Proj EN', zh: '简体项目', 'zh-TW': '改名後' }); + }); + + it('a rename in that designer locale over a Simplified-only map adds zh-Hant', () => { + const locale = designerLocaleIn('zh-Hant'); + expect(renamedLabel({ en: 'Proj EN', zh: '简体项目' }, '改名後', locale)).toEqual({ + en: 'Proj EN', + zh: '简体项目', + 'zh-Hant': '改名後', + }); + }); + + it.each(['zh-Hans', 'zh-CN', 'zh'])('CONTROL: a %s session designs as zh-CN and reads the Simplified entry', (lang) => { + expect(designerLocaleIn(lang)).toBe('zh-CN'); + expect(screen.getByTestId('probe').textContent).toBe('简体项目'); + }); + + it.each(['en', 'de', 'ja'])('CONTROL: a %s session designs as en-US', (lang) => { + expect(designerLocaleIn(lang)).toBe('en-US'); + }); + + it('a zh-Hant designer reads Chinese chrome (the Simplified catalogue), never English', () => { + expect(isZhLocale('zh-Hant')).toBe(true); + expect(t('engine.studio.cancel', 'zh-Hant')).toBe(t('engine.studio.cancel', 'zh-CN')); + }); +}); From 1c008fe17de79767edb7230654293fe5376da784 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:17:36 +0000 Subject: [PATCH 3/4] fix(cli): i18n coverage counts a blank inline entry the renderer shows as a gap Empty entries are judged after the shared resolver picks, so a blank zh-TW beside zh-CN is a gap for a zh-Hant reader rather than covered by zh-CN. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .../31074-cli-i18n-coverage-empty-entry.md | 10 +++++++++ packages/cli/src/utils/i18n-coverage.ts | 19 +++++++++-------- .../i18n-inline-locale-map-coverage.test.ts | 21 +++++++++++++++++-- 3 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 .changeset/31074-cli-i18n-coverage-empty-entry.md diff --git a/.changeset/31074-cli-i18n-coverage-empty-entry.md b/.changeset/31074-cli-i18n-coverage-empty-entry.md new file mode 100644 index 00000000000..4d68c18e25d --- /dev/null +++ b/.changeset/31074-cli-i18n-coverage-empty-entry.md @@ -0,0 +1,10 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): i18n coverage reports a blank inline-label entry the renderer shows as a gap + +Clause-②: no +Metadata-protocol: none + +An inline locale map with an empty entry that the renderer reaches for a locale no longer counts that locale as translated. `{ 'zh-CN': '成员', 'zh-TW': '' }` was reported covered for `zh-Hant` by the Simplified text, while the console shows the blank Traditional entry; it is now a `missing translation` finding, as an empty bundle value already is. Readers that never reach the blank entry are covered as before. diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index d57a9ff02ac..02e916bd59c 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -545,27 +545,28 @@ function collectExpectedKeys(config: any): ExpectedKey[] { * * Empty values do not count, matching {@link lookupKey}'s rule on the bundle * side — an empty translation is not a translation, whichever side it is - * written on. + * written on. They are judged AFTER the resolver has picked, not before: the + * renderer shows an empty entry when it is the one its limbs reach, so a blank + * `zh-TW` beside `zh-CN` leaves a `zh-Hant` reader looking at nothing, and a + * blank exact `zh-CN` beside `zh` leaves a `zh-CN` reader the same. Dropping + * empties first would let the other entry stand in and report that reader + * covered (#31074). */ export function inlineLocaleText( map: Readonly> | undefined, locale: string, ): string | undefined { if (!map) return undefined; - const read = (tag: string): string | undefined => { - if (!Object.prototype.hasOwnProperty.call(map, tag)) return undefined; - const value = map[tag]; - return typeof value === 'string' && value.length > 0 ? value : undefined; - }; const tag = (locale || 'en').trim(); const base = tag.split('-')[0]; const sameLanguage: Record = {}; for (const key of Object.keys(map)) { - const value = read(key); - if (value !== undefined && key.split('-')[0] === base) sameLanguage[key] = value; + const value = map[key]; + if (typeof value === 'string' && key.split('-')[0] === base) sameLanguage[key] = value; } if (Object.keys(sameLanguage).length === 0) return undefined; - return resolveI18nLabel(sameLanguage, tag); + const shown = resolveI18nLabel(sameLanguage, tag); + return shown !== undefined && shown.length > 0 ? shown : undefined; } /** diff --git a/packages/cli/test/i18n-inline-locale-map-coverage.test.ts b/packages/cli/test/i18n-inline-locale-map-coverage.test.ts index 1e5fbd95114..54755b530b4 100644 --- a/packages/cli/test/i18n-inline-locale-map-coverage.test.ts +++ b/packages/cli/test/i18n-inline-locale-map-coverage.test.ts @@ -277,6 +277,9 @@ describe('coverage reads the map the way the renderer does', () => { { en: 'Members', 'zh-CN': '成员', 'zh-TW': '成員' }, { en: 'Members', 'zh-TW': '成員', 'zh-CN': '成员' }, { en: 'Members', zh: '成员', 'zh-Hant': '成員' }, + // An empty entry the renderer reaches (#31074): that reader sees nothing. + { en: 'Members', 'zh-CN': '成员', 'zh-TW': '' }, + { en: 'Members', zh: '成员', 'zh-CN': '' }, ]; const locales = ['en', 'zh', 'zh-CN', 'zh-TW', 'zh-Hans', 'zh-Hant', 'zh-HK', 'ja-JP', 'es-ES']; @@ -309,7 +312,21 @@ describe('coverage reads the map the way the renderer does', () => { it("a map with both Chinese scripts covers each reader with its own script's entry (#30910)", () => { expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '成員' }, 'zh-Hant')).toBe('成員'); expect(inlineLocaleText({ 'zh-TW': '成員', 'zh-CN': '成员' }, 'zh')).toBe('成员'); - // An empty entry of the reader's script is no translation: the other script still covers. - expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBe('成员'); + // (The row that pinned an empty `zh-TW` as covered by `zh-CN` at `zh-Hant` + // moved to the #31074 case below: the renderer shows the empty entry.) + }); + + it('an empty entry the renderer shows is a gap, not covered by another entry (#31074)', () => { + // Own script: the reader of the Traditional script sees the blank `zh-TW`. + expect(resolveI18nLabel({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBe(''); + expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hant')).toBeUndefined(); + expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-HK')).toBeUndefined(); + // Own tag: an exact blank `zh-CN` shadows the bare `zh`. + expect(resolveI18nLabel({ zh: '成员', 'zh-CN': '' }, 'zh-CN')).toBe(''); + expect(inlineLocaleText({ zh: '成员', 'zh-CN': '' }, 'zh-CN')).toBeUndefined(); + // CONTROL: the readers that never reach the blank entry stay covered. + expect(inlineLocaleText({ 'zh-CN': '成员', 'zh-TW': '' }, 'zh-Hans')).toBe('成员'); + expect(inlineLocaleText({ zh: '成员', 'zh-CN': '' }, 'zh')).toBe('成员'); + expect(inlineLocaleText({ 'zh-CN': '成员' }, 'zh-TW')).toBe('成员'); }); }); From 410d25d60cf8e96d2c3c4c5e8808d6447ac9f5f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 04:36:08 +0000 Subject: [PATCH 4/4] test(app-shell): the designer-locale completeness pin lists zh-Hant Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .../BuilderLanding.environmentOverlayCopy-12171.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts index 03d1e638006..4f95d75ba2e 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts @@ -88,7 +88,7 @@ const ENVIRONMENT_OVERLAY = /\benvironment overlay\b|环境覆盖/iu; * `SupportedLocale`, so a locale added to that union fails the type-check here * until it is listed. */ -const DECLARED_LOCALES = { 'en-US': true, 'zh-CN': true } satisfies Record; +const DECLARED_LOCALES = { 'en-US': true, 'zh-CN': true, 'zh-Hant': true } satisfies Record; function tablesCarryingKeys(): Array<[string, Map]> { return [...readTables(REPO_ROOT)].filter(([, table]) => KEYS.some((key) => table.has(key)));