Skip to content

Commit 901355c

Browse files
os-trumpclaude
andauthored
fix(spec): translatePage resolves copy for components nested in properties.children (#13111)
* fix(spec): translatePage descends into declared properties.children `translatePage` visited region-level components only, so copy authored under `pages.<name>.components.<id>` for a component nested in another component's `properties.children` parsed against the published face and was never applied — the accepted-but-inert shape, measured as four KPI labels staying English inside a translated `page:card`. Ruled 2026-08-29 (option A): the resolver descends into declared `properties.children` arrays, reversing the documented region-only boundary and widening the resolver to the face already published. On an id collision a region-level component wins outright; among nested matches the document-order first takes the entry. The descent follows `children` only, and is depth-capped and cycle-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 * test(spec): type the nested-descent fixtures so the debt ratchet stays flat `translatePage<T extends PageLike>` returns `T`, so literal fixtures make reading back an overlaid key a type error. Widening the fixtures through one shared alias keeps packages/spec's test-typecheck debt at its recorded 11 for this file instead of growing it by 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 225e769 commit 901355c

3 files changed

Lines changed: 521 additions & 9 deletions

File tree

.changeset/great-poems-repeat.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`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.<name>.components.<id>` 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).
6+
7+
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.

packages/spec/src/system/i18n-resolver.test.ts

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,386 @@ describe('translatePage', () => {
12241224
});
12251225
});
12261226

