From 7228816bf5698efe4a3599256f34cc4f2f2ac923 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:08:24 +0000 Subject: [PATCH 1/5] fix(i18n): prefer the session's own script when an inline label map holds both Chinese scripts pickLocalized and its write-side twin localeWriteKey now share one limb function that, for a language the platform locale table splits by script (zh-Hans / zh-Hant), prefers a key meaning the session's own table tag (read through platformLocaleTagOf) before falling back to the first sibling. The spec twin resolveI18nLabel takes the same limb so the parity between the two resolvers holds. Maps without a same-script entry and every other language resolve exactly as before. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .changeset/30910-i18n-script-preference.md | 12 +++ ...0-spec-label-resolver-script-preference.md | 10 ++ .../spec/src/ui/i18n-label-resolver.test.ts | 102 +++++++++++++++--- packages/spec/src/ui/i18n-label-resolver.ts | 45 +++++++- .../i18n/src/__tests__/clearLocalized.test.ts | 13 +++ .../i18n/src/__tests__/pickLocalized.test.ts | 75 ++++++++++++- .../i18n/src/__tests__/setLocalized.test.ts | 46 ++++++++ ui/packages/i18n/src/pickLocalized.ts | 79 +++++++++++--- .../i18nLabel-resolver-parity.test.ts | 12 +++ 9 files changed, 362 insertions(+), 32 deletions(-) create mode 100644 .changeset/30910-i18n-script-preference.md create mode 100644 .changeset/30910-spec-label-resolver-script-preference.md diff --git a/.changeset/30910-i18n-script-preference.md b/.changeset/30910-i18n-script-preference.md new file mode 100644 index 00000000000..257472fed5f --- /dev/null +++ b/.changeset/30910-i18n-script-preference.md @@ -0,0 +1,12 @@ +--- +'@object-ui/i18n': patch +--- + +fix(i18n): a Traditional Chinese session reads and edits the Traditional entry of an inline label map + +Clause-②: no +Metadata-protocol: none + +`pickLocalized`, `setLocalized` and `clearLocalized` now prefer an entry of the session's own script where the platform locale table splits a language by script. In a `zh-Hant` (or `zh-TW`, `zh-HK`) session, a label written as `{ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }` shows `客戶` and an edit replaces the `zh-TW` entry; before, both took the first `zh-*` key in authoring order, so a Traditional session showed the Simplified text and an edit overwrote it. A `zh-Hans`, `zh-CN` or bare `zh` session prefers the Simplified entry the same way. + +Which tag a key means comes from `platformLocaleTagOf` in `@objectstack/spec/system`, and the preference applies only to a language the table names more than once (`zh` today). A map without an entry of the session's script, and every other language, resolve exactly as before. What an author can write is unchanged. diff --git a/.changeset/30910-spec-label-resolver-script-preference.md b/.changeset/30910-spec-label-resolver-script-preference.md new file mode 100644 index 00000000000..7c19ad6f32a --- /dev/null +++ b/.changeset/30910-spec-label-resolver-script-preference.md @@ -0,0 +1,10 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `resolveI18nLabel` prefers an inline label entry of the locale's own script + +Clause-②: no +Metadata-protocol: none + +Where the platform locale table splits a language by script (`zh-Hans`, `zh-Hant`), `resolveI18nLabel` now reads an entry of the requested locale's script before an entry of the other one: `zh-Hant`, `zh-TW` or `zh-HK` over `{ 'zh-CN': '客户', 'zh-TW': '客戶' }` answers `客戶` in any authoring order, and `zh`, `zh-CN` or `zh-Hans` answers `客户`. It used to take the first `zh-*` key. The script a key means is read from `platformLocaleTagOf`. A map without an entry of the locale's script, and every language the table names once, resolve exactly as before. This keeps the resolver limb for limb with objectui's `pickLocalized`, which takes the same change. The `InlineLocaleMapSchema` keys an author can write are unchanged. diff --git a/packages/spec/src/ui/i18n-label-resolver.test.ts b/packages/spec/src/ui/i18n-label-resolver.test.ts index 4a6ba751cc6..9ddca102785 100644 --- a/packages/spec/src/ui/i18n-label-resolver.test.ts +++ b/packages/spec/src/ui/i18n-label-resolver.test.ts @@ -14,13 +14,12 @@ * ## How the expectations were derived * * `pickLocalizedReference` below is a VERBATIM copy of the reference - * implementation, taken from + * implementation (with its two private helpers), taken from * - * repo objectstack-ai/objectui - * path packages/i18n/src/pickLocalized.ts - * rev origin/main d8d0d665dceba53dada4994f1eeef9f83bf1cf91 - * blob 30fcb0a86343b9b937432a1b2ea89e0a78321f46 - * last touched by objectui PR #4359 / objectui#3907 (2026-08-11) + * repo objectstack-ai/objectstack + * path ui/packages/i18n/src/pickLocalized.ts + * blob 37b88c07aebfca42ce375c1e5b653f8ea26765c8 + * last touched by #30910 (the script-preference limb) * * Copied rather than imported because `@objectstack/spec` must not take a * workspace dependency on objectui — spec sits UNDER objectui in the dependency @@ -48,11 +47,37 @@ import { describe, it, expect } from 'vitest'; import { resolveI18nLabel } from './i18n-label-resolver'; import type { I18nLabel } from './i18n.zod'; +import { PLATFORM_LOCALE_TAGS, platformLocaleTagOf } from '../system/platform-locales'; // --------------------------------------------------------------------------- // The reference implementation — verbatim, see the header for provenance. Only -// the NAME differs, so that a reader of a failing assertion can tell at a glance -// which side is the copy. +// the NAMES differ (a `Reference` suffix), so that a reader of a failing +// assertion can tell at a glance which side is the copy. +function splitScriptTagOfReference(lang: string): string | undefined { + const tag = platformLocaleTagOf(lang); + if (tag === undefined) return undefined; + const base = tag.split('-')[0]; + return PLATFORM_LOCALE_TAGS.filter((t) => t.split('-')[0] === base).length > 1 ? tag : undefined; +} + +function localizedEntryKeyReference( + lang: string, + usable: (key: string) => boolean, + keys: readonly string[], +): string | undefined { + if (usable(lang)) return lang; + const base = lang.split('-')[0]; + const siblings = keys.filter((k) => k.split('-')[0] === base && usable(k)); + const script = splitScriptTagOfReference(lang); + if (script !== undefined) { + if (usable(base) && platformLocaleTagOf(base) === script) return base; + const sameScript = siblings.find((k) => platformLocaleTagOf(k) === script); + if (sameScript !== undefined) return sameScript; + } + if (usable(base)) return base; + return siblings[0]; +} + function pickLocalizedReference(value: unknown, language: string | undefined | null): string { if (value == null) return ''; if (typeof value === 'string') return value; @@ -67,14 +92,11 @@ function pickLocalizedReference(value: unknown, language: string | undefined | n return typeof entry === 'string' ? entry : undefined; }; const lang = (language || 'en').trim(); - const base = lang.split('-')[0]; // Runtime language is often a bare base code ('zh') while metadata authors - // write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base. - const regional = Object.keys(o).find((k) => k.split('-')[0] === base && readReference(k) !== undefined); + // write full BCP-47 tags ('zh-CN') — upgrade to a key sharing the base. + const entry = localizedEntryKeyReference(lang, (k) => readReference(k) !== undefined, Object.keys(o)); const pick = - readReference(lang) ?? - readReference(base) ?? - (regional !== undefined ? readReference(regional) : undefined) ?? + (entry !== undefined ? readReference(entry) : undefined) ?? readReference('default') ?? readReference('en') ?? Object.values(o).find((v): v is string => typeof v === 'string'); @@ -123,10 +145,62 @@ const PARITY_VECTORS: readonly ParityVector[] = [ { limb: '3 base key wins over the region upgrade', label: { zh: '基础', 'zh-CN': '区域' }, locale: 'zh', pick: '基础' }, { limb: '3 first sibling in key order wins, not the "best" region', + label: { 'pt-PT': 'Proprietário (PT)', 'pt-BR': 'Proprietário' }, + locale: 'pt', + pick: 'Proprietário (PT)', + }, + { + limb: '3 first sibling in key order wins for a language the table names once (es-ES aliases es)', + label: { 'es-MX': 'Propietario (MX)', 'es-ES': 'Propietario' }, + locale: 'es', + pick: 'Propietario (MX)', + }, + + // Limbs 2–3, script first (#30910) — where the platform table splits the + // language by script (`zh-Hans`, `zh-Hant`), an entry of the locale's own + // script wins over the other script, whatever the authoring order. + { + limb: '2–3 bare zh means zh-Hans: the Simplified sibling wins over an earlier Traditional one', label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' }, locale: 'zh', + pick: '负责人', + }, + { + limb: '2–3 zh-Hant reads zh-TW over an earlier zh-CN', + label: { en: 'Owner', 'zh-CN': '负责人', 'zh-TW': '擁有者' }, + locale: 'zh-Hant', pick: '擁有者', }, + { + limb: '2–3 zh-HK reads zh-Hant over an earlier zh-Hans', + label: { en: 'Owner', 'zh-Hans': '负责人', 'zh-Hant': '擁有者' }, + locale: 'zh-HK', + pick: '擁有者', + }, + { + limb: '2–3 zh-TW skips a bare zh key (it means zh-Hans) for zh-Hant', + label: { zh: '负责人', 'zh-Hant': '擁有者' }, + locale: 'zh-TW', + pick: '擁有者', + }, + { + limb: '2–3 zh-CN keeps the bare zh key when it means the same script', + label: { 'zh-Hans': '简体', zh: '负责人' }, + locale: 'zh-CN', + pick: '负责人', + }, + { + limb: '2–3 no entry of the locale\'s script ⇒ the old limbs answer', + label: { en: 'Owner', 'zh-CN': '负责人' }, + locale: 'zh-Hant', + pick: '负责人', + }, + { + limb: '2–3 a script-subtag spelling the alias table does not name is not guessed at', + label: { 'zh-CN': '负责人', 'zh-Hant-US': '擁有者' }, + locale: 'zh-Hant', + pick: '负责人', + }, { limb: '3 runs BEFORE default — a wrong-region hit beats the untagged entry', label: { default: 'Owner', 'fr-FR': 'Propriétaire' }, diff --git a/packages/spec/src/ui/i18n-label-resolver.ts b/packages/spec/src/ui/i18n-label-resolver.ts index aa2b20731c2..44e3c1d7ce4 100644 --- a/packages/spec/src/ui/i18n-label-resolver.ts +++ b/packages/spec/src/ui/i18n-label-resolver.ts @@ -31,9 +31,9 @@ * with neither side erroring — the server-rendered column header and the * client-rendered one simply differ. * - * The reference is objectui `packages/i18n/src/pickLocalized.ts` (read at - * `origin/main` `d8d0d66`, blob `30fcb0a8`, last touched by objectui PR #4359 / - * objectui#3907). Its rule, mirrored here limb for limb: + * The reference is `ui/packages/i18n/src/pickLocalized.ts` in this repository + * (the blob is pinned in `i18n-label-resolver.test.ts`, last touched by #30910). + * Its rule, mirrored here limb for limb: * * | # | limb | note | * |---|---|---| @@ -42,6 +42,7 @@ * | 1 | exact tag — `map[locale]` | `zh-CN` matches the key `zh-CN` | * | 2 | base language — `map[base]` | `zh-CN` → `zh` | * | 3 | first region-qualified sibling sharing the base | `zh` → `zh-CN`; **key insertion order** decides | + * | 2–3 | …an entry of the locale's own script first | `zh-Hant` over `{ zh-CN, zh-TW }` → `zh-TW`; see {@link scriptEntryKey} | * | 4 | `map.default` | the untagged entry `InlineLocaleMapSchema` admits | * | 5 | `map.en` | the platform's source language | * | 6 | first string value | insertion order again | @@ -64,6 +65,11 @@ * no key of that map on limbs 1, 2 or 3, so the request falls through to * `default` / `en` / any-string and can land in the wrong language entirely. * + * The one place case folds is the script preference of limbs 2–3, because it + * asks {@link platformLocaleTagOf}, which folds: `zh-hant` prefers a `zh-TW` + * key. It only reorders keys the base comparison above already admitted, so + * `ZH-HANT` still matches no `zh-*` key at all. + * * BCP-47 says subtags are case-insensitive, so a stricter reading would fold * case — but parity with the reference is the ruled acceptance, and a resolver * that folded case would answer differently from the renderer for exactly the @@ -138,6 +144,33 @@ */ import type { I18nLabel } from './i18n.zod'; +import { PLATFORM_LOCALE_TAGS, platformLocaleTagOf } from '../system/platform-locales'; + +/** + * Limbs 2–3's script preference, shared limb for limb with `pickLocalized`'s + * `localizedEntryKey`: the key of `map` that means the same platform table + * tag as `tag`, or `undefined`. + * + * It answers only where the platform table splits the base language into more + * than one tag (`zh` into `zh-Hans` and `zh-Hant`), and reads both facts off + * the table: which tag a code means is {@link platformLocaleTagOf} (`zh-TW` + * means `zh-Hant`, `zh-CN` and bare `zh` mean `zh-Hans`). The bare base key + * comes first when it means that tag; otherwise the first sibling that does, + * in key insertion order. A base the table names once (`pt`, `en`), or a map + * with no entry of the locale's script, answers `undefined`, and limbs 2 and + * 3 then run exactly as they always did. + */ +function scriptEntryKey(map: Record, tag: string, base: string): string | undefined { + const script = platformLocaleTagOf(tag); + if (script === undefined) return undefined; + const scriptBase = script.split('-')[0]; + if (PLATFORM_LOCALE_TAGS.filter((t) => t.split('-')[0] === scriptBase).length < 2) return undefined; + if (readEntry(map, base) !== undefined && platformLocaleTagOf(base) === script) return base; + return Object.keys(map).find( + (key) => + key.split('-')[0] === base && readEntry(map, key) !== undefined && platformLocaleTagOf(key) === script, + ); +} /** * The locale `pickLocalized` assumes when the caller has none — and the last @@ -239,6 +272,12 @@ export function resolveI18nLabel( const exact = readEntry(map, tag); if (exact !== undefined) return exact; + // 2–3, script first — in a locale whose language the platform table splits + // by script, an entry of the locale's own script (`zh-Hant` over + // `{ zh-CN, zh-TW }` reads `zh-TW`) before any entry of the other one. + const sameScript = scriptEntryKey(map, tag, base); + if (sameScript !== undefined) return readEntry(map, sameScript); + // 2 — base language (`zh-CN` → `zh`). const baseHit = readEntry(map, base); if (baseHit !== undefined) return baseHit; diff --git a/ui/packages/i18n/src/__tests__/clearLocalized.test.ts b/ui/packages/i18n/src/__tests__/clearLocalized.test.ts index a80d5a0b92b..a3fc7846ac6 100644 --- a/ui/packages/i18n/src/__tests__/clearLocalized.test.ts +++ b/ui/packages/i18n/src/__tests__/clearLocalized.test.ts @@ -115,3 +115,16 @@ describe('clearLocalized — paired with setLocalized', () => { expect(clearLocalized(stored, 'en')).toEqual({ 'zh-CN': '收入', bogus: 42 }); }); }); + +describe('clearLocalized — a script-split language clears the entry of its own script (#30910)', () => { + it('a zh-Hant clear removes zh-TW and keeps the Simplified entry, in either authoring order', () => { + expect(clearLocalized({ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }, 'zh-Hant')).toEqual({ + en: 'Customer', + 'zh-CN': '客户', + }); + expect(clearLocalized({ en: 'Customer', 'zh-TW': '客戶', 'zh-CN': '客户' }, 'zh')).toEqual({ + en: 'Customer', + 'zh-TW': '客戶', + }); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/pickLocalized.test.ts b/ui/packages/i18n/src/__tests__/pickLocalized.test.ts index a4e0c8973fd..f0cee5d873c 100644 --- a/ui/packages/i18n/src/__tests__/pickLocalized.test.ts +++ b/ui/packages/i18n/src/__tests__/pickLocalized.test.ts @@ -180,7 +180,8 @@ describe('pickLocalized — controls: every real language tag is byte-identical [{ en: 'Pricing', 'zh-CN': '定价' }, 'zh', '定价'], [{ en: 'Pricing', 'zh-CN': '定价' }, 'zh-TW', '定价'], [{ 'pt-BR': 'Preços' }, 'pt-PT', 'Preços'], - [{ 'zh-TW': '區域', 'zh-CN': '区域' }, 'zh', '區域'], + // `{ 'zh-TW', 'zh-CN' }` at `zh` moved to the script-preference block below: + // it holds an entry of the session's own script, so it now reads `zh-CN`. // Limb 4 — `default` outranks `en`. [{ default: 'D', en: 'E' }, 'fr', 'D'], // Limb 5 — `en`. @@ -213,3 +214,75 @@ describe('pickLocalized — controls: every real language tag is byte-identical expect(pickLocalized(true, 'en')).toBe('true'); }); }); + +/** + * #30910 — where the platform table splits a language by script (`zh` into + * `zh-Hans` and `zh-Hant`), the base-language and sibling limbs prefer an entry + * of the session's own script, read through the spec's `platformLocaleTagOf` + * (`zh-TW`, `zh-HK` mean `zh-Hant`; `zh`, `zh-CN` mean `zh-Hans`), whatever the + * authoring order. Before, they took the FIRST `zh-*` key, so a `zh-Hant` + * console showed (and, through `localeWriteKey`, overwrote) the `zh-CN` entry. + */ +describe('pickLocalized — an entry of the session\'s own script first (#30910)', () => { + const SIMPLIFIED = ['zh-Hans', 'zh-CN', 'zh-SG', 'zh', 'zh-Hans-CN'] as const; + const TRADITIONAL = ['zh-Hant', 'zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant-TW'] as const; + + /** `[name, map]` — every map holds one Simplified and one Traditional entry. */ + const BOTH_SCRIPTS: ReadonlyArray]> = [ + ['zh-CN + zh-TW', { en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }], + ['zh-TW + zh-CN (Traditional authored first)', { en: 'Customer', 'zh-TW': '客戶', 'zh-CN': '客户' }], + ['zh-Hans + zh-Hant', { en: 'Customer', 'zh-Hans': '客户', 'zh-Hant': '客戶' }], + ['zh-Hant + zh-Hans', { en: 'Customer', 'zh-Hant': '客戶', 'zh-Hans': '客户' }], + ['zh-CN + zh-HK', { en: 'Customer', 'zh-CN': '客户', 'zh-HK': '客戶' }], + ['bare zh + zh-TW', { en: 'Customer', zh: '客户', 'zh-TW': '客戶' }], + ]; + + for (const [name, map] of BOTH_SCRIPTS) { + it.each(TRADITIONAL)(`${name}: a %s session reads the Traditional entry`, (lang) => { + expect(pickLocalized(map, lang)).toBe('客戶'); + }); + it.each(SIMPLIFIED)(`${name}: a %s session reads the Simplified entry`, (lang) => { + expect(pickLocalized(map, lang)).toBe('客户'); + }); + } + + it('the vector that used to read the first sibling now reads the session\'s script', () => { + // Before #30910 this answered '區域' — the first `zh-*` key in authoring + // order — though bare `zh` means `zh-Hans`. + expect(pickLocalized({ 'zh-TW': '區域', 'zh-CN': '区域' }, 'zh')).toBe('区域'); + }); + + it('the bare base key still wins when it means the session\'s script', () => { + expect(pickLocalized({ zh: '基础', 'zh-CN': '区域', 'zh-TW': '區域' }, 'zh-CN')).toBe('区域'); + expect(pickLocalized({ 'zh-Hans': '简体', zh: '基础' }, 'zh-CN')).toBe('基础'); + expect(pickLocalized({ 'zh-Hans': '简体', zh: '基础' }, 'zh-Hans-SG')).toBe('基础'); + }); + + /** + * Byte-identical controls: a map with no entry of the session's script, and + * every language the table names once. `[map, locale, answer on main]`. + */ + const UNCHANGED: ReadonlyArray, string, string]> = [ + // One script only — nothing to prefer, so the old limbs answer. + [{ en: 'Customer', 'zh-CN': '客户' }, 'zh-Hant', '客户'], + [{ en: 'Customer', 'zh-HK': '客戶' }, 'zh-Hans', '客戶'], + [{ en: 'Customer', 'zh-HK': '客戶' }, 'zh-CN', '客戶'], + [{ en: 'Customer', zh: '客户' }, 'zh-Hant', '客户'], + [{ en: 'Customer', zh: '客户' }, 'zh-TW', '客户'], + // A script-subtag spelling the alias table does not name is not guessed at. + [{ 'zh-CN': '客户', 'zh-Hant-US': '客戶' }, 'zh-Hant', '客户'], + // Languages the table names once keep authoring order on the sibling limb. + [{ 'pt-PT': 'PT', 'pt-BR': 'BR' }, 'pt', 'PT'], + [{ 'pt-PT': 'PT', 'pt-BR': 'BR' }, 'pt-BR', 'BR'], + [{ 'es-MX': 'MX', 'es-ES': 'ES' }, 'es', 'MX'], + [{ 'en-GB': 'GB', 'en-US': 'US' }, 'en', 'GB'], + [{ 'fr-CA': 'CA', 'fr-FR': 'FR' }, 'fr', 'CA'], + [{ 'ja-JP': 'J', en: 'E' }, 'ja', 'J'], + // The language subtag's case still decides the base limb. + [{ 'zh-CN': '客户', 'zh-TW': '客戶', en: 'Customer' }, 'ZH-HANT', 'Customer'], + ]; + + it.each(UNCHANGED)('resolves %j at %j to %j, exactly as before', (map, locale, expected) => { + expect(pickLocalized(map, locale)).toBe(expected); + }); +}); diff --git a/ui/packages/i18n/src/__tests__/setLocalized.test.ts b/ui/packages/i18n/src/__tests__/setLocalized.test.ts index 52980fd3bbc..7f8709dc3dc 100644 --- a/ui/packages/i18n/src/__tests__/setLocalized.test.ts +++ b/ui/packages/i18n/src/__tests__/setLocalized.test.ts @@ -191,3 +191,49 @@ describe('setLocalized ∘ pickLocalized — what you write is what you read', ( expect(Object.keys(written)).toEqual(Object.keys(stored)); }); }); + +/** + * #30910 — the write side takes the same script-preference limb as the read + * side (one `localizedEntryKey` in `pickLocalized.ts`). A `zh-Hant` edit over + * `{ 'zh-CN', 'zh-TW' }` used to overwrite the Simplified authors' `zh-CN` text + * and leave `zh-TW` stale. + */ +describe('setLocalized — a script-split language writes the entry of its own script (#30910)', () => { + /** `[map, language, the key the edit must replace]` */ + const WRITES: ReadonlyArray, string, string]> = [ + [{ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }, 'zh-Hant', 'zh-TW'], + [{ en: 'Customer', 'zh-CN': '客户', 'zh-TW': '客戶' }, 'zh-HK', 'zh-TW'], + [{ en: 'Customer', 'zh-TW': '客戶', 'zh-CN': '客户' }, 'zh-Hans', 'zh-CN'], + [{ en: 'Customer', 'zh-TW': '客戶', 'zh-CN': '客户' }, 'zh', 'zh-CN'], + [{ en: 'Customer', 'zh-Hans': '客户', 'zh-Hant': '客戶' }, 'zh-TW', 'zh-Hant'], + [{ en: 'Customer', 'zh-Hans': '客户', 'zh-Hant': '客戶' }, 'zh-CN', 'zh-Hans'], + [{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-Hant', 'zh-TW'], + [{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-CN', 'zh'], + ]; + + it.each(WRITES)('%j edited in %s replaces %s and nothing else', (map, language, key) => { + const written = setLocalized(map, language, 'WRITTEN') as Record; + expect(written[key]).toBe('WRITTEN'); + for (const other of Object.keys(map).filter((k) => k !== key)) { + expect(written[other]).toBe(map[other]); + } + expect(Object.keys(written)).toEqual(Object.keys(map)); + expect(pickLocalized(written, language)).toBe('WRITTEN'); + }); + + /** Byte-identical controls: `[map, language, the key main writes]`. */ + 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'], + [{ en: 'Customer' }, 'zh-Hant', 'zh-Hant'], + ]; + + it.each(UNCHANGED)('%j edited in %s still writes %s, exactly as before', (map, language, key) => { + const written = setLocalized(map, language, 'WRITTEN') as Record; + expect(written[key]).toBe('WRITTEN'); + }); +}); diff --git a/ui/packages/i18n/src/pickLocalized.ts b/ui/packages/i18n/src/pickLocalized.ts index a72b5e48363..37b88c07aeb 100644 --- a/ui/packages/i18n/src/pickLocalized.ts +++ b/ui/packages/i18n/src/pickLocalized.ts @@ -1,11 +1,67 @@ +import { PLATFORM_LOCALE_TAGS, platformLocaleTagOf } from '@objectstack/spec/system'; + +/** + * The platform table tag `lang` means, when the table splits its base language + * into more than one tag (`zh` into `zh-Hans` and `zh-Hant`), else `undefined`. + * + * Both readings are the spec's: which tag a code means is + * `platformLocaleTagOf` (`zh-TW` and `zh-HK` mean `zh-Hant`, `zh` and `zh-CN` + * mean `zh-Hans`), and whether a base is split is read off + * `PLATFORM_LOCALE_TAGS`. A base the table names once (`pt`, `en`) answers + * `undefined`, so its siblings keep resolving in authoring order. + */ +function splitScriptTagOf(lang: string): string | undefined { + const tag = platformLocaleTagOf(lang); + if (tag === undefined) return undefined; + const base = tag.split('-')[0]; + return PLATFORM_LOCALE_TAGS.filter((t) => t.split('-')[0] === base).length > 1 ? tag : undefined; +} + +/** + * The entry key the first three limbs pick for `lang` (already normalized), + * or `undefined` when none hits. ONE function for the read side + * ({@link pickLocalized}) and the write side ({@link localeWriteKey}), so the + * entry an author sees is the entry an edit replaces. + * + * 1. the exact tag; + * 2. the base language (`zh-CN` -> `zh`), when it means the session's language; + * 3. a sibling sharing the base that means the session's language: in a + * `zh-Hant` session over `{ 'zh-CN', 'zh-TW' }` that is `zh-TW`, whatever the + * authoring order. Applies only where {@link splitScriptTagOf} answers; + * 4. the base language, whatever it means; + * 5. the first sibling sharing the base, in authoring order. + * + * Limbs 2 and 3 only reorder entries of the session's own language ahead of + * the others. A map with no such entry, and every language whose base the + * table names once, resolve exactly as limbs 4 and 5 always did. + */ +function localizedEntryKey( + lang: string, + usable: (key: string) => boolean, + keys: readonly string[], +): string | undefined { + if (usable(lang)) return lang; + const base = lang.split('-')[0]; + const siblings = keys.filter((k) => k.split('-')[0] === base && usable(k)); + const script = splitScriptTagOf(lang); + if (script !== undefined) { + if (usable(base) && platformLocaleTagOf(base) === script) return base; + const sameScript = siblings.find((k) => platformLocaleTagOf(k) === script); + if (sameScript !== undefined) return sameScript; + } + if (usable(base)) return base; + return siblings[0]; +} + /** * Resolve a possibly-localized value to the best string for a language. * * Accepts a plain string (passed through) or an i18n object keyed by language * code — e.g. `{ en: 'Pricing', 'zh-CN': '定价', zh: '定价', default: 'Pricing' }`. * Resolution order: exact language -> base language (`zh-CN` -> `zh`) -> any - * region-qualified key sharing the base (`zh` -> `zh-CN`) -> `default` -> `en` - * -> first value. Unlike a `default`/`en`-only resolver this is genuinely + * region-qualified key sharing the base (`zh` -> `zh-CN`), an entry of the + * session's own script first (see {@link localizedEntryKey}) -> `default` -> + * `en` -> first value. Unlike a `default`/`en`-only resolver this is genuinely * locale-aware, so server-driven metadata (page text, action param labels) can * carry inline translations instead of rendering "[object Object]" or English. * @@ -49,16 +105,13 @@ export function pickLocalized(value: unknown, language: string | undefined | nul return typeof entry === 'string' ? entry : undefined; }; const lang = (language || 'en').trim(); - const base = lang.split('-')[0]; // Runtime language is often a bare base code ('zh') while metadata authors - // write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base. + // write full BCP-47 tags ('zh-CN') — upgrade to a key sharing the base. // `Object.keys` is already own-and-enumerable, so `read` here is uniformity // rather than a second guard. - const regional = Object.keys(o).find((k) => k.split('-')[0] === base && read(k) !== undefined); + const entry = localizedEntryKey(lang, (k) => read(k) !== undefined, Object.keys(o)); const pick = - read(lang) ?? - read(base) ?? - (regional !== undefined ? read(regional) : undefined) ?? + (entry !== undefined ? read(entry) : undefined) ?? read('default') ?? read('en') ?? Object.values(o).find((v): v is string => typeof v === 'string'); @@ -71,8 +124,9 @@ export function pickLocalized(value: unknown, language: string | undefined | nul * The map key an edit made in `language` must be WRITTEN to — the write-side * twin of {@link pickLocalized}'s read limbs. * - * It deliberately implements only the first three limbs (exact tag, base - * language, region-qualified sibling) and then STOPS. `pickLocalized` continues + * 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 * 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 @@ -95,12 +149,9 @@ function localeWriteKey( language: string | undefined | null, ): string { const lang = (language || 'en').trim() || 'en'; - const base = lang.split('-')[0]; const has = (key: string): boolean => Object.prototype.hasOwnProperty.call(o, key) && typeof o[key] === 'string'; - if (has(lang)) return lang; - if (has(base)) return base; - return Object.keys(o).find((k) => k.split('-')[0] === base && has(k)) ?? lang; + return localizedEntryKey(lang, has, Object.keys(o)) ?? lang; } /** diff --git a/ui/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts b/ui/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts index 320ac38a55b..3f8edc175c5 100644 --- a/ui/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts +++ b/ui/packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts @@ -116,6 +116,18 @@ const TABLE: ReadonlyArray = [ [{ en: 'Sales', 'zh-CN': '销售' }, 'zh', '销售'], [{ en: 'Sales', 'zh-CN': '销售' }, 'zh-TW', '销售'], [{ 'pt-BR': 'Vendas' }, 'pt-PT', 'Vendas'], + // …the first one in authoring order, for a language the table names once. + [{ 'pt-PT': 'Vendas (PT)', 'pt-BR': 'Vendas' }, 'pt', 'Vendas (PT)'], + + // Limbs 2–3, script first (#30910): where the platform table splits the + // language by script, an entry of the session's own script wins, whatever + // the authoring order — on both sides, so the nav (spec resolver) and the + // plugin text (`pickLocalized`) of one zh-Hant session agree. + [{ en: 'Sales', 'zh-CN': '销售', 'zh-TW': '銷售' }, 'zh-Hant', '銷售'], + [{ en: 'Sales', 'zh-TW': '銷售', 'zh-CN': '销售' }, 'zh-Hans', '销售'], + [{ en: 'Sales', 'zh-Hans': '销售', 'zh-Hant': '銷售' }, 'zh-TW', '銷售'], + [{ en: 'Sales', zh: '销售', 'zh-HK': '銷售' }, 'zh-Hant', '銷售'], + [{ 'zh-TW': '銷售', 'zh-CN': '销售' }, 'zh', '销售'], // Limb 4 — `default` outranks `en`. [{ default: 'Default', en: 'English' }, 'fr', 'Default'], From 7422324200c9003cc0dc1f1e56639b6f63a0cbed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:20:54 +0000 Subject: [PATCH 2/5] fix(app-shell): the Studio nav label editor writes the entry the resolver shows ownEntryKey asked for the exact tag, else the bare language. Where the resolver now prefers a sibling of the locale's own script over a bare language of the other script, the editor overwrote and cleared the entry it was not showing. It now asks resolveI18nLabel which entry it reads, over a map of each key to its own name, and keeps no copy of the resolver's limbs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .../navItemLabel.inherit-11196.test.ts | 57 +++++++++++++++++++ .../metadata-admin/previews/navItemLabel.ts | 37 ++++++++++-- 2 files changed, 88 insertions(+), 6 deletions(-) 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 31eaf3d86b8..17b6da16b8f 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 @@ -26,10 +26,13 @@ import { describe, it, expect } from 'vitest'; import { resolveNavItemLabel, type NavTargetLabelResolver } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; +import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import { clearedLabel, inheritedNavEntryText, navEntryLabelText, + navItemLabelText, + renamedLabel, type NavEntryLike, } from './navItemLabel'; @@ -150,3 +153,57 @@ describe('objectui#11196 — clearedLabel restores inheritance, the inverse of r expect(clearedLabel({ 'zh-CN': '客户' }, 'zh-CN')).toBeUndefined(); }); }); + +/** + * #30910 — where the platform table splits a language by script, the resolver + * reads a sibling of the locale's own script over a bare language of the other + * one (`zh-TW` under `zh-Hant` in `{ zh, 'zh-TW' }`, bare `zh` meaning + * `zh-Hans`). The entry a rename writes and a clear deletes must be that same + * entry, or a Traditional editor overwrites the Simplified text while the + * Traditional text stays on screen. Pinned against `resolveI18nLabel` itself. + */ +describe('#30910 — renamedLabel and clearedLabel change the entry the resolver shows', () => { + /** `[map, designer locale, the key the resolver reads and an edit owns]` */ + const OWNED: ReadonlyArray, string, string]> = [ + [{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-Hant', 'zh-TW'], + [{ en: 'Customer', zh: '客户', 'zh-TW': '客戶' }, 'zh-TW', 'zh-TW'], + [{ en: 'Customer', zh: '客户', 'zh-HK': '客戶' }, 'zh-HK', 'zh-HK'], + [{ en: 'Customer', zh: '客户', 'zh-Hant': '客戶' }, 'zh-HK', 'zh-Hant'], + [{ en: 'Customer', 'zh-TW': '客戶', zh: '客户' }, 'zh-CN', 'zh'], + [{ en: 'Customer', 'zh-TW': '客戶', zh: '客户' }, 'zh-Hans', 'zh'], + ]; + + it.each(OWNED)('%j in %s: the resolver reads %s', (map, locale, key) => { + expect(resolveI18nLabel(map as I18nLabel, locale)).toBe(map[key]); + }); + + it.each(OWNED)('%j renamed in %s writes %s, shows the new text, and keeps every other entry', (map, locale, key) => { + const next = renamedLabel(map as I18nLabel, 'RENAMED', locale) as Record; + expect(next[key]).toBe('RENAMED'); + for (const other of Object.keys(map).filter((k) => k !== key)) expect(next[other]).toBe(map[other]); + expect(Object.keys(next)).toEqual(Object.keys(map)); + expect(navItemLabelText(next as I18nLabel, locale)).toBe('RENAMED'); + }); + + it.each(OWNED)('%j cleared in %s deletes %s and nothing else', (map, locale, key) => { + const rest = Object.fromEntries(Object.entries(map).filter(([k]) => k !== key)); + expect(clearedLabel(map as I18nLabel, locale)).toEqual(rest); + }); + + /** Byte-identical controls: `[map, locale, what main's rename wrote]`. */ + const UNCHANGED: ReadonlyArray, string, Record]> = [ + // 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' }], + [{ 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)' }], + [{ pt: 'Contas', 'pt-PT': 'Contas (PT)' }, 'pt-BR', { pt: 'RENAMED', 'pt-PT': 'Contas (PT)' }], + ]; + + it.each(UNCHANGED)('%j renamed in %s writes exactly as before', (map, locale, expected) => { + expect(renamedLabel(map as I18nLabel, 'RENAMED', locale)).toEqual(expected); + }); +}); 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 90497709cb2..698171232e4 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 @@ -111,10 +111,13 @@ export function navEntryLabelText( * language's text. Only the designer locale's entry changes, and every other * entry is kept. The entry written is the one `resolveI18nLabel` reads first * for this locale — the exact tag, else its bare language (`en` under - * `en-US`) — so the editor shows the new text straight after the edit. A map - * with neither 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 + * `en-US`), else, where the resolver prefers it over that bare language, a + * sibling of the locale's own script (`zh-TW` under `zh-Hant` in + * `{ 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 * tag, the next edit finds it first: an editor that writes on every keystroke * adds that entry once, never one per keystroke. */ @@ -145,9 +148,31 @@ export function clearedLabel(cur: I18nLabel | undefined, locale: string): I18nLa return Object.values(map).some((v) => typeof v === 'string') ? map : undefined; } -/** The map entry an edit in `locale` owns: the exact tag, else its bare language; `undefined` when the map has neither. */ +/** + * 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. + */ function ownEntryKey(map: Record, locale: string): string | undefined { const tag = locale.trim(); const own = (key: string) => Object.prototype.hasOwnProperty.call(map, key) && typeof map[key] === 'string'; - return [tag, tag.split('-')[0]].find(own); + 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; } From 4b4e0036857c41f35dcb7427296ac5463c4186d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:23:42 +0000 Subject: [PATCH 3/5] fix(cli): i18n coverage reads a two-script inline map with the shared resolver inlineLocaleText now narrows the map to the reader's language and asks resolveI18nLabel, so a zh-Hant reader is covered by its own script's entry rather than by the first zh-* key. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- packages/cli/src/utils/i18n-coverage.ts | 37 +++++++++---------- .../i18n-inline-locale-map-coverage.test.ts | 14 ++++++- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index dffa3317905..d57a9ff02ac 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -44,6 +44,7 @@ import { deepMerge } from '@objectstack/core'; import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; +import { resolveI18nLabel } from '@objectstack/spec/ui'; import { collectExpectedEntries, type ExpectedEntry } from './i18n-bundle-extract.js'; export type CoverageSeverity = 'error' | 'warning'; @@ -518,15 +519,16 @@ function collectExpectedKeys(config: any): ExpectedKey[] { * as covered the moment a map exists, which is the mirror image of the bug * this function was added to fix: one answer standing for two opposite facts. * - * So only the **tag-matching** limbs count as coverage, in the reference's own - * order and with its own case rules: - * - * 1. the exact tag — `zh-CN` reads the key `zh-CN`; - * 2. the base language — `zh-CN` reads the key `zh`; - * 3. the first region-qualified sibling sharing that base — `zh` reads - * `zh-CN`; `zh-TW` reads `zh-CN` too. Same language, other region: what - * the renderer really shows, and a translation into the language asked - * for. + * So only the **tag-matching** limbs count as coverage: the entries whose + * language subtag is the locale's (the exact tag, the bare language, a + * region-qualified sibling). Which of them the reader sees is asked of + * `resolveI18nLabel` itself, over the map cut down to those entries, so this + * function keeps no copy of the resolver's limb order: `zh-CN` reads `zh-CN`, + * else `zh`; `zh` reads `zh-CN`; `zh-TW` reads `zh-CN` when the map has no + * Traditional entry, and `zh-TW` over `zh-CN` when it has both (the script + * preference where the platform table splits a language). Same language, + * other region: what the renderer really shows, and a translation into the + * language asked for. * * The fallback limbs are excluded because falling back **is** what an * untranslated locale looks like: a `ja-JP` reader shown the `en` entry is @@ -536,7 +538,8 @@ function collectExpectedKeys(config: any): ExpectedKey[] { * `i18n-label-resolver.ts` is the authority on the limb rule and on the case * asymmetry mirrored here (region case is irrelevant because limb 3 compares * language subtags only; language case is significant, because the reference - * folds neither side). `inline-locale-coverage-parity.test.ts` pins this + * folds neither side). The describe block "coverage reads the map the way the + * renderer does" in `test/i18n-inline-locale-map-coverage.test.ts` pins this * function against `resolveI18nLabel`: whenever this says "covered", the * renderer really shows that entry. * @@ -555,18 +558,14 @@ export function inlineLocaleText( return typeof value === 'string' && value.length > 0 ? value : undefined; }; const tag = (locale || 'en').trim(); - const exact = read(tag); - if (exact !== undefined) return exact; const base = tag.split('-')[0]; - const baseHit = read(base); - if (baseHit !== undefined) return baseHit; + const sameLanguage: Record = {}; for (const key of Object.keys(map)) { - if (key.split('-')[0] === base) { - const sibling = read(key); - if (sibling !== undefined) return sibling; - } + const value = read(key); + if (value !== undefined && key.split('-')[0] === base) sameLanguage[key] = value; } - return undefined; + if (Object.keys(sameLanguage).length === 0) return undefined; + return resolveI18nLabel(sameLanguage, tag); } /** 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 59e8a022322..1e5fbd95114 100644 --- a/packages/cli/test/i18n-inline-locale-map-coverage.test.ts +++ b/packages/cli/test/i18n-inline-locale-map-coverage.test.ts @@ -272,8 +272,13 @@ describe('coverage reads the map the way the renderer does', () => { { 'zh-CN': '成员' }, { default: 'Members', 'zh-CN': '成员' }, { en: 'Members' }, + // Both Chinese scripts, in either authoring order (#30910): the renderer + // reads the entry of the reader's own script, and so does coverage. + { en: 'Members', 'zh-CN': '成员', 'zh-TW': '成員' }, + { en: 'Members', 'zh-TW': '成員', 'zh-CN': '成员' }, + { en: 'Members', zh: '成员', 'zh-Hant': '成員' }, ]; - const locales = ['en', 'zh', 'zh-CN', 'zh-TW', 'ja-JP', 'es-ES']; + const locales = ['en', 'zh', 'zh-CN', 'zh-TW', 'zh-Hans', 'zh-Hant', 'zh-HK', 'ja-JP', 'es-ES']; it('a covered locale is the entry the renderer picks', () => { for (const map of maps) { @@ -300,4 +305,11 @@ describe('coverage reads the map the way the renderer does', () => { expect(inlineLocaleText({ 'zh-CN': '成员' }, 'zh-TW')).toBe('成员'); expect(inlineLocaleText({ zh: '成员' }, 'zh-CN')).toBe('成员'); }); + + 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('成员'); + }); }); From 2d5cae2c8dc5f85722530c7bc7e0421b37e119d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:23:43 +0000 Subject: [PATCH 4/5] docs(spec): label resolver prose matches the limb order and the one-repo home The script-preference row sits before limbs 2 and 3 where the code runs it, and the parity note no longer calls a change a two-repo decision. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- packages/spec/src/ui/i18n-label-resolver.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ui/i18n-label-resolver.ts b/packages/spec/src/ui/i18n-label-resolver.ts index 44e3c1d7ce4..425ede3adf6 100644 --- a/packages/spec/src/ui/i18n-label-resolver.ts +++ b/packages/spec/src/ui/i18n-label-resolver.ts @@ -10,7 +10,7 @@ * `I18nLabelSchema` (see `./i18n.zod`) authorizes two forms of a display label: * a plain string, and an inline locale map `{ en: 'Owner', 'zh-CN': '负责人' }`. * Until now only ONE end of the platform knew what the second form *means*: - * objectui's `pickLocalized`. Every backend producer that had to put a label on + * the renderer's `pickLocalized` (then in objectui). Every backend producer that had to put a label on * the wire tested `typeof label === 'string'` and dropped anything else — so a * dataset that declared its dimension label the way the schema authorizes shipped * `fields[]` entries with **no label at all**, or with the machine name published @@ -23,10 +23,10 @@ * two enrichment sites and `dataset-compiler.ts:374/406` — is #6761 and is * deliberately NOT implemented here. * - * ## Rule parity with objectui's `pickLocalized` is the contract + * ## Rule parity with the renderer's `pickLocalized` is the contract * * The ruling's acceptance hinge is not this function's shape but that it picks - * **the same entry** objectui would, for the same map and the same locale. Two + * **the same entry** the renderer would, for the same map and the same locale. Two * resolvers that disagree render the same metadata differently on the two ends * with neither side erroring — the server-rendered column header and the * client-rendered one simply differ. @@ -40,9 +40,9 @@ * | 0 | plain string ⇒ itself | pass-through, `''` included | * | — | `locale` normalization | `(locale \|\| 'en').trim()`; **no** case folding | * | 1 | exact tag — `map[locale]` | `zh-CN` matches the key `zh-CN` | + * | 2–3 | before limbs 2 and 3, where the platform splits the base by script: the base or first sibling of the locale's own script | `zh-Hant` over `{ zh-CN, zh-TW }` → `zh-TW`; only `zh` splits today; see {@link scriptEntryKey} | * | 2 | base language — `map[base]` | `zh-CN` → `zh` | * | 3 | first region-qualified sibling sharing the base | `zh` → `zh-CN`; **key insertion order** decides | - * | 2–3 | …an entry of the locale's own script first | `zh-Hant` over `{ zh-CN, zh-TW }` → `zh-TW`; see {@link scriptEntryKey} | * | 4 | `map.default` | the untagged entry `InlineLocaleMapSchema` admits | * | 5 | `map.en` | the platform's source language | * | 6 | first string value | insertion order again | @@ -74,8 +74,8 @@ * case — but parity with the reference is the ruled acceptance, and a resolver * that folded case would answer differently from the renderer for exactly the * inputs where it "improved". The asymmetry is pinned as-is, with vectors, in - * `i18n-label-resolver.test.ts`; changing it is a two-repo decision, not a - * detail to fix in passing. + * `i18n-label-resolver.test.ts`; changing it means changing both twins in this + * repository together (and their parity pins), not a detail to fix in passing. * * ## Rule departures — converged as of objectui#3907, zero remain * @@ -137,7 +137,7 @@ * `system/i18n-resolver.ts` resolves form **1** — a plain-string label plus a * translation *bundle*, addressed by convention * (`objects.._views..label`). This module resolves form **2**, the - * map the author inlined. They compose in that order at objectui's own call + * map the author inlined. They compose in that order at the renderer's own call * sites (`translateLabel(pickLocalized(label, language), …)`; read points * recorded in `liveness/page.json`, row `regions`): resolve the inline map * first, then look the resulting string up in the bundle. @@ -200,7 +200,7 @@ function readEntry(map: Record, key: string): string | undefine * Accepts either authorized form of {@link I18nLabel}: a plain string (returned * unchanged, `''` included — an author who wrote an empty label wrote a label) * or an inline locale map, resolved by the six-limb rule documented on this - * module and shared verbatim with objectui's `pickLocalized`. + * module and shared verbatim with `pickLocalized` in `ui/packages/i18n`. * * ## The return shape, and why it is not `pickLocalized`'s * From 6e8b63894022174bb53a869f49d38eb05e85173d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 02:13:09 +0000 Subject: [PATCH 5/5] chore(changeset): patch note for the cli i18n coverage script preference Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxSGAjHBvjATmenUGp --- .changeset/30910-cli-i18n-coverage-script.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/30910-cli-i18n-coverage-script.md diff --git a/.changeset/30910-cli-i18n-coverage-script.md b/.changeset/30910-cli-i18n-coverage-script.md new file mode 100644 index 00000000000..5ec1293371e --- /dev/null +++ b/.changeset/30910-cli-i18n-coverage-script.md @@ -0,0 +1,10 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): i18n coverage reads a two-script inline label map the way the renderer does + +Clause-②: no +Metadata-protocol: none + +For an inline locale map that carries both Chinese scripts, the text the coverage report reads for a reader locale now equals what the renderer shows: a `zh-Hant` reader of `{ en, 'zh-CN', 'zh-TW' }` is read from `zh-TW`, not from the first `zh-*` key. Every other map and language reads as before.