diff --git a/.changeset/great-poems-repeat.md b/.changeset/great-poems-repeat.md new file mode 100644 index 0000000000..4a3e3ac5be --- /dev/null +++ b/.changeset/great-poems-repeat.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +`translatePage` now resolves copy for components nested in a container's declared `properties.children` array, recursively — previously it visited region-level components only, so copy authored under `pages..components.` for a nested id parsed happily and was never applied (measured: four KPI labels nested in a `page:card` stayed English on an otherwise fully translated page). + +The published translation face does not change; the resolver widens to match the face it already accepted. When an id appears more than once, a region-level component carrying it wins outright, and among nested components the document-order first match takes the entry. The descent follows `children` only — `body` remains a renderer-side back-compat spelling, not an authoring key — and is depth-capped and cycle-safe, since `children` is authored data. diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index f6e8d971fb..7983773550 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -1224,6 +1224,386 @@ describe('translatePage', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// #12961 — the descent into a container's declared `properties.children` +// +// Ruled 2026-08-29 (maintainer, verbatim 「同意」, option A): `translatePage` +// descends into DECLARED `properties.children` arrays, so copy authored for a +// nested component id is resolved; the region-only boundary that stood at +// `i18n-resolver.ts:944-946` is deliberately reversed. Collision rule fixed at +// the ruling: region-level id WINS; among nested matches, document-order first. +// Recursion depth-guarded and cycle-safe — `children` is authored data. The +// published face does not move: `pages..components.` has ALWAYS +// parsed copy for a nested id, so this widens the resolver to the face. +// +// Shape is the measured hotCRM one: `sales_home_page`'s `key_metrics` card +// holds the four `object-metric` KPI blocks in its `properties.children`, and +// their labels stayed English while the header and card titles translated. +// ──────────────────────────────────────────────────────────────────────────── + +describe('translatePage — nested `properties.children` descent (#12961)', () => { + /** + * Fixtures are typed through these open shapes instead of being left to + * literal inference. `translatePage` returns `T`, so an + * inferred literal type carries only the keys the LITERAL spells — reading + * back a key the overlay ADDS, or a `children` array a sibling branch of the + * union does not declare, is then a type error rather than a test. Same + * widening the #6080 block does inline (`as Record`), hoisted + * because this block builds a dozen fixtures. Written as type ALIASES so they + * keep the implicit index signature `PageLike` needs. + */ + type FixtureComponent = { type?: string; id?: string; label?: string; properties: Record }; + type FixturePage = { + name: string; + label?: string; + regions: Array<{ name: string; components: FixtureComponent[] }>; + }; + + const kpiBundle: TranslationBundle = { + 'zh-CN': { + pages: { + sales_home_page: { + label: '销售看板', + components: { + key_metrics: { title: '关键指标' }, + kpi_revenue_won: { label: '已赢收入' }, + kpi_deals_won: { label: '赢单数' }, + kpi_pipeline_value: { label: '管道金额' }, + kpi_open_leads: { label: '未处理线索' }, + }, + }, + }, + }, + }; + + /** The hotCRM `sales_home_page` shape: KPI blocks nested in a card. */ + const kpiPage = (): FixturePage => ({ + name: 'sales_home_page', + label: 'Sales Home', + regions: [{ + name: 'main', + components: [ + { + type: 'page:card', + id: 'key_metrics', + properties: { + title: 'Key Metrics', + children: [ + { type: 'object-metric', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)' } }, + { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }, + { type: 'object-metric', id: 'kpi_pipeline_value', properties: { label: 'Pipeline Value' } }, + { type: 'object-metric', id: 'kpi_open_leads', properties: { label: 'Open Leads' } }, + ], + }, + }, + ], + }], + }); + + const card = (doc: any) => doc.regions[0].components[0]; + const kid = (doc: any, id: string) => + card(doc).properties.children.find((c: any) => c?.id === id); + + it('resolves copy for every KPI block nested in the card (the measured residual)', () => { + const out = translatePage(kpiPage(), kpiBundle, { locale: 'zh-CN' }); + expect(kid(out, 'kpi_revenue_won').properties.label).toBe('已赢收入'); + expect(kid(out, 'kpi_deals_won').properties.label).toBe('赢单数'); + expect(kid(out, 'kpi_pipeline_value').properties.label).toBe('管道金额'); + expect(kid(out, 'kpi_open_leads').properties.label).toBe('未处理线索'); + }); + + it('still translates the containing card and the page itself', () => { + const out = translatePage(kpiPage(), kpiBundle, { locale: 'zh-CN' }); + expect(out.label).toBe('销售看板'); + expect(card(out).properties.title).toBe('关键指标'); + }); + + it('descends recursively, not one level', () => { + const doc: FixturePage = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [{ + type: 'page:section', + properties: { + children: [{ + type: 'page:card', + id: 'key_metrics', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }, + }], + }, + }], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + const section = out.regions[0].components[0]; + expect(section.properties.children[0].properties.title).toBe('关键指标'); + expect(section.properties.children[0].properties.children[0].properties.label).toBe('赢单数'); + }); + + it('does not mutate the input page', () => { + const doc = kpiPage(); + const snapshot = JSON.parse(JSON.stringify(doc)); + translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + expect(doc).toEqual(snapshot); + }); + + it('leaves non-component `children` entries — bare id strings — as they are', () => { + // `children` is declared `z.array(z.unknown())`; a bare component-id string + // is a legal entry and must ride through untouched, not be spread into an + // object. + const doc = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [{ + type: 'page:card', + id: 'key_metrics', + properties: { children: ['kpi_revenue_won', null, 42, { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }, + }], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + expect(card(out).properties.children.slice(0, 3)).toEqual(['kpi_revenue_won', null, 42]); + expect(card(out).properties.children[3].properties.label).toBe('赢单数'); + }); + + // ── The boundary the ruling drew: `children`, and only `children` ──────── + describe('the descended slot', () => { + const nestedUnder = (slotProps: Record): FixturePage => ({ + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [{ type: 'page:card', id: 'key_metrics', properties: slotProps }], + }], + }); + + it('does not descend into `body` — the back-compat spelling is not authorable (#5775)', () => { + // The renderers read `schema.children || schema.body` for STORED + // documents, but `body` is deliberately NOT a declared authoring key. + // Descending into it would resurrect a second composition spelling. + const out = translatePage( + nestedUnder({ body: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }), + kpiBundle, + { locale: 'zh-CN' }, + ); + expect((card(out).properties.body as any[])[0].properties.label).toBe('Deals Won'); + }); + + it('does not descend into `items[].children` — outside the ruled `properties.children` face', () => { + // `page:tabs` / `page:accordion` nest their children one level deeper, + // under `properties.items[].children`. The ruling names + // `properties.children`; widening further is its own contract call, so + // this records where the line is rather than silently crossing it. + const out = translatePage( + nestedUnder({ items: [{ label: 'Details', children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }] }), + kpiBundle, + { locale: 'zh-CN' }, + ); + expect((card(out).properties.items as any[])[0].children[0].properties.label).toBe('Deals Won'); + }); + + it('keeps the page-name header route region-level', () => { + // `pages..{title,subtitle}` addresses THE page header. A + // `page:header` nested inside a container is not it — nested components + // are reached by the id route only. + const out = translatePage( + nestedUnder({ children: [{ type: 'page:header', properties: { title: 'Sales Home' } }] }), + kpiBundle, + { locale: 'zh-CN' }, + ); + expect((card(out).properties.children as any[])[0].properties.title).toBe('Sales Home'); + }); + }); + + // ── The ruled collision rule, both directions ─────────────────────────── + describe('id collisions across nesting levels', () => { + it('gives the entry to the REGION-LEVEL component when a nested id repeats it', () => { + const doc: FixturePage = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [ + { + type: 'page:card', + id: 'shell', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Nested Deals Won' } }] }, + }, + { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Region Deals Won' } }, + ], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + const [shell, regionLevel] = out.regions[0].components; + expect(regionLevel.properties.label).toBe('赢单数'); + // …and the nested namesake keeps its literal — one entry, one winner. + expect(shell.properties.children[0].properties.label).toBe('Nested Deals Won'); + }); + + it('wins region-level even when the region-level namesake comes LAST in a later region', () => { + // The rule is level-priority, not document order: a nested component + // that appears first in the document still loses to a region-level id. + const doc: FixturePage = { + name: 'sales_home_page', + regions: [ + { + name: 'main', + components: [{ + type: 'page:card', + id: 'shell', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Nested Deals Won' } }] }, + }], + }, + { name: 'aside', components: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Region Deals Won' } }] }, + ], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + expect(out.regions[1].components[0].properties.label).toBe('赢单数'); + expect(out.regions[0].components[0].properties.children[0].properties.label).toBe('Nested Deals Won'); + }); + + it('gives it to the DOCUMENT-ORDER FIRST nested match when no region-level id claims it', () => { + const doc: FixturePage = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [ + { + type: 'page:card', + id: 'first_shell', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'First Deals Won' } }] }, + }, + { + type: 'page:card', + id: 'second_shell', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Second Deals Won' } }] }, + }, + ], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + const [first, second] = out.regions[0].components; + expect(first.properties.children[0].properties.label).toBe('赢单数'); + expect(second.properties.children[0].properties.label).toBe('Second Deals Won'); + }); + + it('reads document order depth-first — a deeper earlier match beats a shallower later one', () => { + const doc: FixturePage = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [ + { + type: 'page:card', + id: 'first_shell', + properties: { + children: [{ + type: 'page:section', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deep First' } }] }, + }], + }, + }, + { + type: 'page:card', + id: 'second_shell', + properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Shallow Second' } }] }, + }, + ], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + const [first, second] = out.regions[0].components; + expect(first.properties.children[0].properties.children[0].properties.label).toBe('赢单数'); + expect(second.properties.children[0].properties.label).toBe('Shallow Second'); + }); + }); + + // ── Depth guard / cycle safety — `children` is authored data ───────────── + describe('depth guard and cycle safety', () => { + /** + * The resolver descends at most this many levels below region level. + * Module-private in `i18n-resolver.ts` (`MAX_NESTED_COMPONENT_DEPTH`) so + * the guard adds no public API; this literal is the PIN — raise the cap + * there and this test reds, which is the point. + */ + const CAP = 32; + + /** + * A leaf carrying `leafId` wrapped in `depth` containers — so the leaf + * sits exactly `depth` levels BELOW region level (`depth: 0` would make + * the leaf itself the region-level component). + */ + const chain = (depth: number, leafId: string): FixturePage => { + let node: any = { type: 'object-metric', id: leafId, properties: { label: 'Deals Won' } }; + for (let i = 0; i < depth; i++) { + node = { type: 'page:section', properties: { children: [node] } }; + } + return { + name: 'sales_home_page', + regions: [{ name: 'main', components: [node] }], + }; + }; + + const leafOf = (doc: any) => { + let node = doc.regions[0].components[0]; + while (node?.properties?.children) node = node.properties.children[0]; + return node; + }; + + it(`translates a component nested exactly ${CAP} levels below region level`, () => { + const out = translatePage(chain(CAP, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' }); + expect(leafOf(out).properties.label).toBe('赢单数'); + }); + + it(`stops at the cap — a component ${CAP + 1} levels down keeps its literal`, () => { + const out = translatePage(chain(CAP + 1, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' }); + expect(leafOf(out).properties.label).toBe('Deals Won'); + }); + + it('returns rather than recursing forever on an absurdly deep tree', () => { + // Deep enough that an unguarded walk is a stack-overflow risk; the guard + // makes the call finite whatever the document does. + const out = translatePage(chain(200_000, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' }); + expect(out.label).toBe('销售看板'); + }); + + it('survives a component whose `children` contains itself', () => { + const selfRef: any = { type: 'page:card', id: 'key_metrics', properties: { title: 'Key Metrics', children: [] as unknown[] } }; + selfRef.properties.children.push(selfRef); + const doc = { name: 'sales_home_page', regions: [{ name: 'main', components: [selfRef] }] }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.title).toBe('关键指标'); + }); + + it('survives a two-node cycle', () => { + const a: any = { type: 'page:card', id: 'key_metrics', properties: { title: 'Key Metrics', children: [] as unknown[] } }; + const b: any = { type: 'page:section', properties: { children: [a] } }; + a.properties.children.push(b); + const doc = { name: 'sales_home_page', regions: [{ name: 'main', components: [a] }] }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.title).toBe('关键指标'); + }); + + it('translates a subtree that is REFERENCED twice without treating it as a cycle', () => { + // Shared references are not cycles: the second sighting is a legitimate + // second component, and the collision rule (not the cycle guard) decides + // that only the first gets the entry. + const shared: any = { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }; + const doc = { + name: 'sales_home_page', + regions: [{ + name: 'main', + components: [{ type: 'page:card', id: 'key_metrics', properties: { children: [shared, shared] } }], + }], + }; + const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' }); + const children = card(out).properties.children; + expect(children[0].properties.label).toBe('赢单数'); + expect(children[1].properties.label).toBe('Deals Won'); + }); + }); +}); + // ──────────────────────────────────────────────────────────────────────────── // #5377 — filter-preset tab labels (`objects.._tabs..label`) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 59de0b11c7..0e2db5f366 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -922,6 +922,32 @@ function lookupPageComponentCopy( return found; } +/** + * How many levels of `properties.children` nesting `translatePage` descends + * below region level (#12961). Authored page trees run three or four deep in + * practice, so the cap is not a limit any real document meets — it exists + * because `children` is authored data, and a resolver that never throws must + * still be finite on a pathological one. + * + * Paired with, not a substitute for, the ancestor-path cycle guard in + * {@link translatePage}: the cycle guard catches a subtree that contains + * itself, the cap catches one that is merely absurd. Deliberately NOT + * exported — the guard is a safety property of the walk, not a contract + * consumers address, and this card widens the resolver without widening any + * published face. The pin lives in `i18n-resolver.test.ts`, which restates + * this number so raising it here reds there. + */ +const MAX_NESTED_COMPONENT_DEPTH = 32; + +/** + * The id a component is addressed by, or `undefined` when it carries none. + * Tolerates non-object entries because `children` is `z.array(z.unknown())`. + */ +function pageComponentId(component: PageComponentLike | undefined): string | undefined { + if (!component || typeof component !== 'object') return undefined; + return typeof component.id === 'string' && component.id.length > 0 ? component.id : undefined; +} + /** * Apply the active locale to a page metadata document — translates the page's * own `label` / `description` and the `properties.title` / `properties.subtitle` @@ -941,9 +967,35 @@ function lookupPageComponentCopy( * two, it wins wherever both could apply (a `page:header` that does carry an * id). * - * Only region-level components are visited: `page:header` is a top-level - * layout block by convention, and components nested inside another component's - * `properties` (tabs, sections) are untyped free-form props. + * Components nested in a container's declared `properties.children` array are + * visited too, recursively (#12961, ruled 2026-08-29). This REVERSES the + * region-only boundary that stood here — "components nested inside another + * component's `properties` are untyped free-form props" — which had made the + * resolver narrower than the face it serves: `pages..components.` + * has always PARSED copy for a nested id, so an author could write copy that + * nothing would ever apply (measured: hotCRM's four KPI labels, nested in a + * `page:card`'s children, stayed English on a fully translated page). The + * ruling widens the resolver to the published face rather than narrowing a + * released face. + * + * `children` is the ONLY slot descended — it is the one composition key + * (#5775). `body` is a renderer-side back-compat fallback for stored + * documents, not an authorable spelling, so descending it would resurrect a + * second spelling; `properties.items[].children` (`page:tabs`, + * `page:accordion`) sits one level deeper than the slot the ruling names and + * is left for its own contract call. + * + * Nested components are reached by the **id route only**. The page-name header + * route addresses THE page's header, and a `page:header` nested inside a + * container is not it. + * + * When one id appears more than once, the ruling fixes the winner: a + * region-level component carrying it WINS outright, and among nested + * components the document-order (depth-first) FIRST match takes it — one + * bundle entry, one component. The descent is depth-capped + * ({@link MAX_NESTED_COMPONENT_DEPTH}) and cycle-safe, because `children` is + * authored data and a resolver must not hang or blow the stack on a document + * that contains itself. * * A list page's filter-preset tab bar * (`interfaceConfig.userFilters.tabs[].label`) is translated too, against @@ -966,16 +1018,59 @@ export function translatePage( ?? lookupPageAttr(bundle, name, 'label', opts); const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts); - const translateComponent = (component: PageComponentLike): PageComponentLike => { - if (!component || typeof component !== 'object') return component; + // Collision arbitration, pass 1 (#12961): every id carried by a REGION-LEVEL + // component. The ruling makes region level the outright winner when an id + // repeats across levels, so the whole set has to be known before the descent + // visits its first nested component — a region-level namesake in a LATER + // region still beats a nested match seen earlier. + const regionLevelIds = new Set(); + if (Array.isArray(doc.regions)) { + for (const region of doc.regions) { + if (!region || typeof region !== 'object' || !Array.isArray(region.components)) continue; + for (const component of region.components) { + const id = pageComponentId(component); + if (id !== undefined) regionLevelIds.add(id); + } + } + } + + // Pass 2's ledger: ids already taken by an earlier nested component, so the + // document-order first match keeps the entry. Claimed on first SIGHTING + // rather than on a resolved lookup — within one call `lookupPageComponentCopy` + // is a pure function of the id (bundle, page name and options are fixed), so + // "first nested match" and "first nested sighting" select the same component + // and the cheaper test is the honest one. + const claimedNestedIds = new Set(); + + // The current descent path, for the cycle guard. Ancestors only — a subtree + // referenced twice as a SIBLING is two legitimate components, not a cycle, + // and the collision ledger above is what decides between them. + const ancestors = new Set(); + + const translateComponent = (component: PageComponentLike, depth: number): PageComponentLike => { + // `children` is declared `z.array(z.unknown())`: bare component-id strings + // and other non-component values are legal entries, and an array is not a + // component — spreading one would rewrite it into an object. + if (!component || typeof component !== 'object' || Array.isArray(component)) return component; + // Cycle guard: this node is already an ancestor of itself. + if (ancestors.has(component)) return component; + + const nested = depth > 0; + const id = pageComponentId(component); // Per-component copy (#6080) — addressed by the component's own id, so it // is strictly more specific than the page-name route below and is applied // first. A `page:header` that DOES carry an id can therefore be translated // either way, and the id wins. - const copy = typeof component.id === 'string' && component.id.length > 0 - ? lookupPageComponentCopy(bundle, name, component.id, opts) - : undefined; + // + // At nesting level the ruled collision rule (#12961) arbitrates first: a + // region-level component holding this id takes the entry outright, and + // among nested components the first in document order takes it. + let copy: Partial> | undefined; + if (id !== undefined && (!nested || (!regionLevelIds.has(id) && !claimedNestedIds.has(id)))) { + if (nested) claimedNestedIds.add(id); + copy = lookupPageComponentCopy(bundle, name, id, opts); + } let next = component; if (copy) { @@ -997,6 +1092,17 @@ export function translatePage( }; } + // Descend into the declared composition slot (#12961). Applied AFTER the + // copy overlay so the translated children survive the props spread above; + // the two never contend for a key, since `children` is not a copy key. + const children = translateChildren(component, depth); + if (children !== undefined) { + next = { ...next, properties: { ...next.properties, children } }; + } + + // The page-name header route addresses THE page's header, so it stops at + // region level — nested components are reached by the id route only. + if (nested) return next; if (next.type !== PAGE_HEADER_COMPONENT) return next; if (headerTitle === undefined && headerSubtitle === undefined) return next; return { @@ -1011,10 +1117,29 @@ export function translatePage( }; }; + /** + * The component's translated `properties.children`, or `undefined` when + * there is nothing to descend into — so a component without the slot is + * returned untouched rather than gaining an invented `properties` bag. + */ + const translateChildren = (component: PageComponentLike, depth: number): unknown[] | undefined => { + if (depth >= MAX_NESTED_COMPONENT_DEPTH) return undefined; + const props = component.properties; + if (!props || typeof props !== 'object' || Array.isArray(props)) return undefined; + const children = (props as Record).children; + if (!Array.isArray(children)) return undefined; + ancestors.add(component); + try { + return children.map((child) => translateComponent(child as PageComponentLike, depth + 1)); + } finally { + ancestors.delete(component); + } + }; + const regions = Array.isArray(doc.regions) ? doc.regions.map((region) => { if (!region || typeof region !== 'object' || !Array.isArray(region.components)) return region; - return { ...region, components: region.components.map(translateComponent) }; + return { ...region, components: region.components.map((c) => translateComponent(c, 0)) }; }) : doc.regions;