diff --git a/.changeset/30193-override-label-pair-precedence.md b/.changeset/30193-override-label-pair-precedence.md new file mode 100644 index 00000000000..87f30db905b --- /dev/null +++ b/.changeset/30193-override-label-pair-precedence.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +--- + +feat(i18n): one label order on an overridden item, for all eight document translators — the pair's translation, then the override's own label, then the base's translation + +Clause-②: yes (widening) +Metadata-protocol: none + +- **New option `TranslateDocumentOptions.pairBundle`.** The translations the served item's paired `.custom` package ships itself (ADR-0146 D2/D3), locale-keyed as `bundle` is. The serving layer's `bundle` is one merged catalog per locale with the package ids dropped, so only this option can tell a translation the pair ships from the base's. +- **The order, per translatable string.** A string the override changed (`valueOverridesPackagedBase` against `packagedBase`, the comparison every translator already makes) reads the pair's translations only, along the locale chain, and otherwise keeps its own text: the base's translation is never read for it. A string the override left alone reads, locale by locale, the pair's translation and then the deployment catalog, so a pair that ships only `en` never displaces the base's `zh-CN` on an untouched label. Nested labels follow the same rule: widget titles, filter and option labels, view bulk-action defs and form sections, report blocks, field labels, help and options, page component copy, tab items and the header, dataset members, app navigation nodes and book groups. +- **`translatePage`, `translateDataset`, `translateApp` and `translateBook` now read `packagedBase`** (their third parameter widens from `ResolveOptions` to `TranslateDocumentOptions`), each string judged against the base's counterpart: the page's components by id and its header, the dataset's members by `name`, the app's navigation nodes by `id`, the book's groups by `key`. A page header title the override left as the base wrote it translates as the base's label. +- **Nothing moves for an existing caller.** With no `pairBundle` (or one that answers none of the chain's locales) every translator gives exactly the answer it gave before, and with no `packagedBase` nothing counts as changed. Passing both for a `.custom` override is the serving layer's half (#30062). +- An object's `validations[].message` and its inline `actions` keep reading the merged catalog: the rule evaluator answers the same message from `i18n.t()` with no pair input, and the refusal text must equal the served text. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 491c550265d..6d53a4b086c 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -6081,3 +6081,329 @@ describe('resolveMetadataFormSchemaTitles — bundle labels overlaid as JSON Sch expect(out.properties.fields.items.properties.label.title).toBe('行标签'); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// #30193 — label precedence on an overridden item (ADR-0146 D2) +// ──────────────────────────────────────────────────────────────────────────── + +import { type TranslateDocumentOptions } from './i18n-resolver'; +import { ReportSchema } from '../ui/report.zod'; +import { BookSchema } from './book.zod'; + +describe("label precedence on an overridden item — the pair's translation, then the override's label, then the base's translation (ADR-0146 D2)", () => { + /** One translatable string of a served document, and the bundle address that translates it. */ + interface Slot { + readonly what: string; + readonly read: (doc: any) => unknown; + readonly write: (doc: any, text: string) => void; + /** The translation data addressing this string, holding `text`. */ + readonly entry: (text: string) => Record; + /** The base's `zh-CN` translation, as the deployment catalog carries it. */ + readonly baseZh: string; + } + + /** A type's packaged base and its strings: the top-level label first, then nested ones. */ + interface PrecedenceCase { + readonly base: () => any; + readonly slots: readonly Slot[]; + } + + const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + const merge = (...parts: Record[]): Record => { + const out: Record = {}; + for (const part of parts) { + for (const [key, value] of Object.entries(part)) { + const prior = out[key]; + out[key] = isRecord(prior) && isRecord(value) ? merge(prior, value) : value; + } + } + return out; + }; + const clone = (value: T): T => JSON.parse(JSON.stringify(value)); + /** A slot builder: `path` addresses the string inside the translation data. */ + const slot = ( + what: string, + read: (doc: any) => unknown, + write: (doc: any, text: string) => void, + path: readonly string[], + baseZh: string, + ): Slot => ({ + what, + read, + write, + baseZh, + entry: (text) => path.reduceRight((inner, key) => ({ [key]: inner }), text) as Record, + }); + + const viewContainer = { + listViews: { + in_progress: { + label: 'In Progress', + type: 'grid' as const, + data: { provider: 'object' as const, object: 'showcase_task' }, + columns: [{ field: 'title' }], + bulkActionDefs: [{ name: 'mark_done', label: 'Mark done', operation: 'update' as const, patch: { status: 'done' } }], + }, + }, + }; + const VIEW = ['objects', 'showcase_task', '_views', 'in_progress']; + + const CASES: Record = { + view: { + base: () => expandViewContainer('showcase_task', viewContainer).find((v) => v.name === 'showcase_task.in_progress'), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, [...VIEW, 'label'], '进行中'), + slot('bulk action label', (d) => d.config.bulkActionDefs[0].label, (d, t) => { d.config.bulkActionDefs[0].label = t; }, + [...VIEW, 'bulkActions', 'mark_done', 'label'], '标记完成'), + ], + }, + dashboard: { + base: () => ({ name: 'system_overview', label: 'System Overview', widgets: [{ id: 'widget_total_users', title: 'Total Users' }] }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['dashboards', 'system_overview', 'label'], '系统概览'), + slot('widget title', (d) => d.widgets[0].title, (d, t) => { d.widgets[0].title = t; }, + ['dashboards', 'system_overview', 'widgets', 'widget_total_users', 'title'], '用户总数'), + ], + }, + report: { + base: () => ReportSchema.parse({ + name: 'task_overview', + label: 'Task Overview', + type: 'joined', + blocks: [{ name: 'open_block', label: 'Open Tasks', type: 'summary', dataset: 'tasks', rows: ['status'], values: ['n'] }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['reports', 'task_overview', 'label'], '任务概览'), + slot('block label', (d) => d.blocks[0].label, (d, t) => { d.blocks[0].label = t; }, + ['reports', 'task_overview', 'blocks', 'open_block', 'label'], '未完成任务'), + ], + }, + object: { + base: () => ({ + name: 'showcase_task', + label: 'Task', + fields: { + title: { name: 'title', type: 'text', label: 'Title' }, + status: { name: 'status', type: 'select', label: 'Status', options: [{ value: 'todo', label: 'To Do' }] }, + }, + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['objects', 'showcase_task', 'label'], '任务'), + slot('field label', (d) => d.fields.title.label, (d, t) => { d.fields.title.label = t; }, + ['objects', 'showcase_task', 'fields', 'title', 'label'], '标题'), + slot('option label', (d) => d.fields.status.options[0].label, (d, t) => { d.fields.status.options[0].label = t; }, + ['objects', 'showcase_task', 'fields', 'status', 'options', 'todo'], '待办'), + ], + }, + page: { + base: () => ({ + name: 'ops_home', + label: 'Ops Home', + regions: [{ + name: 'main', + components: [ + { type: 'page:card', id: 'open_work', properties: { title: 'Open Work' } }, + { type: 'page:tabs', id: 'ops_tabs', properties: { items: [{ label: 'Mine', value: 'mine', children: [] }] } }, + ], + }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['pages', 'ops_home', 'label'], '运营首页'), + slot('component copy', (d) => d.regions[0].components[0].properties.title, + (d, t) => { d.regions[0].components[0].properties.title = t; }, + ['pages', 'ops_home', 'components', 'open_work', 'title'], '进行中的工作'), + slot('tab item label', (d) => d.regions[0].components[1].properties.items[0].label, + (d, t) => { d.regions[0].components[1].properties.items[0].label = t; }, + ['pages', 'ops_home', 'components', 'ops_tabs', 'items', 'mine', 'label'], '我的'), + ], + }, + dataset: { + base: () => DatasetSchema.parse({ + name: 'duty_pulse', + label: 'Duty pulse', + object: 'duly_duty', + dimensions: [{ name: 'owner', field: 'owner_id', label: 'Owner' }], + measures: [{ name: 'untouched_14d', aggregate: 'count', label: 'Untouched > 14 days' }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['datasets', 'duty_pulse', 'label'], '任务脉搏'), + slot('measure label', (d) => d.measures[0].label, (d, t) => { d.measures[0].label = t; }, + ['datasets', 'duty_pulse', 'measures', 'untouched_14d', 'label'], '停滞超过 14 天'), + slot('dimension label', (d) => d.dimensions[0].label, (d, t) => { d.dimensions[0].label = t; }, + ['datasets', 'duty_pulse', 'dimensions', 'owner', 'label'], '负责人'), + ], + }, + app: { + base: () => ({ + name: 'setup', + label: 'Setup', + navigation: [{ + id: 'group_overview', + type: 'group', + label: 'Overview', + children: [{ id: 'nav_users', type: 'object', label: 'Users' }], + }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['apps', 'setup', 'label'], '系统设置'), + slot('nested nav label', (d) => d.navigation[0].children[0].label, (d, t) => { d.navigation[0].children[0].label = t; }, + ['apps', 'setup', 'navigation', 'nav_users', 'label'], '用户'), + ], + }, + book: { + base: () => BookSchema.parse({ + name: 'crm_manual', + label: 'User Manual', + groups: [{ key: 'start', label: 'Getting started', include: 'crm_start_*' }], + }), + slots: [ + slot('label', (d) => d.label, (d, t) => { d.label = t; }, ['books', 'crm_manual', 'label'], '使用手册'), + slot('group label', (d) => d.groups[0].label, (d, t) => { d.groups[0].label = t; }, + ['books', 'crm_manual', 'groups', 'start', 'label'], '快速上手'), + ], + }, + }; + + it('covers every document translator the ruling names — all eight types', () => { + expect(Object.keys(CASES).sort()).toEqual(['app', 'book', 'dashboard', 'dataset', 'object', 'page', 'report', 'view']); + for (const type of Object.keys(CASES)) expect(TRANSLATABLE_METADATA_TYPES.has(type), type).toBe(true); + }); + + for (const [type, c] of Object.entries(CASES)) { + describe(type, () => { + const base = c.base(); + const ownText = (s: Slot) => String(s.read(base)); + const overrideText = (s: Slot) => `${ownText(s)} (ours)`; + const pairText = (s: Slot) => `${s.baseZh}(配对)`; + // The deployment catalog: the base's translations. `en` repeats the + // shipped strings, as an extracted source locale does, so the source + // locale is as able to overwrite an override as `zh-CN` is. + const bundle: TranslationBundle = { + en: merge(...c.slots.map((s) => s.entry(ownText(s)))) as any, + 'zh-CN': merge(...c.slots.map((s) => s.entry(s.baseZh))) as any, + }; + /** The override: the base with `which` strings re-authored. */ + const overridden = (which: readonly Slot[] = c.slots) => { + const doc = clone(base); + for (const s of which) s.write(doc, overrideText(s)); + return doc; + }; + /** What the pair ships: a translation of each `which` string, in `locale`. */ + const pairOf = (locale: string, which: readonly Slot[] = c.slots): TranslationBundle => + ({ [locale]: merge(...which.map((s) => s.entry(pairText(s)))) }) as TranslationBundle; + const run = (doc: any, opts: TranslateDocumentOptions) => + translateMetadataDocument(type, doc, bundle, { packagedBase: base, ...opts }); + + it('composes the fixture this block is pinned to — the catalog translates every string of the base', () => { + const out = run(clone(base), { locale: 'zh-CN' }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(s.baseZh); + }); + + it("the pair wins — its translation beats the override's own label, top-level and nested", () => { + const out = run(overridden(), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s)); + }); + + it("with no pair translation the override's own label beats the base's translation — in zh-CN and in the source locale", () => { + for (const locale of ['zh-CN', 'en']) { + // No pair at all (today's call), and a pair that ships nothing for this item. + for (const pairBundle of [undefined, { [locale]: {} } as TranslationBundle]) { + const out = run(overridden(), { locale, pairBundle }); + for (const s of c.slots) expect(s.read(out), `${s.what} · ${locale} · pair ${pairBundle ? 'empty' : 'absent'}`).toBe(overrideText(s)); + } + } + }); + + it("a label the override did not change keeps the base's translation, beside one it changed", () => { + for (const changed of c.slots) { + const out = run(overridden([changed]), { locale: 'zh-CN', pairBundle: pairOf('zh-CN', [changed]) }); + for (const s of c.slots) { + expect(s.read(out), `${changed.what} changed · ${s.what}`).toBe(s === changed ? pairText(s) : s.baseZh); + } + } + }); + + it("an untouched label takes the pair's translation where the pair ships one", () => { + const out = run(clone(base), { locale: 'zh-CN', pairBundle: pairOf('zh-CN') }); + for (const s of c.slots) expect(s.read(out), s.what).toBe(pairText(s)); + }); + + it("per locale: a pair that ships only `en` leaves an untouched label on the base's zh-CN", () => { + const [changed, ...untouched] = c.slots; + const out = run(overridden([changed]), { locale: 'zh-CN', fallbackChain: ['en'], pairBundle: pairOf('en') }); + for (const s of untouched) expect(s.read(out), s.what).toBe(s.baseZh); + // The changed one reads the pair alone, along the chain. + expect(changed.read(out), changed.what).toBe(pairText(changed)); + }); + + it('no packaged base supplied — nothing counts as changed, so every string reads the pair, then the catalog', () => { + const [first, ...rest] = c.slots; + const out = run(overridden(), { locale: 'zh-CN', packagedBase: undefined, pairBundle: pairOf('zh-CN', [first]) }); + expect(first.read(out), first.what).toBe(pairText(first)); + for (const s of rest) expect(s.read(out), s.what).toBe(s.baseZh); + }); + + it('an empty pairBundle is the same answer as none — byte for byte, with and without a base, in every reading', () => { + const readings: TranslateDocumentOptions[] = [ + { locale: 'zh-CN' }, { locale: 'en' }, { locale: 'zh-CN', fallbackChain: ['en'] }, { locale: 'fr-FR' }, + ]; + for (const doc of [clone(base), overridden(), overridden(c.slots.slice(0, 1))]) { + for (const reading of readings) { + for (const packagedBase of [base, undefined]) { + const opts = { ...reading, packagedBase }; + const without = translateMetadataDocument(type, doc, bundle, opts); + for (const pairBundle of [{}, { 'de-DE': { apps: {} } }] as TranslationBundle[]) { + expect(JSON.stringify(translateMetadataDocument(type, doc, bundle, { ...opts, pairBundle }))).toBe(JSON.stringify(without)); + } + } + } + // No catalog at all: the same answer, and the input reference back + // wherever the translator hands it back without a pair. + const bare = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base }); + const withEmpty = translateMetadataDocument(type, doc, undefined, { locale: 'zh-CN', packagedBase: base, pairBundle: {} }); + expect(withEmpty).toStrictEqual(bare); + if (bare === doc) expect(withEmpty).toBe(doc); + } + }); + + it('reads neither input: the served document, the base and the pair come back as they went in', () => { + const doc = overridden(); + const pair = pairOf('zh-CN'); + const before = JSON.stringify([doc, base, pair, bundle]); + run(doc, { locale: 'zh-CN', pairBundle: pair }); + expect(JSON.stringify([doc, base, pair, bundle])).toBe(before); + }); + }); + } + + describe('page — the header title restates the label it was written against', () => { + const base = { + name: 'ops_home', + label: 'Ops Home', + regions: [{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Ops Home' } }] }], + }; + const bundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '运营首页' } } } as any }; + const title = (doc: any) => doc.regions[0].components[0].properties.title; + const relabelled = (headerToo: boolean) => { + const doc = clone(base); + doc.label = 'Our Ops'; + if (headerToo) doc.regions[0].components[0].properties.title = 'Our Ops'; + return doc; + }; + + it("a header title the override left as the base wrote it translates as the base's label, though the label changed", () => { + const out: any = translatePage(relabelled(false), bundle, { locale: 'zh-CN', packagedBase: base }); + expect(out.label).toBe('Our Ops'); + expect(title(out)).toBe('运营首页'); + }); + + it("a header title the override re-authored restates the override's label — its own text, or the pair's translation of it", () => { + expect(title(translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base }))).toBe('Our Ops'); + const pairBundle: TranslationBundle = { 'zh-CN': { pages: { ops_home: { label: '我们的运营' } } } as any }; + const out: any = translatePage(relabelled(true), bundle, { locale: 'zh-CN', packagedBase: base, pairBundle }); + expect(out.label).toBe('我们的运营'); + expect(title(out)).toBe('我们的运营'); + }); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 76f843de623..5bae7180d0c 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -372,8 +372,133 @@ export interface TranslateDocumentOptions extends ResolveOptions { * evidence that a value was authored. * * See {@link translateObject} for the rule it feeds. + * + * For a declared override from the base's paired `.custom` package + * (ADR-0146 D2) it is the BASE package's item — the document the override + * replaces, as the read would serve it without the override — never the + * override itself. Every document translator reads it, each string against + * its packaged counterpart: see {@link labelCatalogs} for the order. */ packagedBase?: unknown; + /** + * The translations the served item's paired `.custom` package ships itself + * (ADR-0146 D2/D3), locale-keyed as `bundle` is — that package's own + * `translation` items and nothing else. + * + * Needed because `bundle` cannot say where a string came from: the serving + * layer hands the translators one catalog per locale, every package's code + * bundle and every authored `translation` row deep-merged with the package + * ids dropped. Only this option lets a translation the pair ships outrank + * the override's own text, which outranks the base's translation — the + * order {@link labelCatalogs} applies to every translatable string of the + * document translators. + * + * Absent, every translator answers exactly as it does without it. + */ + pairBundle?: TranslationBundle; +} + +/** + * [ADR-0146 D2] The two catalogs a document translator reads one string from, + * built once per document by {@link labelCatalogs}. + */ +interface LabelCatalogs { + /** + * For a string the served document did NOT change against its packaged + * base: the pair's translations laid over the deployment catalog, locale + * by locale along the chain. + */ + readonly unchanged: TranslationBundle | undefined; + /** + * For a string the override changed: the pair's own translations, and no + * other catalog. + */ + readonly changed: TranslationBundle | undefined; +} + +/** + * [ADR-0146 D2] Label precedence on an overridden item — one order for every + * translatable string of every document translator (ruling record + * 6631250894): + * + * 1. a translation the paired `.custom` package ships + * ({@link TranslateDocumentOptions.pairBundle}); + * 2. the override's own text; + * 3. the base's translation. + * + * A string the override did not change still takes the base's translation: + * whether a string changed is the ONE comparison every translator already + * asks, {@link valueOverridesPackagedBase} against + * {@link TranslateDocumentOptions.packagedBase}, and {@link catalogFor} turns + * its answer into the catalog the string is read from: + * + * - **changed** — the pair's own translations only, along the locale chain; + * no entry keeps the override's text, because the deployment catalog + * translates the BASE's text; + * - **unchanged** — locale by locale, the pair's translation and then the + * deployment catalog's. Interleaved, not pair-first across the chain, so a + * pair that ships only `en` never displaces the base's `zh-CN` on a string + * it left alone. + * + * With no `pairBundle` — or one that answers none of the chain's locales — the + * unchanged catalog IS `bundle` (the same reference) and the changed one is + * empty, which is exactly what every translator did + * before the option existed: a changed string keeps its text, an unchanged + * one reads `bundle`. With no `packagedBase` nothing counts as changed. + */ +function labelCatalogs( + bundle: TranslationBundle | undefined, + opts: TranslateDocumentOptions | undefined, +): LabelCatalogs { + const pair = opts?.pairBundle; + if (!pair) return { unchanged: bundle, changed: undefined }; + const chain = localeChain(opts); + const pairData = chain.map((code) => pickData(pair, code)); + // A pair that answers none of the chain's locales adds nothing to read, so + // the catalogs are the ones without it — the same references, which is what + // keeps "no bundle" handing back the input document untouched. + if (pairData.every((data) => data === undefined)) return { unchanged: bundle, changed: undefined }; + // Keyed by the chain's own codes, each resolved against both bundles the + // way `pickData` resolves it, so every lookup on the result hits exactly. + // A code neither bundle answers gets `{}`: a bare miss would let + // `pickData` re-resolve the code against the OTHER codes kept here. + const unchanged: Record = {}; + chain.forEach((code, index) => { + unchanged[code] = overlayTranslationData(pickData(bundle, code), pairData[index]) ?? {}; + }); + return { unchanged: unchanged as TranslationBundle, changed: pair }; +} + +/** The catalog a string is read from: {@link labelCatalogs}' rule, by whether the override changed it. */ +function catalogFor(catalogs: LabelCatalogs, changed: boolean): TranslationBundle | undefined { + return changed ? catalogs.changed : catalogs.unchanged; +} + +/** + * `top` laid over `under`, one locale's translation data: nested groups merge + * key by key, and a non-empty leaf of `top` replaces `under`'s. An empty + * string is no translation (every lookup here reads only non-empty strings), + * so it never masks the leaf beneath it. Neither input is mutated; a branch + * `top` does not reach keeps `under`'s reference. + */ +function overlayTranslationData( + under: TranslationData | undefined, + top: TranslationData | undefined, +): TranslationData | undefined { + if (top === undefined) return under; + if (under === undefined) return top; + return overlayRecord(under as Record, top as Record) as TranslationData; +} + +function overlayRecord(under: Record, top: Record): Record { + const next: Record = { ...under }; + for (const [key, value] of Object.entries(top)) { + if (value === undefined || value === '') continue; + const below = asRecord(under[key]); + const above = asRecord(value); + next[key] = below !== undefined && above !== undefined ? overlayRecord(below, above) : value; + } + return next; } /** @@ -995,13 +1120,13 @@ function lookupBulkActionText( */ function translateBulkActionDefs( defs: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, objectName: string, viewKey: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, ): unknown { - if (!Array.isArray(defs) || !bundle) return defs; + if (!Array.isArray(defs) || !catalogs.unchanged) return defs; let changed = false; const next = defs.map((raw) => { const def = raw as BulkActionDefLike; @@ -1010,16 +1135,18 @@ function translateBulkActionDefs( } const defName = def.name; const packagedDef = packagedPart(packagedConfig, 'bulkActionDefs', (candidate) => candidate.name === defName); - // The catalog's string for `attr`, unless the authored value diverged from - // `packaged`'s — then `undefined`, which leaves the authored value in place. + // The catalog's string for `attr`, read from the catalog `labelCatalogs` + // assigns by whether the authored value diverged from `packaged`'s; + // `undefined` leaves the authored value in place. const text = ( packaged: Record | undefined, attr: string, authored: unknown, pick: (node: NonNullable>) => unknown, - ) => valueOverridesPackagedBase(packaged, attr, authored) - ? undefined - : lookupBulkActionText(bundle, objectName, viewKey, defName, pick, opts); + ) => lookupBulkActionText( + catalogFor(catalogs, valueOverridesPackagedBase(packaged, attr, authored)), + objectName, viewKey, defName, pick, opts, + ); const label = text(packagedDef, 'label', def.label, (n) => n.label); // `confirmText` is a template slot (#24798): the authored side and the @@ -1082,12 +1209,13 @@ function translateBulkActionDefs( * caller then leaves the authored value in place. */ function lookupSectionText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, sectionName: string, attr: 'label' | 'description', opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName]?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -1102,13 +1230,14 @@ function lookupSectionText( * leaves the authored value in place. */ function lookupSectionEntryText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, sectionName: string, fieldName: string, attr: FormSectionEntryAttr, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._sections?.[sectionName] ?.fields?.[fieldName]?.[attr]; @@ -1178,7 +1307,7 @@ function formSectionObjectName(config: Record, viewObject: stri */ function translateFormSections( sections: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, @@ -1191,15 +1320,18 @@ function translateFormSections( const sectionName = section.name; const packagedSection = packagedPart(packagedConfig, 'sections', (candidate) => candidate.name === sectionName); const text = (attr: 'label' | 'description', authored: unknown): string | undefined => - !sectionCopyAuthored(authored) || valueOverridesPackagedBase(packagedSection, attr, authored) + !sectionCopyAuthored(authored) ? undefined - : lookupSectionText(bundle, objectName, sectionName, attr, opts); + : lookupSectionText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedSection, attr, authored)), + objectName, sectionName, attr, opts, + ); const label = text('label', section.label); const description = text('description', section.description); const fields = translateFormSectionEntries( section.fields, - bundle, + catalogs, objectName, sectionName, opts, @@ -1236,7 +1368,7 @@ function translateFormSections( */ function translateFormSectionEntries( entries: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, sectionName: string, opts: ResolveOptions | undefined, @@ -1250,9 +1382,12 @@ function translateFormSectionEntries( const fieldName = entry.field; const packagedEntry = packagedPart(packagedSection, 'fields', (candidate) => candidate.field === fieldName); const text = (attr: FormSectionEntryAttr): string | undefined => - !sectionCopyAuthored(entry[attr]) || valueOverridesPackagedBase(packagedEntry, attr, entry[attr]) + !sectionCopyAuthored(entry[attr]) ? undefined - : lookupSectionEntryText(bundle, objectName, sectionName, fieldName, attr, opts); + : lookupSectionEntryText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedEntry, attr, entry[attr])), + objectName, sectionName, fieldName, attr, opts, + ); const label = text('label'); const placeholder = text('placeholder'); @@ -1291,15 +1426,15 @@ function translateFormSectionEntries( */ function translateFormTitle( title: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, objectName: string, viewKey: string, opts: ResolveOptions | undefined, packagedConfig: Record | undefined, ): unknown { if (typeof title !== 'string' || title.length === 0) return title; - if (valueOverridesPackagedBase(packagedConfig, 'title', title)) return title; - return lookupViewText(bundle, objectName, viewKey, 'title', opts) ?? title; + const catalog = catalogFor(catalogs, valueOverridesPackagedBase(packagedConfig, 'title', title)); + return lookupViewText(catalog, objectName, viewKey, 'title', opts) ?? title; } /** @@ -1333,7 +1468,8 @@ function translateFormTitle( */ function translateFormSubmitBehavior( behavior: unknown, - lookup: (attr: SubmitBehaviorCopyKey) => string | undefined, + catalogs: LabelCatalogs, + lookup: (catalog: TranslationBundle | undefined, attr: SubmitBehaviorCopyKey) => string | undefined, packagedConfig: Record | undefined, ): unknown { const served = asRecord(behavior); @@ -1342,8 +1478,7 @@ function translateFormSubmitBehavior( const text = (attr: SubmitBehaviorCopyKey): string | undefined => { const value = served[attr]; if (typeof value !== 'string' || value.length === 0) return undefined; - if (valueOverridesPackagedBase(packaged, attr, value)) return undefined; - return lookup(attr); + return lookup(catalogFor(catalogs, valueOverridesPackagedBase(packaged, attr, value)), attr); }; const title = text('title'); const message = text('message'); @@ -1363,12 +1498,13 @@ type SubmitBehaviorCopyKey = 'title' | 'message'; * locale chain — `undefined` when no locale carries it. */ function lookupSubmitBehaviorText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, objectName: string, viewKey: string, attr: SubmitBehaviorCopyKey, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.objects?.[objectName]?._views?.[viewKey]?.submitBehavior?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -1483,22 +1619,30 @@ export function translateView( opts?: TranslateDocumentOptions, ): T { const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', view.label) - ? (view.label as string) - : resolveViewLabel(bundle, view, opts); - const description = valueOverridesPackagedBase(base, 'description', view.description) - ? view.description - : resolveViewDescription(bundle, view, opts); + const catalogs = labelCatalogs(bundle, opts); + // A changed string reads the pair's catalog only, and falls back to its own + // value: `resolveViewLabel` / `resolveViewDescription` answer the view's + // own text on a miss. + const label = resolveViewLabel( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', view.label)), + view, + opts, + ); + const description = resolveViewDescription( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', view.description)), + view, + opts, + ); let config = view.config; const objectName = viewObjectName(view); - if (config && typeof config === 'object' && bundle && objectName) { + if (config && typeof config === 'object' && catalogs.unchanged && objectName) { // The base's own `config` — `{}` when the base is known but carries none, // so every def and section on the served view then counts as authored. const packagedConfig = base === undefined ? undefined : asRecord(base.config) ?? {}; const defs = translateBulkActionDefs( config.bulkActionDefs, - bundle, + catalogs, objectName, viewTranslationKey(view, objectName), opts, @@ -1506,14 +1650,14 @@ export function translateView( ); const sections = translateFormSections( config.sections, - bundle, + catalogs, formSectionObjectName(config, objectName), opts, packagedConfig, ); const title = translateFormTitle( config.title, - bundle, + catalogs, objectName, viewTranslationKey(view, objectName), opts, @@ -1522,7 +1666,8 @@ export function translateView( const viewKey = viewTranslationKey(view, objectName); const submitBehavior = translateFormSubmitBehavior( config.submitBehavior, - (attr) => lookupSubmitBehaviorText(bundle, objectName, viewKey, attr, opts), + catalogs, + (catalog, attr) => lookupSubmitBehaviorText(catalog, objectName, viewKey, attr, opts), packagedConfig, ); if ( @@ -1743,6 +1888,31 @@ function lookupNavLabel( return undefined; } +/** + * The packaged base's navigation nodes by `id` — the key + * `apps..navigation.` addresses a node by, at any depth — with + * {@link packagedPart}'s three answers per node: `undefined` for every id when + * no base was supplied, `{}` for an id the known base does not carry (a node + * authored after the fact), the packaged node otherwise. The first node of an + * id, depth-first, wins; the walk stops at {@link MAX_NESTED_COMPONENT_DEPTH} + * levels, since `children` is authored data. + */ +function packagedNavNodes(base: Record | undefined): (id: string) => Record | undefined { + if (base === undefined) return () => undefined; + const byId = new Map>(); + const visit = (nodes: unknown, depth: number): void => { + if (!Array.isArray(nodes) || depth >= MAX_NESTED_COMPONENT_DEPTH) return; + for (const raw of nodes) { + const node = asRecord(raw); + if (node === undefined) continue; + if (typeof node.id === 'string' && !byId.has(node.id)) byId.set(node.id, node); + visit(node.children, depth + 1); + } + }; + visit(base.navigation, 0); + return (id) => byId.get(id) ?? {}; +} + /** * Apply the active locale to an app metadata document — translates the app's * `label` / `description` and walks the (possibly nested) `navigation` tree, @@ -1752,24 +1922,45 @@ function lookupNavLabel( * Translation keys are addressed by the stable navigation-node `id` * (e.g. `group_overview`, `nav_users`) — the same flat keyspace used by the * `apps..navigation` map, regardless of tree depth. + * + * ## An override's own text — ADR-0146 D2 + * + * An app is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: the app's `label` and `description` + * against the base's, each navigation node's `label` against the base's node + * of the same `id` ({@link packagedNavNodes}). A string the override changed + * reads only the pair's translations and otherwise keeps its own text; a + * string it left alone takes the pair's translation, then the base's. No base + * supplied: the catalog applies to every string, as before. */ export function translateApp( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const appName = doc.name; - if (!appName || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!appName || !catalogs.unchanged) return doc; - const label = lookupAppAttr(bundle, appName, 'label', opts) ?? doc.label; - const description = lookupAppAttr(bundle, appName, 'description', opts) ?? doc.description; + const base = asRecord(opts?.packagedBase); + const label = lookupAppAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), appName, 'label', opts, + ) ?? doc.label; + const description = lookupAppAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), appName, 'description', opts, + ) ?? doc.description; + const packagedNode = packagedNavNodes(base); const translateNav = (node: NavNodeLike): NavNodeLike => { if (!node || typeof node !== 'object') return node; const next: NavNodeLike = { ...node }; if (typeof node.id === 'string') { - const translated = lookupNavLabel(bundle, appName, node.id, opts); + const changed = valueOverridesPackagedBase(packagedNode(node.id), 'label', node.label); + const translated = lookupNavLabel(catalogFor(catalogs, changed), appName, node.id, opts); if (translated) next.label = translated; } if (Array.isArray(node.children)) { @@ -1922,7 +2113,9 @@ function isOptionValue(value: unknown): value is string | number | boolean { */ function packagedPart( parent: Record | undefined, - key: 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields' | 'blocks', + key: + | 'widgets' | 'globalFilters' | 'options' | 'bulkActionDefs' | 'params' | 'sections' | 'fields' | 'blocks' + | 'dimensions' | 'measures' | 'groups' | 'items' | 'tabs', matches: (candidate: Record) => boolean, ): Record | undefined { if (parent === undefined) return undefined; @@ -1978,7 +2171,7 @@ function packagedObjectField( */ function translateGlobalFilter( filter: GlobalFilterLike, - bundle: TranslationBundle, + catalogs: LabelCatalogs, dashboardName: string, opts: ResolveOptions | undefined, packagedFilter: Record | undefined, @@ -1987,9 +2180,10 @@ function translateGlobalFilter( if (key === undefined) return filter; let next = filter; - const label = valueOverridesPackagedBase(packagedFilter, 'label', filter.label) - ? undefined - : lookupGlobalFilterLabel(bundle, dashboardName, key, opts); + const label = lookupGlobalFilterLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedFilter, 'label', filter.label)), + dashboardName, key, opts, + ); if (label !== undefined) next = { ...next, label }; if (Array.isArray(filter.options)) { @@ -2007,8 +2201,10 @@ function translateGlobalFilter( 'options', (candidate) => isOptionValue(candidate.value) && String(candidate.value) === String(value), ); - if (valueOverridesPackagedBase(packagedOption, 'label', option.label)) return option; - const translated = lookupGlobalFilterOption(bundle, dashboardName, key, String(value), opts); + const translated = lookupGlobalFilterOption( + catalogFor(catalogs, valueOverridesPackagedBase(packagedOption, 'label', option.label)), + dashboardName, key, String(value), opts, + ); if (translated === undefined) return option; changed = true; return { ...option, label: translated }; @@ -2105,15 +2301,16 @@ export function translateDashboard( ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', doc.label) - ? doc.label - : lookupDashboardAttr(bundle, name, 'label', opts) ?? doc.label; - const description = valueOverridesPackagedBase(base, 'description', doc.description) - ? doc.description - : lookupDashboardAttr(bundle, name, 'description', opts) ?? doc.description; + const label = lookupDashboardAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ) ?? doc.label; + const description = lookupDashboardAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ) ?? doc.description; let globalFilters: GlobalFilterLike[] | undefined; if (Array.isArray(doc.globalFilters)) { @@ -2126,7 +2323,7 @@ export function translateDashboard( 'globalFilters', (candidate) => key !== undefined && globalFilterKey(candidate) === key, ); - const next = translateGlobalFilter(filter, bundle, name, opts, packagedFilter); + const next = translateGlobalFilter(filter, catalogs, name, opts, packagedFilter); if (next !== filter) changed = true; return next; }); @@ -2138,13 +2335,15 @@ export function translateDashboard( if (!w || typeof w !== 'object' || typeof w.id !== 'string') return w; const next: WidgetLike = { ...w }; const packagedWidget = packagedPart(base, 'widgets', (candidate) => candidate.id === w.id); - const title = valueOverridesPackagedBase(packagedWidget, 'title', w.title) - ? undefined - : lookupWidgetAttr(bundle, name, w.id, 'title', opts); + const title = lookupWidgetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedWidget, 'title', w.title)), + name, w.id, 'title', opts, + ); if (title) next.title = title; - const desc = valueOverridesPackagedBase(packagedWidget, 'description', w.description) - ? undefined - : lookupWidgetAttr(bundle, name, w.id, 'description', opts); + const desc = lookupWidgetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedWidget, 'description', w.description)), + name, w.id, 'description', opts, + ); if (desc) next.description = desc; return next; }) @@ -2214,14 +2413,17 @@ function lookupDatasetMemberLabel( /** * Overlay the bundle's labels onto a dataset's `dimensions[]` / `measures[]`, - * returning the SAME array reference when nothing matched. + * returning the SAME array reference when nothing matched. Each member is + * judged against the packaged base's member of the same `name` + * ({@link packagedPart}) — {@link translateDataset}'s rule, one level down. */ function translateDatasetMembers( members: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, datasetName: string, group: 'dimensions' | 'measures', - opts?: ResolveOptions, + opts: ResolveOptions | undefined, + base: Record | undefined, ): unknown { if (!Array.isArray(members)) return members; let changed = false; @@ -2230,7 +2432,12 @@ function translateDatasetMembers( if (!member || typeof member !== 'object' || typeof member.name !== 'string' || member.name.length === 0) { return raw; } - const label = lookupDatasetMemberLabel(bundle, datasetName, group, member.name, opts); + const memberName = member.name; + const packagedMember = packagedPart(base, group, (candidate) => candidate.name === memberName); + const label = lookupDatasetMemberLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedMember, 'label', member.label)), + datasetName, group, memberName, opts, + ); if (label === undefined) return raw; changed = true; return { ...member, label }; @@ -2266,20 +2473,40 @@ function translateDatasetMembers( * down to one, so — exactly as `translatePage` does — a key the bundle does not * carry leaves the authored value untouched, and the arrays keep their identity * when nothing matched. + * + * ## An override's own text — ADR-0146 D2 + * + * A dataset is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: `label` and `description` against the + * base's, each dimension's and measure's `label` against the base's member of + * the same `name`. A string the override changed reads only the pair's + * translations and otherwise keeps its own text; a string it left alone takes + * the pair's translation, then the base's. An inline locale map is never + * "changed" (the comparison reads strings only), so the catalog keeps the + * reading it has today. No base supplied: the catalog applies, as before. */ export function translateDataset( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; - const label = lookupDatasetAttr(bundle, name, 'label', opts); - const description = lookupDatasetAttr(bundle, name, 'description', opts); - const dimensions = translateDatasetMembers(doc.dimensions, bundle, name, 'dimensions', opts); - const measures = translateDatasetMembers(doc.measures, bundle, name, 'measures', opts); + const base = asRecord(opts?.packagedBase); + const label = lookupDatasetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); + const description = lookupDatasetAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ); + const dimensions = translateDatasetMembers(doc.dimensions, catalogs, name, 'dimensions', opts, base); + const measures = translateDatasetMembers(doc.measures, catalogs, name, 'measures', opts, base); return { ...doc, @@ -2437,22 +2664,46 @@ function lookupBookGroupLabel( * console's docs portal builds its sidebar from), and the book tree route * (`GET /meta/book/:name/tree`) translates the book it resolves through the * same table before the spine is rendered. + * + * ## An override's own text — ADR-0146 D2 + * + * A book is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}: `label` and `description` against the + * base's, each group's `label` against the base's group of the same `key`. A + * string the override changed reads only the pair's translations and + * otherwise keeps its own text; a string it left alone takes the pair's + * translation, then the base's. No base supplied: the catalog applies, as + * before. */ export function translateBook( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const bookName = doc.name; - if (typeof bookName !== 'string' || bookName.length === 0 || !bundle) return doc; - const label = lookupBookAttr(bundle, bookName, 'label', opts); - const description = lookupBookAttr(bundle, bookName, 'description', opts); + const catalogs = labelCatalogs(bundle, opts); + if (typeof bookName !== 'string' || bookName.length === 0 || !catalogs.unchanged) return doc; + const base = asRecord(opts?.packagedBase); + const label = lookupBookAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), bookName, 'label', opts, + ); + const description = lookupBookAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), bookName, 'description', opts, + ); let groupsMoved = false; const groups = Array.isArray(doc.groups) ? doc.groups.map((group) => { if (!group || typeof group !== 'object' || typeof group.key !== 'string') return group; - const translated = lookupBookGroupLabel(bundle, bookName, group.key, opts); + const groupKey = group.key; + const packagedGroup = packagedPart(base, 'groups', (candidate) => candidate.key === groupKey); + const translated = lookupBookGroupLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedGroup, 'label', group.label)), + bookName, groupKey, opts, + ); if (translated === undefined) return group; groupsMoved = true; return { ...group, label: translated }; @@ -2526,7 +2777,7 @@ function lookupReportBlockAttr( */ function translateReportBlocks( blocks: unknown, - bundle: TranslationBundle, + catalogs: LabelCatalogs, name: string, base: Record | undefined, opts?: ResolveOptions, @@ -2538,12 +2789,14 @@ function translateReportBlocks( const blockName = block.name; if (typeof blockName !== 'string' || blockName.length === 0) return block; const packagedBlock = packagedPart(base, 'blocks', (candidate) => candidate.name === blockName); - const label = valueOverridesPackagedBase(packagedBlock, 'label', block.label) - ? undefined - : lookupReportBlockAttr(bundle, name, blockName, 'label', opts); - const description = valueOverridesPackagedBase(packagedBlock, 'description', block.description) - ? undefined - : lookupReportBlockAttr(bundle, name, blockName, 'description', opts); + const label = lookupReportBlockAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedBlock, 'label', block.label)), + name, blockName, 'label', opts, + ); + const description = lookupReportBlockAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedBlock, 'description', block.description)), + name, blockName, 'description', opts, + ); if (label === undefined && description === undefined) return block; changed = true; return { @@ -2594,16 +2847,17 @@ export function translateReport( ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (typeof name !== 'string' || name.length === 0 || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (typeof name !== 'string' || name.length === 0 || !catalogs.unchanged) return doc; const base = asRecord(opts?.packagedBase); - const label = valueOverridesPackagedBase(base, 'label', doc.label) - ? undefined - : lookupReportAttr(bundle, name, 'label', opts); - const description = valueOverridesPackagedBase(base, 'description', doc.description) - ? undefined - : lookupReportAttr(bundle, name, 'description', opts); - const blocks = translateReportBlocks(doc.blocks, bundle, name, base, opts); + const label = lookupReportAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); + const description = lookupReportAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ); + const blocks = translateReportBlocks(doc.blocks, catalogs, name, base, opts); if (label === undefined && description === undefined && blocks === undefined) return doc; return { ...doc, @@ -2733,20 +2987,27 @@ function sameAuthoredText(a: string | Record, b: unknown): boole * `os i18n extract` offers no `title` key for such a header (it offers the * label alone), so reading the label here is what translates it. * + * The two cases of step 3 read two different labels (ADR-0146 D2). An + * untitled header is filled with the PAGE's label, so `fillLabel` is that + * label's own translation, under whatever rule the page label follows. A + * title that restates a label translates as THAT label: `restated` names the + * label the title is compared with and its translation — the served page's + * when the header's title is the override's own, the base page's when the + * override left the header's title as the base wrote it. + * * `undefined` means "leave the authored title alone". */ function resolvePageHeaderTitle( bundleTitle: string | undefined, - bundleLabel: string | undefined, + fillLabel: string | undefined, + restated: { label: unknown; translation: string | undefined }, authoredTitle: unknown, - authoredPageLabel: unknown, derivesFromRecord: boolean, ): string | undefined { if (bundleTitle !== undefined) return bundleTitle; - if (bundleLabel === undefined) return undefined; const own = authoredLabelText(authoredTitle); - if (own === undefined) return derivesFromRecord ? undefined : bundleLabel; - if (sameAuthoredText(own, authoredPageLabel)) return bundleLabel; + if (own === undefined) return derivesFromRecord ? undefined : fillLabel; + if (sameAuthoredText(own, restated.label)) return restated.translation; return undefined; } @@ -2864,12 +3125,13 @@ export const PAGE_FORM_COMPONENT_TYPE = 'object-form'; * key by key, as {@link lookupPageComponentCopy} is. */ function lookupPageFormSubmitBehaviorText( - bundle: TranslationBundle, + bundle: TranslationBundle | undefined, name: string, id: string, attr: SubmitBehaviorCopyKey, opts: ResolveOptions | undefined, ): string | undefined { + if (!bundle) return undefined; for (const code of localeChain(opts)) { const candidate = pickData(bundle, code)?.pages?.[name]?.components?.[id]?.submitBehavior?.[attr]; if (typeof candidate === 'string' && candidate.length > 0) return candidate; @@ -2918,13 +3180,18 @@ function lookupPageTabItemLabel( * Overlay a `page:tabs` component's item labels from the bundle. Returns the * component unchanged when no item resolves, so a tab strip the bundle does * not cover — an inline locale map included — is left exactly as authored. + * + * `packagedProps` is the packaged base's same component's `properties` + * ({@link packagedPageParts}); each item is judged against the packaged item + * of the same `value` ({@link labelCatalogs}). */ function translatePageTabItems( component: PageComponentLike, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, name: string, id: string, - opts?: ResolveOptions, + opts: ResolveOptions | undefined, + packagedProps: Record | undefined, ): PageComponentLike { const props = component.properties; if (!props || typeof props !== 'object' || !Array.isArray(props.items)) return component; @@ -2932,7 +3199,12 @@ function translatePageTabItems( const items = (props.items as unknown[]).map((item) => { const value = pageTabItemKey(item); if (value === undefined) return item; - const label = lookupPageTabItemLabel(bundle, name, id, value, opts); + const packagedItem = packagedPart(packagedProps, 'items', (candidate) => pageTabItemKey(candidate) === value); + const authored = (item as Record).label; + const label = lookupPageTabItemLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedItem, 'label', authored)), + name, id, value, opts, + ); if (label === undefined) return item; changed = true; return { ...(item as Record), label }; @@ -3279,6 +3551,45 @@ export function walkAddressedPageComponents( }; } +/** + * Where a page component's copy key lands, read back: a `label` on the + * component's own top-level `label` when it authors a string there, every + * other key (and a `label` otherwise) in `properties` — the one either/or + * {@link translatePage} writes the copy back by. + */ +function pageComponentCopyValue(component: Record, key: PageComponentCopyKey): unknown { + if (key === 'label' && typeof component.label === 'string') return component.label; + return asRecord(component.properties)?.[key]; +} + +/** + * The packaged base page's counterparts, for {@link translatePage}'s rule + * (ADR-0146 D2): each addressed component by `id`, found by the same walk and + * the same collision arbitration the translator reads the served page with + * ({@link walkAddressedPageComponents}), and THE page's header — its first + * root-level `page:header`, the one the page-name route addresses. + * + * {@link packagedPart}'s three answers for each: `undefined` when no base was + * supplied, `{}` when the known base carries no such part, the part itself + * otherwise. + */ +function packagedPageParts(base: Record | undefined): { + component: (id: string) => Record | undefined; + header: Record | undefined; +} { + if (base === undefined) return { component: () => undefined, header: undefined }; + const byId = new Map>(); + let header: Record | undefined; + walkAddressedPageComponents(base as AddressedPageRoots, (component, { id, nested, addressed }) => { + if (addressed && id !== undefined && !byId.has(id)) byId.set(id, component as Record); + if (!nested && header === undefined && component.type === PAGE_HEADER_COMPONENT) { + header = component as Record; + } + return component; + }); + return { component: (id) => byId.get(id) ?? {}, header: header ?? {} }; +} + /** * Apply the active locale to a page metadata document — translates the page's * own `label` / `description` and the `properties.title` / `properties.subtitle` @@ -3389,23 +3700,49 @@ export function walkAddressedPageComponents( * `objects.._tabs..label` — see * {@link translateInterfaceTabs} for why that key is object-scoped and why * only this one of `ViewTabSchema`'s two carriers is covered (#5377). + * + * ## An override's own text — ADR-0146 D2 + * + * A page is overridable from its base's paired `.custom` package, and the + * catalog translates the BASE's text. So every string here follows + * {@link labelCatalogs}' order, judged against + * {@link TranslateDocumentOptions.packagedBase} by + * {@link valueOverridesPackagedBase}, each against its packaged counterpart + * ({@link packagedPageParts}): the page's `label` and `description` against + * the base page's; the root-level header's `title` and `subtitle` against the + * base's root-level header; an addressed component's copy, key by key, against + * the base's component of the same `id`, read where the copy lands; a tab + * item's `label` against the base component's item of the same `value`; an + * `object-form`'s thank-you panel against the base component's; a preset tab's + * `label` against the base's tab of the same `name`. A string the override + * changed reads only the pair's translations and otherwise keeps its own + * text; a string it left alone takes the pair's translation, then the + * base's. No base supplied: the catalog applies to every string, as before. */ export function translatePage( doc: T, bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + opts?: TranslateDocumentOptions, ): T { if (!doc || typeof doc !== 'object') return doc; const name = doc.name; - if (!name || !bundle) return doc; + const catalogs = labelCatalogs(bundle, opts); + if (!name || !catalogs.unchanged) return doc; - const bundleLabel = lookupPageAttr(bundle, name, 'label', opts); + const base = asRecord(opts?.packagedBase); + const packaged = packagedPageParts(base); + const bundleLabel = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'label', doc.label)), name, 'label', opts, + ); const label = bundleLabel ?? doc.label; - const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description; - // The header title is decided per header, against what that header authors - // (`resolvePageHeaderTitle`); only the bundle half is page-wide. - const bundleHeaderTitle = lookupPageAttr(bundle, name, 'title', opts); - const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts); + const description = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(base, 'description', doc.description)), name, 'description', opts, + ) ?? doc.description; + // The header strings are decided per header, against what that header + // authors (`resolvePageHeaderTitle`) and against the base's header. + const packagedHeaderProps = packaged.header === undefined + ? undefined + : asRecord(packaged.header.properties) ?? {}; // The traversal — roots, descent, depth cap, cycle guard, collision // arbitration — is the shared walk (commit c45d8e6b4). This visitor owns only the @@ -3438,9 +3775,27 @@ export function translatePage( // the document, decided identically for every consumer of the walk; only // whether this consumer READS the entry changes here, and the extractor // blocks the namesake the same way. + // The base's component of the same id, and its `properties` + // (`packagedPageParts`' three answers), for every rule below. + const packagedComponent = addressed ? packaged.component(id as string) : undefined; + const packagedProps = packagedComponent === undefined + ? (base === undefined ? undefined : {}) + : asRecord(packagedComponent.properties) ?? {}; + let copy: Partial> | undefined; if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT)) { - copy = lookupPageComponentCopy(bundle, name, id as string, opts); + // Key by key, each from the catalog its own served value earns: + // compared where the copy lands, the base's value read in the same place. + const fromUnchanged = lookupPageComponentCopy(catalogs.unchanged, name, id as string, opts); + const fromChanged = lookupPageComponentCopy(catalogs.changed, name, id as string, opts); + for (const key of PAGE_COMPONENT_COPY_KEYS) { + const packagedCopy = packagedComponent === undefined + ? undefined + : { [key]: pageComponentCopyValue(packagedComponent, key) }; + const changed = valueOverridesPackagedBase(packagedCopy, key, pageComponentCopyValue(component, key)); + const text = (changed ? fromChanged : fromUnchanged)?.[key]; + if (text !== undefined) (copy ??= {})[key] = text; + } } let next = component; @@ -3469,21 +3824,21 @@ export function translatePage( // entry belongs to one tabs component. The walk keeps a visitor's edit to // a panel's own keys and still descends the authored panel children. if (addressed && component.type === PAGE_TABS_COMPONENT_TYPE) { - next = translatePageTabItems(next, bundle, name, id as string, opts); + next = translatePageTabItems(next, catalogs, name, id as string, opts, packagedProps); } // An `object-form`'s thank-you panel, under the form component's own id // (`pages..components..submitBehavior`), on the same // `addressed` arbitration. The rules are the form view's panel's, through - // the one function both carriers share; `translatePage` is handed no - // packaged base for any page string, so the catalog applies as it does - // to this component's `title` and `description`. + // the one function both carriers share, judged against the base's same + // component's `properties.submitBehavior`. if (addressed && component.type === PAGE_FORM_COMPONENT_TYPE) { const behavior = next.properties?.submitBehavior; const submitBehavior = translateFormSubmitBehavior( behavior, - (attr) => lookupPageFormSubmitBehaviorText(bundle, name, id as string, attr, opts), - undefined, + catalogs, + (catalog, attr) => lookupPageFormSubmitBehaviorText(catalog, name, id as string, attr, opts), + packagedProps, ); if (submitBehavior !== behavior) { next = { ...next, properties: { ...next.properties, submitBehavior } }; @@ -3496,8 +3851,21 @@ export function translatePage( if (next.type !== PAGE_HEADER_COMPONENT) return next; // One call for a region root and a slot root alike: both arrive here with // `nested: false`, so the fallback order cannot differ between them. + const authoredTitle = next.properties?.title; + const titleChanged = valueOverridesPackagedBase(packagedHeaderProps, 'title', authoredTitle); + const bundleHeaderTitle = lookupPageAttr(catalogFor(catalogs, titleChanged), name, 'title', opts); + const headerSubtitle = lookupPageAttr( + catalogFor(catalogs, valueOverridesPackagedBase(packagedHeaderProps, 'subtitle', next.properties?.subtitle)), + name, 'subtitle', opts, + ); + // A title the override left as the base wrote it restates the BASE's + // label, and translates as that label does; the override's own title + // restates the served label. + const restated = titleChanged || base === undefined + ? { label: doc.label, translation: bundleLabel } + : { label: base.label, translation: lookupPageAttr(catalogs.unchanged, name, 'label', opts) }; const headerTitle = resolvePageHeaderTitle( - bundleHeaderTitle, bundleLabel, next.properties?.title, doc.label, + bundleHeaderTitle, bundleLabel, restated, authoredTitle, headerDerivesFromRecord(doc, next), ); if (headerTitle === undefined && headerSubtitle === undefined) return next; @@ -3515,7 +3883,7 @@ export function translatePage( }; }); - const interfaceConfig = translateInterfaceTabs(doc, bundle, opts); + const interfaceConfig = translateInterfaceTabs(doc, catalogs, opts, base); return { ...doc, @@ -3545,11 +3913,15 @@ export function translatePage( * the records these presets filter — falling back to the page-level `object`. * Same order `i18n-extract` uses when it scaffolds the keys, and the same * "component's own binding first" discipline `_sections` follows. + * + * Each tab is judged against the packaged page's tab of the same `name` + * ({@link labelCatalogs}; `base` is {@link TranslateDocumentOptions.packagedBase}). */ function translateInterfaceTabs( doc: PageLike, - bundle: TranslationBundle | undefined, - opts?: ResolveOptions, + catalogs: LabelCatalogs, + opts: ResolveOptions | undefined, + base: Record | undefined, ): PageLike['interfaceConfig'] | undefined { const cfg = doc.interfaceConfig; if (!cfg || typeof cfg !== 'object') return undefined; @@ -3562,10 +3934,17 @@ function translateInterfaceTabs( : doc.object; if (!objectName) return undefined; + const packagedFilters = base === undefined + ? undefined + : asRecord(asRecord(base.interfaceConfig)?.userFilters) ?? {}; let changed = false; const tabs = userFilters.tabs.map((tab) => { if (!tab || typeof tab !== 'object') return tab; - const label = lookupTabLabel(bundle, objectName, tab, opts); + const packagedTab = packagedPart(packagedFilters, 'tabs', (candidate) => candidate.name === tab.name); + const label = lookupTabLabel( + catalogFor(catalogs, valueOverridesPackagedBase(packagedTab, 'label', tab.label)), + objectName, tab, opts, + ); if (label === undefined) return tab; changed = true; return { ...tab, label }; @@ -4192,12 +4571,12 @@ function authorsInlineHelpText(field: Record | undefined): bool */ function translateObjectListViews( listViews: unknown, - bundle: TranslationBundle | undefined, + catalogs: LabelCatalogs, objectName: string, opts: TranslateDocumentOptions | undefined, ): unknown { const views = asRecord(listViews); - if (views === undefined || !bundle) return listViews; + if (views === undefined || !catalogs.unchanged) return listViews; const base = asRecord(opts?.packagedBase); // The base's own `listViews` — `{}` when the base is known but carries none, // so every view on the served document then counts as authored. @@ -4211,13 +4590,13 @@ function translateObjectListViews( continue; } const packagedView = packagedViews === undefined ? undefined : asRecord(packagedViews[viewKey]) ?? {}; - const text = (attr: 'label' | 'description') => - valueOverridesPackagedBase(packagedView, attr, view[attr]) - ? undefined - : lookupViewText(bundle, objectName, viewKey, attr, opts); + const text = (attr: 'label' | 'description') => lookupViewText( + catalogFor(catalogs, valueOverridesPackagedBase(packagedView, attr, view[attr])), + objectName, viewKey, attr, opts, + ); const label = text('label'); const description = text('description'); - const defs = translateBulkActionDefs(view.bulkActionDefs, bundle, objectName, viewKey, opts, packagedView); + const defs = translateBulkActionDefs(view.bulkActionDefs, catalogs, objectName, viewKey, opts, packagedView); if (label === undefined && description === undefined && defs === view.bulkActionDefs) { next[viewKey] = raw; continue; @@ -4497,14 +4876,17 @@ export function translateObject( // [#8284] `catalog ?? document`, EXCEPT where the document's own value has // diverged from the packaged declaration — there the document is authored - // data and the catalog yields to it. + // data and the deployment catalog yields to it (the pair's own catalog + // still applies, ADR-0146 D2 — see `labelCatalogs`). + const catalogs = labelCatalogs(bundle, opts); const resolveScalar = ( key: 'label' | 'pluralLabel' | 'description', authored: string | undefined, ): string | undefined => - scalarOverridesPackagedBase(opts?.packagedBase, key, authored) - ? authored - : lookupObjectField(bundle, objectName, key, opts) ?? authored; + lookupObjectField( + catalogFor(catalogs, scalarOverridesPackagedBase(opts?.packagedBase, key, authored)), + objectName, key, opts, + ) ?? authored; const label = resolveScalar('label', doc.label); const pluralLabel = resolveScalar('pluralLabel', doc.pluralLabel); @@ -4514,51 +4896,53 @@ export function translateObject( const translateField = (name: string, def: ObjectFieldLike): ObjectFieldLike => { const next: ObjectFieldLike = { ...def }; const packagedField = packagedObjectField(packagedObject, name); - // The field `label` keeps a value that diverged from the packaged field's - // — an extension's `fieldPatches` label, a tenant's rename — under the one - // comparison its sibling slots use (ADR-0029 D9.2a; see the docblock). - if (!valueOverridesPackagedBase(packagedField, 'label', def.label)) { - const translatedLabel = - lookupObjectFieldAttr(bundle, objectName, name, 'label', opts) ?? + // Each slot reads the catalog `labelCatalogs` assigns by whether its + // served value diverged from the packaged field's (ADR-0029 D9.2a, + // ADR-0146 D2; see the docblock). + const slotCatalog = (slot: string, value: unknown): TranslationBundle | undefined => + catalogFor(catalogs, valueOverridesPackagedBase(packagedField, slot, value)); + // The field `label`: a value that diverged from the packaged field's — an + // extension's `fieldPatches` label, a tenant's rename — reads only the + // pair's catalog, and the built-in system-field labels apply only where + // the deployment catalog may. + const labelChanged = valueOverridesPackagedBase(packagedField, 'label', def.label); + const translatedLabel = labelChanged + ? lookupObjectFieldAttr(catalogs.changed, objectName, name, 'label', opts) + : lookupObjectFieldAttr(catalogs.unchanged, objectName, name, 'label', opts) ?? builtinSystemFieldLabel(name, def.label, opts); - if (translatedLabel) next.label = translatedLabel; - } + if (translatedLabel) next.label = translatedLabel; // The bundle's field `help` entry is served on the slot the extractor // seeded it from: the field's own `inlineHelpText` when it authors one — // the slot the console draws first — and `description` only when neither // the served nor the packaged field authors one; never on an undeclared - // `help`. The slot keeps a value that diverged from the packaged field's - // (ADR-0029 D9.2a; see the docblock). - const translatedHelp = lookupObjectFieldAttr(bundle, objectName, name, 'help', opts); - if (translatedHelp) { - const servedInlineHelp = authorsInlineHelpText(def); - if (servedInlineHelp) { - if (!valueOverridesPackagedBase(packagedField, 'inlineHelpText', def.inlineHelpText)) { - next.inlineHelpText = translatedHelp; - } - } else if ( - !authorsInlineHelpText(packagedField) && - !valueOverridesPackagedBase(packagedField, 'description', def.description) - ) { - next.description = translatedHelp; - } - } - // The inline child grid's heading, under the same divergence rule. The - // console's inline-subform pass copies the served value onto the parent's - // form, so this is the one place it is localized. An untranslated heading - // keeps its authored text — never the child's plural label in its place. - if (!valueOverridesPackagedBase(packagedField, 'inlineTitle', def.inlineTitle)) { - const translatedInlineTitle = lookupObjectFieldAttr(bundle, objectName, name, 'inlineTitle', opts); - if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; + // `help`. + if (authorsInlineHelpText(def)) { + const translatedHelp = lookupObjectFieldAttr( + slotCatalog('inlineHelpText', def.inlineHelpText), objectName, name, 'help', opts, + ); + if (translatedHelp) next.inlineHelpText = translatedHelp; + } else if (!authorsInlineHelpText(packagedField)) { + const translatedHelp = lookupObjectFieldAttr( + slotCatalog('description', def.description), objectName, name, 'help', opts, + ); + if (translatedHelp) next.description = translatedHelp; } + // The inline child grid's heading, under the same rule. The console's + // inline-subform pass copies the served value onto the parent's form, so + // this is the one place it is localized. An untranslated heading keeps its + // authored text — never the child's plural label in its place. + const translatedInlineTitle = lookupObjectFieldAttr( + slotCatalog('inlineTitle', def.inlineTitle), objectName, name, 'inlineTitle', opts, + ); + if (translatedInlineTitle) next.inlineTitle = translatedInlineTitle; // The related list's title on the parent's record page, under the same // rule: the console derives the list from the served child field, so this // is the one place it is localized, and an untranslated title keeps its // authored text. - if (!valueOverridesPackagedBase(packagedField, 'relatedListTitle', def.relatedListTitle)) { - const translatedRelatedListTitle = lookupObjectFieldAttr(bundle, objectName, name, 'relatedListTitle', opts); - if (translatedRelatedListTitle) next.relatedListTitle = translatedRelatedListTitle; - } + const translatedRelatedListTitle = lookupObjectFieldAttr( + slotCatalog('relatedListTitle', def.relatedListTitle), objectName, name, 'relatedListTitle', opts, + ); + if (translatedRelatedListTitle) next.relatedListTitle = translatedRelatedListTitle; if (Array.isArray(def.options)) { // A picklist-bound field is served with its list's options resolved // onto it (`PicklistServedFieldSchema`), and INHERITS the list's option @@ -4578,10 +4962,10 @@ export function translateObject( 'options', (candidate) => isOptionValue(candidate.value) && String(candidate.value) === String(opt.value), ); - if (valueOverridesPackagedBase(packagedOption, 'label', opt.label)) return opt; + const catalog = catalogFor(catalogs, valueOverridesPackagedBase(packagedOption, 'label', opt.label)); const translated = - lookupObjectFieldOption(bundle, objectName, name, opt.value, opts) ?? - (picklist !== undefined ? lookupPicklistOption(bundle, picklist, opt.value, opts) : undefined); + lookupObjectFieldOption(catalog, objectName, name, opt.value, opts) ?? + (picklist !== undefined ? lookupPicklistOption(catalog, picklist, opt.value, opts) : undefined); return translated ? { ...opt, label: translated } : opt; }); } @@ -4616,7 +5000,7 @@ export function translateObject( }) : undefined; - const listViews = translateObjectListViews(doc.listViews, bundle, objectName, opts); + const listViews = translateObjectListViews(doc.listViews, catalogs, objectName, opts); const authoredValidations = (doc as { validations?: unknown }).validations; const validations = translateObjectValidations(authoredValidations, bundle, objectName, opts);