Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/30910-cli-i18n-coverage-script.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .changeset/30910-i18n-script-preference.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions .changeset/30910-spec-label-resolver-script-preference.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 18 additions & 19 deletions packages/cli/src/utils/i18n-coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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<string, string> = {};
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);
}

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/cli/test/i18n-inline-locale-map-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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('成员');
});
});
102 changes: 88 additions & 14 deletions packages/spec/src/ui/i18n-label-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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' },
Expand Down
Loading
Loading