1227+
// ────────────────────────────────────────────────────────────────────────────
1228+
// #12961 — the descent into a container's declared `properties.children`
1229+
//
1230+
// Ruled 2026-08-29 (maintainer, verbatim 「同意」, option A): `translatePage`
1231+
// descends into DECLARED `properties.children` arrays, so copy authored for a
1232+
// nested component id is resolved; the region-only boundary that stood at
1233+
// `i18n-resolver.ts:944-946` is deliberately reversed. Collision rule fixed at
1234+
// the ruling: region-level id WINS; among nested matches, document-order first.
1235+
// Recursion depth-guarded and cycle-safe — `children` is authored data. The
1236+
// published face does not move: `pages.<name>.components.<id>` has ALWAYS
1237+
// parsed copy for a nested id, so this widens the resolver to the face.
1238+
//
1239+
// Shape is the measured hotCRM one: `sales_home_page`'s `key_metrics` card
1240+
// holds the four `object-metric` KPI blocks in its `properties.children`, and
1241+
// their labels stayed English while the header and card titles translated.
1242+
// ────────────────────────────────────────────────────────────────────────────
1243+
1244+
describe('translatePage — nested `properties.children` descent (#12961)', () => {
1245+
/**
1246+
* Fixtures are typed through these open shapes instead of being left to
1247+
* literal inference. `translatePage<T extends PageLike>` returns `T`, so an
1248+
* inferred literal type carries only the keys the LITERAL spells — reading
1249+
* back a key the overlay ADDS, or a `children` array a sibling branch of the
1250+
* union does not declare, is then a type error rather than a test. Same
1251+
* widening the #6080 block does inline (`as Record<string, string>`), hoisted
1252+
* because this block builds a dozen fixtures. Written as type ALIASES so they
1253+
* keep the implicit index signature `PageLike` needs.
1254+
*/
1255+
type FixtureComponent = { type?: string; id?: string; label?: string; properties: Record<string, any> };
1256+
type FixturePage = {
1257+
name: string;
1258+
label?: string;
1259+
regions: Array<{ name: string; components: FixtureComponent[] }>;
1260+
};
1261+
1262+
const kpiBundle: TranslationBundle = {
1263+
'zh-CN': {
1264+
pages: {
1265+
sales_home_page: {
1266+
label: '销售看板',
1267+
components: {
1268+
key_metrics: { title: '关键指标' },
1269+
kpi_revenue_won: { label: '已赢收入' },
1270+
kpi_deals_won: { label: '赢单数' },
1271+
kpi_pipeline_value: { label: '管道金额' },
1272+
kpi_open_leads: { label: '未处理线索' },
1273+
},
1274+
},
1275+
},
1276+
},
1277+
};
1278+
1279+
/** The hotCRM `sales_home_page` shape: KPI blocks nested in a card. */
1280+
const kpiPage = (): FixturePage => ({
1281+
name: 'sales_home_page',
1282+
label: 'Sales Home',
1283+
regions: [{
1284+
name: 'main',
1285+
components: [
1286+
{
1287+
type: 'page:card',
1288+
id: 'key_metrics',
1289+
properties: {
1290+
title: 'Key Metrics',
1291+
children: [
1292+
{ type: 'object-metric', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)' } },
1293+
{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } },
1294+
{ type: 'object-metric', id: 'kpi_pipeline_value', properties: { label: 'Pipeline Value' } },
1295+
{ type: 'object-metric', id: 'kpi_open_leads', properties: { label: 'Open Leads' } },
1296+
],
1297+
},
1298+
},
1299+
],
1300+
}],
1301+
});
1302+
1303+
const card = (doc: any) => doc.regions[0].components[0];
1304+
const kid = (doc: any, id: string) =>
1305+
card(doc).properties.children.find((c: any) => c?.id === id);
1306+
1307+
it('resolves copy for every KPI block nested in the card (the measured residual)', () => {
1308+
const out = translatePage(kpiPage(), kpiBundle, { locale: 'zh-CN' });
1309+
expect(kid(out, 'kpi_revenue_won').properties.label).toBe('已赢收入');
1310+
expect(kid(out, 'kpi_deals_won').properties.label).toBe('赢单数');
1311+
expect(kid(out, 'kpi_pipeline_value').properties.label).toBe('管道金额');
1312+
expect(kid(out, 'kpi_open_leads').properties.label).toBe('未处理线索');
1313+
});
1314+
1315+
it('still translates the containing card and the page itself', () => {
1316+
const out = translatePage(kpiPage(), kpiBundle, { locale: 'zh-CN' });
1317+
expect(out.label).toBe('销售看板');
1318+
expect(card(out).properties.title).toBe('关键指标');
1319+
});
1320+
1321+
it('descends recursively, not one level', () => {
1322+
const doc: FixturePage = {
1323+
name: 'sales_home_page',
1324+
regions: [{
1325+
name: 'main',
1326+
components: [{
1327+
type: 'page:section',
1328+
properties: {
1329+
children: [{
1330+
type: 'page:card',
1331+
id: 'key_metrics',
1332+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] },
1333+
}],
1334+
},
1335+
}],
1336+
}],
1337+
};
1338+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1339+
const section = out.regions[0].components[0];
1340+
expect(section.properties.children[0].properties.title).toBe('关键指标');
1341+
expect(section.properties.children[0].properties.children[0].properties.label).toBe('赢单数');
1342+
});
1343+
1344+
it('does not mutate the input page', () => {
1345+
const doc = kpiPage();
1346+
const snapshot = JSON.parse(JSON.stringify(doc));
1347+
translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1348+
expect(doc).toEqual(snapshot);
1349+
});
1350+
1351+
it('leaves non-component `children` entries — bare id strings — as they are', () => {
1352+
// `children` is declared `z.array(z.unknown())`; a bare component-id string
1353+
// is a legal entry and must ride through untouched, not be spread into an
1354+
// object.
1355+
const doc = {
1356+
name: 'sales_home_page',
1357+
regions: [{
1358+
name: 'main',
1359+
components: [{
1360+
type: 'page:card',
1361+
id: 'key_metrics',
1362+
properties: { children: ['kpi_revenue_won', null, 42, { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] },
1363+
}],
1364+
}],
1365+
};
1366+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1367+
expect(card(out).properties.children.slice(0, 3)).toEqual(['kpi_revenue_won', null, 42]);
1368+
expect(card(out).properties.children[3].properties.label).toBe('赢单数');
1369+
});
1370+
1371+
// ── The boundary the ruling drew: `children`, and only `children` ────────
1372+
describe('the descended slot', () => {
1373+
const nestedUnder = (slotProps: Record<string, unknown>): FixturePage => ({
1374+
name: 'sales_home_page',
1375+
regions: [{
1376+
name: 'main',
1377+
components: [{ type: 'page:card', id: 'key_metrics', properties: slotProps }],
1378+
}],
1379+
});
1380+
1381+
it('does not descend into `body` — the back-compat spelling is not authorable (#5775)', () => {
1382+
// The renderers read `schema.children || schema.body` for STORED
1383+
// documents, but `body` is deliberately NOT a declared authoring key.
1384+
// Descending into it would resurrect a second composition spelling.
1385+
const out = translatePage(
1386+
nestedUnder({ body: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }),
1387+
kpiBundle,
1388+
{ locale: 'zh-CN' },
1389+
);
1390+
expect((card(out).properties.body as any[])[0].properties.label).toBe('Deals Won');
1391+
});
1392+
1393+
it('does not descend into `items[].children` — outside the ruled `properties.children` face', () => {
1394+
// `page:tabs` / `page:accordion` nest their children one level deeper,
1395+
// under `properties.items[].children`. The ruling names
1396+
// `properties.children`; widening further is its own contract call, so
1397+
// this records where the line is rather than silently crossing it.
1398+
const out = translatePage(
1399+
nestedUnder({ items: [{ label: 'Details', children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } }] }] }),
1400+
kpiBundle,
1401+
{ locale: 'zh-CN' },
1402+
);
1403+
expect((card(out).properties.items as any[])[0].children[0].properties.label).toBe('Deals Won');
1404+
});
1405+
1406+
it('keeps the page-name header route region-level', () => {
1407+
// `pages.<name>.{title,subtitle}` addresses THE page header. A
1408+
// `page:header` nested inside a container is not it — nested components
1409+
// are reached by the id route only.
1410+
const out = translatePage(
1411+
nestedUnder({ children: [{ type: 'page:header', properties: { title: 'Sales Home' } }] }),
1412+
kpiBundle,
1413+
{ locale: 'zh-CN' },
1414+
);
1415+
expect((card(out).properties.children as any[])[0].properties.title).toBe('Sales Home');
1416+
});
1417+
});
1418+
1419+
// ── The ruled collision rule, both directions ───────────────────────────
1420+
describe('id collisions across nesting levels', () => {
1421+
it('gives the entry to the REGION-LEVEL component when a nested id repeats it', () => {
1422+
const doc: FixturePage = {
1423+
name: 'sales_home_page',
1424+
regions: [{
1425+
name: 'main',
1426+
components: [
1427+
{
1428+
type: 'page:card',
1429+
id: 'shell',
1430+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Nested Deals Won' } }] },
1431+
},
1432+
{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Region Deals Won' } },
1433+
],
1434+
}],
1435+
};
1436+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1437+
const [shell, regionLevel] = out.regions[0].components;
1438+
expect(regionLevel.properties.label).toBe('赢单数');
1439+
// …and the nested namesake keeps its literal — one entry, one winner.
1440+
expect(shell.properties.children[0].properties.label).toBe('Nested Deals Won');
1441+
});
1442+
1443+
it('wins region-level even when the region-level namesake comes LAST in a later region', () => {
1444+
// The rule is level-priority, not document order: a nested component
1445+
// that appears first in the document still loses to a region-level id.
1446+
const doc: FixturePage = {
1447+
name: 'sales_home_page',
1448+
regions: [
1449+
{
1450+
name: 'main',
1451+
components: [{
1452+
type: 'page:card',
1453+
id: 'shell',
1454+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Nested Deals Won' } }] },
1455+
}],
1456+
},
1457+
{ name: 'aside', components: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Region Deals Won' } }] },
1458+
],
1459+
};
1460+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1461+
expect(out.regions[1].components[0].properties.label).toBe('赢单数');
1462+
expect(out.regions[0].components[0].properties.children[0].properties.label).toBe('Nested Deals Won');
1463+
});
1464+
1465+
it('gives it to the DOCUMENT-ORDER FIRST nested match when no region-level id claims it', () => {
1466+
const doc: FixturePage = {
1467+
name: 'sales_home_page',
1468+
regions: [{
1469+
name: 'main',
1470+
components: [
1471+
{
1472+
type: 'page:card',
1473+
id: 'first_shell',
1474+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'First Deals Won' } }] },
1475+
},
1476+
{
1477+
type: 'page:card',
1478+
id: 'second_shell',
1479+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Second Deals Won' } }] },
1480+
},
1481+
],
1482+
}],
1483+
};
1484+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1485+
const [first, second] = out.regions[0].components;
1486+
expect(first.properties.children[0].properties.label).toBe('赢单数');
1487+
expect(second.properties.children[0].properties.label).toBe('Second Deals Won');
1488+
});
1489+
1490+
it('reads document order depth-first — a deeper earlier match beats a shallower later one', () => {
1491+
const doc: FixturePage = {
1492+
name: 'sales_home_page',
1493+
regions: [{
1494+
name: 'main',
1495+
components: [
1496+
{
1497+
type: 'page:card',
1498+
id: 'first_shell',
1499+
properties: {
1500+
children: [{
1501+
type: 'page:section',
1502+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deep First' } }] },
1503+
}],
1504+
},
1505+
},
1506+
{
1507+
type: 'page:card',
1508+
id: 'second_shell',
1509+
properties: { children: [{ type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Shallow Second' } }] },
1510+
},
1511+
],
1512+
}],
1513+
};
1514+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1515+
const [first, second] = out.regions[0].components;
1516+
expect(first.properties.children[0].properties.children[0].properties.label).toBe('赢单数');
1517+
expect(second.properties.children[0].properties.label).toBe('Shallow Second');
1518+
});
1519+
});
1520+
1521+
// ── Depth guard / cycle safety — `children` is authored data ─────────────
1522+
describe('depth guard and cycle safety', () => {
1523+
/**
1524+
* The resolver descends at most this many levels below region level.
1525+
* Module-private in `i18n-resolver.ts` (`MAX_NESTED_COMPONENT_DEPTH`) so
1526+
* the guard adds no public API; this literal is the PIN — raise the cap
1527+
* there and this test reds, which is the point.
1528+
*/
1529+
const CAP = 32;
1530+
1531+
/**
1532+
* A leaf carrying `leafId` wrapped in `depth` containers — so the leaf
1533+
* sits exactly `depth` levels BELOW region level (`depth: 0` would make
1534+
* the leaf itself the region-level component).
1535+
*/
1536+
const chain = (depth: number, leafId: string): FixturePage => {
1537+
let node: any = { type: 'object-metric', id: leafId, properties: { label: 'Deals Won' } };
1538+
for (let i = 0; i < depth; i++) {
1539+
node = { type: 'page:section', properties: { children: [node] } };
1540+
}
1541+
return {
1542+
name: 'sales_home_page',
1543+
regions: [{ name: 'main', components: [node] }],
1544+
};
1545+
};
1546+
1547+
const leafOf = (doc: any) => {
1548+
let node = doc.regions[0].components[0];
1549+
while (node?.properties?.children) node = node.properties.children[0];
1550+
return node;
1551+
};
1552+
1553+
it(`translates a component nested exactly ${CAP} levels below region level`, () => {
1554+
const out = translatePage(chain(CAP, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' });
1555+
expect(leafOf(out).properties.label).toBe('赢单数');
1556+
});
1557+
1558+
it(`stops at the cap — a component ${CAP + 1} levels down keeps its literal`, () => {
1559+
const out = translatePage(chain(CAP + 1, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' });
1560+
expect(leafOf(out).properties.label).toBe('Deals Won');
1561+
});
1562+
1563+
it('returns rather than recursing forever on an absurdly deep tree', () => {
1564+
// Deep enough that an unguarded walk is a stack-overflow risk; the guard
1565+
// makes the call finite whatever the document does.
1566+
const out = translatePage(chain(200_000, 'kpi_deals_won'), kpiBundle, { locale: 'zh-CN' });
1567+
expect(out.label).toBe('销售看板');
1568+
});
1569+
1570+
it('survives a component whose `children` contains itself', () => {
1571+
const selfRef: any = { type: 'page:card', id: 'key_metrics', properties: { title: 'Key Metrics', children: [] as unknown[] } };
1572+
selfRef.properties.children.push(selfRef);
1573+
const doc = { name: 'sales_home_page', regions: [{ name: 'main', components: [selfRef] }] };
1574+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1575+
expect(out.regions[0].components[0].properties.title).toBe('关键指标');
1576+
});
1577+
1578+
it('survives a two-node cycle', () => {
1579+
const a: any = { type: 'page:card', id: 'key_metrics', properties: { title: 'Key Metrics', children: [] as unknown[] } };
1580+
const b: any = { type: 'page:section', properties: { children: [a] } };
1581+
a.properties.children.push(b);
1582+
const doc = { name: 'sales_home_page', regions: [{ name: 'main', components: [a] }] };
1583+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1584+
expect(out.regions[0].components[0].properties.title).toBe('关键指标');
1585+
});
1586+
1587+
it('translates a subtree that is REFERENCED twice without treating it as a cycle', () => {
1588+
// Shared references are not cycles: the second sighting is a legitimate
1589+
// second component, and the collision rule (not the cycle guard) decides
1590+
// that only the first gets the entry.
1591+
const shared: any = { type: 'object-metric', id: 'kpi_deals_won', properties: { label: 'Deals Won' } };
1592+
const doc = {
1593+
name: 'sales_home_page',
1594+
regions: [{
1595+
name: 'main',
1596+
components: [{ type: 'page:card', id: 'key_metrics', properties: { children: [shared, shared] } }],
1597+
}],
1598+
};
1599+
const out = translatePage(doc, kpiBundle, { locale: 'zh-CN' });
1600+
const children = card(out).properties.children;
1601+
expect(children[0].properties.label).toBe('赢单数');
1602+
expect(children[1].properties.label).toBe('Deals Won');
1603+
});
1604+
});
1605+
});
1606+
12271607
// ────────────────────────────────────────────────────────────────────────────
12281608
// #5377 — filter-preset tab labels (`objects.<o>._tabs.<tab>.label`)
12291609
// ────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)