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. 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/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('成员'); + }); }); 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..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,23 +23,24 @@ * 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. * - * 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 | * |---|---|---| * | 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 | * | 4 | `map.default` | the untagged entry `InlineLocaleMapSchema` admits | @@ -64,12 +65,17 @@ * 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 * 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 * @@ -131,13 +137,40 @@ * `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. */ 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 @@ -167,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 * @@ -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/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; } 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'],