From 7858ce18a4e871cd7c8bd58ff7c9a1dade428c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:15:11 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20resolveI18nLabel=20=E2=80=94?= =?UTF-8?q?=20the=20shared=20`I18nLabel`=20=E2=86=92=20`string`=20resolver?= =?UTF-8?q?=20(#6765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `I18nLabelSchema` has authorized two forms of a display label since #5728: a plain string, and an inline locale map. 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 wire tested `typeof label === 'string'` and dropped anything else, so a dataset declaring its dimension label the way the schema authorizes shipped `fields[]` entries with no label at all (#6761's measurements). This adds the missing half in `packages/spec` rather than inside the service that needed it first (maintainer ruling 2026-08-08, #6761 option B): the backend had zero inline-map resolvers, and a first one born as a private fork is what the next producer copies (PD#12). Rule parity with `pickLocalized` is the contract and it is EXECUTED, not asserted: a 26-row vector table is checked against a pinned verbatim copy of the reference implementation first, then against this resolver. The only visible difference is the spelling of a miss — `undefined` here, `''` there — bridged by one `??` and pinned as an identity. Consumption (`AnalyticsService.queryDataset`, `dataset-compiler.ts`) is #6761 and is deliberately not touched here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --- .changeset/spec-shared-i18n-label-resolver.md | 50 +++ packages/spec/src/system/i18n-resolver.ts | 16 + .../spec/src/ui/i18n-label-resolver.test.ts | 301 ++++++++++++++++++ packages/spec/src/ui/i18n-label-resolver.ts | 242 ++++++++++++++ packages/spec/src/ui/i18n.zod.ts | 9 + packages/spec/src/ui/index.ts | 4 + 6 files changed, 622 insertions(+) create mode 100644 .changeset/spec-shared-i18n-label-resolver.md create mode 100644 packages/spec/src/ui/i18n-label-resolver.test.ts create mode 100644 packages/spec/src/ui/i18n-label-resolver.ts diff --git a/.changeset/spec-shared-i18n-label-resolver.md b/.changeset/spec-shared-i18n-label-resolver.md new file mode 100644 index 0000000000..7358ba3a02 --- /dev/null +++ b/.changeset/spec-shared-i18n-label-resolver.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +--- + +`resolveI18nLabel` — the shared `I18nLabel` → `string` resolver, and the first one the backend has + +`I18nLabelSchema` has authorized two forms of a display label since #5728: a +plain string, and an inline locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) — +which three published platform pages author 31 times. 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 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 as a display title. The +shape was declared and unreadable on the side that produces it. + +`packages/spec/src/ui/i18n-label-resolver.ts` is that missing half: + +```ts +import { resolveI18nLabel } from '@objectstack/spec/ui'; + +resolveI18nLabel({ en: 'Owner', 'zh-CN': '负责人' }, 'zh-CN'); // '负责人' +resolveI18nLabel(dimension.label, locale) ?? dimension.name; // producer shape +``` + +It lives in `packages/spec` rather than inside the service that needed it first +(maintainer ruling 2026-08-08, #6761 option B): the backend had **zero** inline-map +resolvers, and a first one born as a private fork inside one service is what the +next producer copies (Prime Directive #12). + +**Rule parity with `pickLocalized` is the contract, and it is executed, not +asserted.** The resolution rule — exact tag → base language (`zh-CN` → `zh`) → +first region-qualified sibling sharing the base (`zh` → `zh-CN`) → `default` → +`en` → any string in the map, with `(locale || 'en').trim()` and no case folding — +is mirrored limb for limb from objectui `packages/i18n/src/pickLocalized.ts`, and +a 26-row vector table asserts each vector against a pinned verbatim copy of the +reference before asserting it against this resolver. Two resolvers that drift +would render the same metadata differently on the two ends with neither side +erroring; that is the fork this exists to prevent. + +The one visible difference is the spelling of a miss: `pickLocalized` returns `''` +because its caller writes into a text node, while this returns `undefined` because +its callers fill a `label?: string` field whose downstream enrichment is guarded by +`if (field.label == null)` — a producer writing `''` would not be saying "no label", +it would be permanently displacing a real label a later stage still had. The bridge +is one `??`, pinned as an identity: `resolveI18nLabel(l, loc) ?? '' === pickLocalized(l, loc)`. + +Additive only — one new exported function on `@objectstack/spec/ui`, no existing +declaration changed. The consumption half (`AnalyticsService.queryDataset`'s two +enrichment sites and `dataset-compiler.ts`'s `d.name` substitution) is #6761. diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 773b66854e..99ec88a97f 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -29,6 +29,22 @@ * `['en']`) → literal `label` from the metadata. Helpers never throw — they * always return at minimum the metadata literal so unconfigured languages * gracefully degrade. + * + * ## The OTHER half of `I18nLabel`, and where it lives + * + * This file resolves form **1** of {@link I18nLabelSchema} — a plain-string + * label whose translations live in a bundle, addressed by the conventions + * above. Form **2**, the inline locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) + * the author writes into the metadata document itself, is resolved by + * `ui/i18n-label-resolver.ts`'s `resolveI18nLabel` (#6765, #6761 ruling B) — + * the shared seat for that rule, kept in lockstep with objectui's + * `pickLocalized` by an executed parity table. + * + * They compose, inline map first: objectui's own call sites read + * `translateLabel(pickLocalized(label, language), language)`, i.e. collapse the + * map to a string, then look that string up in the bundle. A caller holding an + * `I18nLabel` that may be either form wants `resolveI18nLabel` before anything + * here. */ import type { TranslationBundle, TranslationData } from './translation.zod'; diff --git a/packages/spec/src/ui/i18n-label-resolver.test.ts b/packages/spec/src/ui/i18n-label-resolver.test.ts new file mode 100644 index 0000000000..53035c4e2a --- /dev/null +++ b/packages/spec/src/ui/i18n-label-resolver.test.ts @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Rule parity between `resolveI18nLabel` and objectui's `pickLocalized`. + * + * The #6761 ruling's acceptance hinge is not the resolver's API shape but that + * the two ends of the platform pick the SAME ENTRY out of the same inline + * locale map. A comment claiming that is worth nothing: two resolvers that + * drift apart render the same metadata differently on the two ends and NEITHER + * SIDE ERRORS — the server-rendered column header and the client-rendered one + * simply disagree, forever. So the parity is executed here, not asserted in + * prose. + * + * ## How the expectations were derived + * + * `pickLocalizedReference` below is a VERBATIM copy of the reference + * implementation, taken from + * + * repo objectstack-ai/objectui + * path packages/i18n/src/pickLocalized.ts + * rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f + * blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec + * last touched by objectui#3278 (2026-08-03) + * + * Copied rather than imported because `@objectstack/spec` must not take a + * workspace dependency on objectui — spec sits UNDER objectui in the dependency + * order, and inverting that to buy a test fixture would be a far worse trade + * than copying 20 lines. The copy is what makes each `pickLocalized` column + * below a MEASUREMENT instead of the author's recollection: every vector is + * asserted against the reference first (`the reference really answers this`), + * and only then against `resolveI18nLabel`. + * + * ⛔ `pickLocalizedReference` is a test fixture. It is not exported from this + * file, and nothing under `src/` may import it — the whole point of #6765 is + * that the repo has ONE resolver, not a private copy per consumer (PD#12). If + * you find yourself wanting to call it from production code, you want + * `resolveI18nLabel`. + * + * ## Keeping it honest when objectui moves + * + * The copy is pinned to a revision, so it cannot silently follow objectui. + * If `pickLocalized` changes there, this file goes stale rather than wrong: + * re-read the source at the new revision, update the copy AND the pin above, + * and let the vectors say whether the rule moved. A vector that flips is a + * two-repo decision, not a number to re-record. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveI18nLabel } from './i18n-label-resolver'; +import type { I18nLabel } from './i18n.zod'; + +// --------------------------------------------------------------------------- +// 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. +function pickLocalizedReference(value: unknown, language: string | undefined | null): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'object') { + const o = value as Record; + 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 && typeof o[k] === 'string'); + const pick = + o[lang] ?? + o[base] ?? + (regional !== undefined ? o[regional] : undefined) ?? + o.default ?? + o.en ?? + Object.values(o).find((v) => typeof v === 'string'); + return pick == null ? '' : String(pick); + } + return String(value); +} +// --------------------------------------------------------------------------- + +interface ParityVector { + /** What this vector demonstrates — the limb of the rule it exercises. */ + readonly limb: string; + readonly label: I18nLabel | undefined; + readonly locale: string | undefined; + /** The reference's answer. Asserted against the reference itself below. */ + readonly pick: string; +} + +/** + * One table, both ends. Every row is an input `I18nLabelSchema` accepts (or the + * absence of one), so every row is inside the declared domain where parity is + * total. + */ +const PARITY_VECTORS: readonly ParityVector[] = [ + // Form 1 — the plain string. + { limb: '0 plain string passes through', label: 'Owner', locale: 'zh-CN', pick: 'Owner' }, + { limb: '0 an empty string is a label the author wrote', label: '', locale: 'zh-CN', pick: '' }, + + // Limb 1 — exact tag. + { limb: '1 exact tag', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh-CN', pick: '负责人' }, + { limb: '1 exact tag (source language)', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'en', pick: 'Owner' }, + { + limb: '1 exact tag beats an earlier sibling sharing the base', + label: { 'zh-TW': '擁有者', 'zh-CN': '负责人' }, + locale: 'zh-CN', + pick: '负责人', + }, + + // Limb 2 — region request, base key (`zh-CN` → `zh`). + { limb: '2 region → base', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-CN', pick: '负责人' }, + { limb: '2 region → base, multi-subtag tag', label: { en: 'Owner', zh: '负责人' }, locale: 'zh-Hans-CN', pick: '负责人' }, + + // Limb 3 — base request, region key (`zh` → `zh-CN`). + { limb: '3 base → region', label: { en: 'Owner', 'zh-CN': '负责人' }, locale: 'zh', pick: '负责人' }, + { limb: '3 base → region (ja)', label: { en: 'Owner', 'ja-JP': '所有者' }, locale: 'ja', pick: '所有者' }, + { 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: { 'zh-TW': '擁有者', 'zh-CN': '负责人' }, + locale: 'zh', + pick: '擁有者', + }, + { + limb: '3 runs BEFORE default — a wrong-region hit beats the untagged entry', + label: { default: 'Owner', 'fr-FR': 'Propriétaire' }, + locale: 'fr', + pick: 'Propriétaire', + }, + + // Case sensitivity, both halves of the tag. See the module doc on the + // resolver: this asymmetry is the reference's rule, pinned as-is. + { + limb: '3 the REGION subtag\'s case does not matter (only the base is compared)', + label: { 'zh-CN': '负责人' }, + locale: 'zh-cn', + pick: '负责人', + }, + { + limb: '5 the LANGUAGE subtag\'s case DOES — `ZH-CN` matches nothing and lands on `en`', + label: { 'zh-CN': '负责人', en: 'Owner' }, + locale: 'ZH-CN', + pick: 'Owner', + }, + + // Limb 4 / 5 / 6 — the named fallbacks, then any string at all. + { limb: '4 default', label: { default: 'D', en: 'E' }, locale: 'fr', pick: 'D' }, + { limb: '5 en', label: { en: 'E', ja: 'J' }, locale: 'fr', pick: 'E' }, + { limb: '6 first string value', label: { ja: 'J' }, locale: 'fr', pick: 'J' }, + { limb: '6 first string value, in key order', label: { ja: 'J', ko: 'K' }, locale: 'fr', pick: 'J' }, + + // Locale normalization — `(locale || 'en').trim()`, no case folding. + { limb: 'norm undefined locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: undefined, pick: 'E' }, + { limb: 'norm empty locale ⇒ en', label: { en: 'E', 'zh-CN': 'Z' }, locale: '', pick: 'E' }, + { limb: 'norm surrounding whitespace is trimmed', label: { en: 'E', 'zh-CN': 'Z' }, locale: ' zh-CN ', pick: 'Z' }, + { limb: 'norm undefined locale still reaches limb 6', label: { 'zh-CN': 'Z' }, locale: undefined, pick: 'Z' }, + + // An entry whose VALUE is empty is still a hit — the reference's `??` chain + // does not skip `''`, and neither may this one. + { limb: '1 an empty value is a hit, not a miss', label: { en: '', 'zh-CN': '负责人' }, locale: 'en', pick: '' }, + { limb: '5 an empty `en` is a hit, not a miss', label: { en: '' }, locale: 'fr', pick: '' }, + + // The miss cases. The reference spells "nothing was picked" as `''`. + { limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' }, + { limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' }, +]; + +describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => { + describe('the vector table really is the reference\'s behaviour', () => { + it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => { + // Asserted against the copied reference FIRST. If this row is wrong, the + // parity assertion below would be comparing `resolveI18nLabel` to the + // author's recollection instead of to objectui. + expect(pickLocalizedReference(label, locale)).toBe(pick); + }); + }); + + describe('resolveI18nLabel picks the same entry', () => { + it.each(PARITY_VECTORS)('$limb', ({ label, locale, pick }) => { + // The identity the two spellings of "nothing was picked" are bridged by. + // `?? ''` is the ONLY difference between the two functions inside the + // declared domain — everything else is the same limb, in the same order. + expect(resolveI18nLabel(label, locale) ?? '').toBe(pick); + }); + }); + + it('every vector agrees limb for limb, in one pass', () => { + const disagreements = PARITY_VECTORS.filter( + (v) => (resolveI18nLabel(v.label, v.locale) ?? '') !== pickLocalizedReference(v.label, v.locale), + ).map((v) => v.limb); + expect(disagreements).toEqual([]); + }); +}); + +describe('resolveI18nLabel — the producer-facing return shape', () => { + // Why this is not `''`: downstream enrichment in the producing direction is + // guarded by `if (field.label == null)`, so a producer that wrote `''` would + // not be writing "no label" — it would permanently displace the real label a + // later stage still had (#5199 route A, judged harmful rather than + // redundant; restated in #6761). + it('answers `undefined` — not `\'\'` — when the label is absent', () => { + expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined(); + }); + + it('answers `undefined` when no limb matched', () => { + expect(resolveI18nLabel({}, 'zh-CN')).toBeUndefined(); + }); + + it('answers `\'\'` when the author really wrote an empty label', () => { + // A hit is a hit. This is the case a `''` miss value would be + // indistinguishable from, which is why the miss is `undefined`. + expect(resolveI18nLabel('', 'zh-CN')).toBe(''); + expect(resolveI18nLabel({ en: '' }, 'en')).toBe(''); + }); + + it('composes with `??` into the producer call shape #6761 needs', () => { + // `dataset-compiler.ts:374/406` today: `typeof d.label === 'string' ? d.label : d.name`, + // which publishes the MACHINE NAME as a display title for a map label. + const dimension = { name: 'owner', label: { en: 'Owner', 'zh-CN': '负责人' } as I18nLabel }; + expect(resolveI18nLabel(dimension.label, 'zh-CN') ?? dimension.name).toBe('负责人'); + + const unlabelled = { name: 'owner', label: undefined }; + expect(resolveI18nLabel(unlabelled.label, 'zh-CN') ?? unlabelled.name).toBe('owner'); + }); +}); + +describe('resolveI18nLabel — the two deliberate departures from the reference', () => { + // Both are documented on the resolver's module doc. They are pinned here with + // BOTH answers so the divergence stays MEASURED: if a later change makes the + // two agree again, these tests go red and say so, rather than quietly + // becoming decoration. + + it('reads own properties only — a locale naming an Object.prototype member is a miss', () => { + const label: I18nLabel = { en: 'Owner' }; + + // The reference resolves `map['constructor']` up the prototype chain and + // renders the function's source text as the label. Filed as objectui#3907. + expect(pickLocalizedReference(label, 'constructor')).toContain('function Object'); + + // Here it is simply not a key, so the chain continues to `en`. No BCP-47 + // tag is an `Object.prototype` member, so no in-contract input can tell the + // two implementations apart — but on a server the locale can arrive in an + // `Accept-Language` header, which is why this one is hardened. + expect(resolveI18nLabel(label, 'constructor')).toBe('Owner'); + expect(resolveI18nLabel(label, 'toString')).toBe('Owner'); + }); + + it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => { + // Off-spec: `InlineLocaleMapSchema` is `z.record(, z.string())`, so no + // in-contract map can hold this. The cast is what makes that explicit. + const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel; + + // The reference filters by `typeof === 'string'` on limbs 3 and 6 but not + // on 1/2/4/5, so an exact-tag hit short-circuits and gets stringified. + expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('[object Object]'); + + // PD#12: the producer is wrong; the consumer must not coerce `[object + // Object]` onto a screen. The filter is uniform, so the limb is a miss and + // the chain continues. + expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner'); + }); + + it('refuses an off-contract scalar rather than stringifying it', () => { + // `pickLocalized` accepts `unknown` and stringifies numbers/booleans. This + // resolver's parameter is the declared `I18nLabel`, so the shapes below are + // type errors — the `@ts-expect-error` directives immediately after are the + // real guard. This asserts the runtime half: no coerced `'42'` label. + // @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced + expect(resolveI18nLabel(42, 'en')).toBeUndefined(); + // @ts-expect-error a boolean is not an `I18nLabel` + expect(resolveI18nLabel(true, 'en')).toBeUndefined(); + }); +}); + +describe('resolveI18nLabel — the type signature refuses the calls that caused #6761', () => { + // Reverse verification at the type level. `check:test-typecheck` compiles this + // file (packages/spec/tsconfig.test.json), so each directive below is a REAL + // check: delete the argument it guards and tsc goes red on the unused + // `@ts-expect-error` instead of letting the call through. + + it('rejects a map whose values are not strings', () => { + // @ts-expect-error `InlineLocaleMap` values are strings; a number is not a label + const bad: I18nLabel = { en: 42 }; + expect(resolveI18nLabel(bad, 'en')).toBeUndefined(); + }); + + it('rejects the call that forgets the locale', () => { + // The defect #6761 records is a producer shipping ONE audience's language to + // every audience. `locale` is positional rather than optional precisely so + // that omitting it cannot compile. + // @ts-expect-error `locale` is required positionally — a producer must decide it + expect(resolveI18nLabel({ en: 'Owner' })).toBe('Owner'); + }); + + it('accepts both authorized forms, and an absent label', () => { + const plain: I18nLabel = 'All Active'; + const inline: I18nLabel = { en: 'All Active', 'zh-CN': '全部活跃' }; + expect(resolveI18nLabel(plain, 'zh-CN')).toBe('All Active'); + expect(resolveI18nLabel(inline, 'zh-CN')).toBe('全部活跃'); + expect(resolveI18nLabel(undefined, 'zh-CN')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/ui/i18n-label-resolver.ts b/packages/spec/src/ui/i18n-label-resolver.ts new file mode 100644 index 0000000000..53bd3aad71 --- /dev/null +++ b/packages/spec/src/ui/i18n-label-resolver.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `I18nLabel` → `string` — the one shared resolver for the **inline locale + * map**, and the backend's first (#6761, maintainer ruling 2026-08-08, option + * **B**). + * + * ## What this is for + * + * `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 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 + * as a display title (`dataset-compiler.ts:374/406`). Measurements in #6761. + * + * The rule that resolves a map therefore had to live somewhere **shared**, not + * inside whichever service needed it first: a private twin in `service-analytics` + * is what the next producer would have copied (Prime Directive #12). This module + * is that shared seat. The consumption half — `AnalyticsService.queryDataset`'s + * 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 + * + * 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 + * 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` `50fa376`, blob `9e5d92a`, last touched by objectui#3278). 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 | 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 | + * | 5 | `map.en` | the platform's source language | + * | 6 | first string value | insertion order again | + * + * Miss (no limb hits) ⇒ **nothing was picked**. See the return-shape note below — + * this is the one place where this function deliberately does not spell the + * outcome the way `pickLocalized` does, and the parity test pins the exact + * relationship between the two spellings. + * + * ### Case sensitivity is asymmetric, and that is the reference's rule + * + * Every comparison above is case-**sensitive**: `pickLocalized` folds neither + * the locale nor the keys. The observable effect is asymmetric between the two + * halves of a tag, and both halves are worth knowing before you rely on either: + * + * * the **region** subtag's case does not matter, because limb 3 compares only + * the language subtag — `{ 'zh-CN': '负责人' }` answers `zh-cn` (base `zh` + * on both sides) via limb 3; + * * the **language** subtag's case does — `ZH-CN` has base `ZH`, which equals + * 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. + * + * 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. + * + * ## Two deliberate departures, both narrower than the rule above + * + * 1. **Own properties only.** `pickLocalized` reads `map[locale]` with a bare + * bracket access, so a locale that happens to name an `Object.prototype` + * member (`constructor`, `toString`) resolves to that member and renders as + * its source text. In a browser the locale comes from the app's own language + * state; on a server it can come from an `Accept-Language` header, so this + * module reads own properties only. No language tag is an `Object.prototype` + * key, so no in-contract input can tell the two apart. Filed against the + * reference as objectui#3907. + * 2. **Only `string` values are eligible, on every limb.** `pickLocalized` + * applies a `typeof === 'string'` filter on limbs 3 and 6 but not on 1, 2, 4, + * 5, where a non-string value short-circuits the chain and is stringified + * (`[object Object]`). `InlineLocaleMapSchema` declares `z.record(, + * z.string())`, so no value that reaches either resolver in-contract is + * anything but a string, and the inconsistency is unobservable inside the + * declared domain. Out of contract, Prime Directive #12 says the producer is + * wrong and the consumer must not coerce a rendered `[object Object]` onto a + * screen — so the filter is applied uniformly and an off-spec value is + * treated as absent. + * + * Both are stated rather than silent because parity, not taste, is what this + * module is for: everything a caller can observe with an `I18nLabel` that + * `I18nLabelSchema` accepts is identical between the two ends. + * + * ## Relation to the other resolver in this package + * + * `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 + * sites (`translateLabel(pickLocalized(label, language), language)` — + * `packages/components/src/renderers/layout/containers.tsx:530-532`): resolve + * the inline map first, then look the resulting string up in the bundle. + */ + +import type { I18nLabel } from './i18n.zod'; + +/** + * The locale `pickLocalized` assumes when the caller has none — and the last + * named key it tries before falling back to "any string in the map". + * + * Not exported: it is an implementation detail of the parity, not a knob. A + * caller that wants a different default passes it as `locale`. + */ +const DEFAULT_LOCALE = 'en'; + +/** + * Read one entry of an inline locale map, honouring both departures documented + * on the module: own properties only, and `string` values only. + * + * Returns `undefined` for "this limb did not hit", which is what makes the + * limbs chain in the same order `pickLocalized`'s `??` chain does. + */ +function readEntry(map: Record, key: string): string | undefined { + if (!Object.prototype.hasOwnProperty.call(map, key)) return undefined; + const value = map[key]; + return typeof value === 'string' ? value : undefined; +} + +/** + * Resolve a display label to the string to show for `locale`. + * + * 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`. + * + * ## The return shape, and why it is not `pickLocalized`'s + * + * `pickLocalized` returns `''` when nothing matches, because its caller is a + * renderer writing into a text node. This function returns **`undefined`**, + * because its callers are *producers* filling a `label?: string` field on the + * wire, and downstream enrichment in that direction is guarded by + * `if (field.label == null)`. A producer that wrote `''` would therefore not be + * writing "no label" — it would be permanently displacing the real label some + * later stage still had (#5199 route A, judged harmful rather than redundant; + * restated in #6761). + * + * So `undefined` here means exactly what `''` means there — *nothing was + * picked* — in the spelling each side's callers need. The bridge is one `??`, + * and the parity test pins it as an identity: + * + * ```ts + * resolveI18nLabel(label, locale) ?? '' // ≡ pickLocalized(label, locale) + * ``` + * + * There is deliberately no `fallback` parameter: `?? fallback` at the call site + * is the same expression, and it keeps the decision about the miss case visible + * in the file where writing the wrong thing does the damage. + * + * @param label - The label to resolve. `undefined` (an absent label) resolves + * to `undefined` — absence is not a miss to be papered over. + * @param locale - BCP-47 language tag of the audience, e.g. `zh-CN`. Required + * positionally but nullish-tolerant: `undefined` means "no locale known" and + * resolves as `en`, matching the reference. It is positional rather than + * optional so that a producer cannot silently ship one audience's language to + * every audience by forgetting an argument — the defect #6761 records. + * @returns The string to display, or `undefined` when the label is absent or no + * limb matched. Pair with `?? someDefault` when the caller needs a string. + * + * @example + * ```typescript + * resolveI18nLabel('Owner', 'zh-CN'); // 'Owner' + * resolveI18nLabel({ en: 'Owner', 'zh-CN': '负责人' }, 'zh-CN'); // '负责人' + * resolveI18nLabel({ en: 'Owner', 'zh-CN': '负责人' }, 'zh'); // '负责人' (base → region) + * resolveI18nLabel({ 'ja-JP': '所有者' }, 'fr'); // '所有者' (last resort) + * resolveI18nLabel(undefined, 'zh-CN') ?? dimension.name; // producer shape + * ``` + * + * @see `system/i18n-resolver.ts` — the bundle-addressed resolver for form 1. + */ +export function resolveI18nLabel( + label: I18nLabel | undefined, + locale: string | undefined, +): string | undefined { + // Absent label. Distinct from a miss only in provenance; both answer `undefined`. + if (label == null) return undefined; + + // Form 1 — a plain string is already the answer, `''` included (parity: the + // reference returns it unchanged too). + if (typeof label === 'string') return label; + + // Anything that is not a map is off-contract for `I18nLabelSchema`. Prime + // Directive #12: refuse it rather than stringify it into a visible label. + if (typeof label !== 'object') return undefined; + + const map = label as Record; + + // Locale normalization, verbatim from the reference: a nullish/empty locale + // becomes `en`, surrounding whitespace is trimmed, and case is NOT folded. + const tag = (locale || DEFAULT_LOCALE).trim(); + const base = tag.split('-')[0]; + + // 1 — exact tag. + const exact = readEntry(map, tag); + if (exact !== undefined) return exact; + + // 2 — base language (`zh-CN` → `zh`). + const baseHit = readEntry(map, base); + if (baseHit !== undefined) return baseHit; + + // 3 — first region-qualified sibling sharing the base (`zh` → `zh-CN`), in + // key insertion order. Limbs 1 and 2 already ran, so this only fires when + // neither the exact tag nor the bare base is a key. + for (const key of Object.keys(map)) { + if (key.split('-')[0] !== base) continue; + const regional = readEntry(map, key); + if (regional !== undefined) return regional; + } + + // 4 — the untagged `default` entry. + const untagged = readEntry(map, 'default'); + if (untagged !== undefined) return untagged; + + // 5 — the platform's source language. + const english = readEntry(map, DEFAULT_LOCALE); + if (english !== undefined) return english; + + // 6 — last resort: any string in the map, in key insertion order. A label in + // the wrong language still beats a column with no header. + for (const key of Object.keys(map)) { + const any = readEntry(map, key); + if (any !== undefined) return any; + } + + // Miss. `undefined`, never `''` — see the return-shape note above. + return undefined; +} diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts index 1733fe3400..86fa43cb99 100644 --- a/packages/spec/src/ui/i18n.zod.ts +++ b/packages/spec/src/ui/i18n.zod.ts @@ -143,6 +143,15 @@ export type InlineLocaleMap = z.input; * objectui resolves them (`pickLocalized`), so the map is a delivered * capability, not a convention the runtime ignores. * + * Form 2 is resolved **on this side too**, since #6765: `resolveI18nLabel` in + * `./i18n-label-resolver` is the shared `I18nLabel` → `string` resolver, pinned + * limb for limb to `pickLocalized` by an executed parity table. Before it, the + * meaning of the shape this file declares existed only in the other repo, and a + * backend producer holding a map could do nothing with it but drop it — which + * is exactly what every one of them did (#6761, maintainer ruling 2026-08-08, + * option B). Form 1's bundle lookup is `system/i18n-resolver.ts`; a caller + * holding either form runs `resolveI18nLabel` first. + * * Both are real; neither is deprecated by this schema. The bundle route is the * one that scales (translators never touch `*.page.ts`) and remains the * long-term direction — but a contract that declared only form 1 while the diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 2ce3d3e26e..633e84b375 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -13,6 +13,10 @@ export * from './chart.zod'; export * from './chart-aggregate'; export * from './i18n.zod'; +// `resolveI18nLabel` — the shared `I18nLabel` → `string` resolver (#6765, +// #6761 ruling B). The rule that reads an inline locale map lives here, ONCE, +// so a backend producer never has to grow a private copy of it. +export * from './i18n-label-resolver'; export * from './responsive.zod'; export * from './app.zod'; export * from './bulk-action.zod'; From 6789ac6c0d637e3765fcb1aaefbf0c488b5099d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 03:39:42 +0000 Subject: [PATCH 2/2] chore(spec): regenerate the api-surface snapshot for the new `./ui` export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveI18nLabel (function)` on `./ui`. 0 breaking (nothing removed or narrowed), 1 added — the delta `check:api-surface` asked for. Regenerated only after a post-rebase `pnpm --filter @objectstack/spec build`: run against the pre-rebase dist it also DROPPED `AuthoredRowWriteOperation` / `AuthoredRowWriteVerdict` from `contracts.json`, which #6841 had added to the source in the meantime — the AGENTS.md §9 stale-artefact trap, in mirror image. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M --- packages/spec/api-surface/ui.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 3974530a23..3e8204d5e8 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -392,6 +392,7 @@ "reactBlockTagFor (function)", "reportForm (const)", "reportSelectionOrder (function)", + "resolveI18nLabel (function)", "stripViewConsoleDecorations (function)", "validateActionParams (function)", "viewForm (const)"