Skip to content

Commit eb7613c

Browse files
qq9340100claude
andauthored
feat(spec): translatable page component copy — pages.<name>.components.<id> (#6466)
* feat(spec): translatable page component copy — pages.<name>.components.<id> (#6080) A page's cards, KPI blocks, pickers and forms had no translation key at all, while the near-identically-shaped dashboards.<name>.widgets.<id> has carried title/description all along. Page components have stable ids exactly like a widget does, so the bundle had nowhere to put their copy and .strict() (correctly) refused whatever key a translator invented. Declared and resolved together: translatePage overlays the entry onto the component's properties, so the face is not a declaration waiting for a reader, and the CLI extractor writes the same keys into the skeleton bundle so a translator can discover them. One exported key list (PAGE_COMPONENT_COPY_KEYS) drives schema, resolver and extractor, pinned by a test — two hand-maintained copies would drift into offering a slot nothing reads. The key face is measured against ComponentPropsMap rather than mirrored from the issue's sketch: `help` is NOT declared (no component has it — it would parse clean and translate nothing, ADR-0078) and `subtitle` is NOT declared (page:header is its only declarer and is addressed by page name, so a second route would give one string two spellings). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3U9PJm1hEJitS9LtZz8TC * fix(example-showcase): translate newly-declared page component copy for zh-CN pages.<name>.components made page component copy declared surface, so check-i18n-coverage's frozen ratchet saw app-showcase grow 451 → 462 untranslated strings. Translate the 11 new keys at birth (contact-form fields + submit, page-variables project picker); the pages' own label/title/subtitle predate the ratchet and stay in the frozen baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2bd4e5e commit eb7613c

10 files changed

Lines changed: 498 additions & 12 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): page component copy is translatable — `pages.<name>.components.<id>` (#6080)
6+
7+
A page's cards, KPI blocks, pickers and forms had **no translation key at all**.
8+
Not a drifted key — no key: `pages` was a `.strict()` four-key record whose
9+
`title`/`subtitle` mean the page's `page:header`, so every other component's
10+
user-visible string reached the user as whatever literal the `*.page.ts` author
11+
typed, in every locale, and `.strict()` (correctly) refused the keys a
12+
translator invented.
13+
14+
The asymmetry was the giveaway: `dashboards.<name>.widgets.<widgetId>` has
15+
carried `title`/`description` all along, and a page's components have stable
16+
`id`s exactly like a widget does. Downstream, hotcrm's `sales_home_page` — the
17+
`isDefault` landing page for sales reps — rendered a translated header above
18+
four English cards and four English KPI blocks in zh/ja/es (12 strings across 8
19+
pages).
20+
21+
```ts
22+
pages: {
23+
sales_home_page: {
24+
label: '销售看板',
25+
components: {
26+
quick_create: { title: '快速新建' },
27+
kpi_revenue_won: { label: '已赢收入' },
28+
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
29+
},
30+
},
31+
}
32+
```
33+
34+
**Declared AND resolved in the same change.** `translatePage`
35+
(`system/i18n-resolver.ts`) overlays the entry onto the component's
36+
`properties`, so the face is not a declaration waiting for a reader.
37+
38+
**The key face is measured against `ComponentPropsMap`, not mirrored from the
39+
issue's sketch**`title`, `description`, `label`, `placeholder`, `emptyText`,
40+
`submitLabel`, each one a copy prop some component actually declares as a plain
41+
string with no inline `{en, zh}` form, i.e. one whose only localization route is
42+
this bundle. Two deliberate exclusions:
43+
44+
- **`help` is not declared.** No component in the model has it; it would parse
45+
clean and translate nothing (ADR-0078). It is an alias onto `description`.
46+
- **`subtitle` is not declared.** `page:header` is its only declarer and is
47+
addressed by page name, so a per-component `subtitle` would give one string
48+
two spellings — which is how this asymmetry started.
49+
50+
Resolution rules, all tested: `label` lands on the component's own top-level
51+
`label` when it declares one and in `properties.label` otherwise (copy goes
52+
where the author wrote it); keys resolve **individually** across the locale
53+
chain, so a partially-translated `zh` entry still falls back to `en` per key;
54+
and the id-addressed route beats the page-name route wherever both could apply
55+
(a `page:header` that does carry an `id`).
56+
57+
Purely additive and `.strict()` is unchanged — `components` is optional, every
58+
previously-valid bundle still parses, and every previously-rejected key is still
59+
rejected.

content/docs/references/system/translation.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ Translation data for objects, apps, and UI messages
144144
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
145145
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Global action translations keyed by action name |
146146
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, { label?: string }>; widgets?: Record<string, { title?: string; description?: string }> }>` | optional | Dashboard translations keyed by dashboard name |
147-
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
147+
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
148148
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, { title?: string; description?: string }>; keys?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Settings manifest translations keyed by namespace |
149149
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
150150
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
@@ -197,7 +197,7 @@ One locale of translations — the `translation` metadata type
197197
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
198198
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Global action translations keyed by action name |
199199
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, { label?: string }>; widgets?: Record<string, { title?: string; description?: string }> }>` | optional | Dashboard translations keyed by dashboard name |
200-
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
200+
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
201201
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, { title?: string; description?: string }>; keys?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Settings manifest translations keyed by namespace |
202202
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
203203
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,4 @@ directory rather than per file.
273273
| `kernel/` | 319 |
274274
| `qa/` | 6 |
275275
| `shared/` | 20 |
276-
| `system/` | 366 |
276+
| `system/` | 367 |

examples/app-showcase/src/system/translations/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,5 +540,26 @@ export const ShowcaseTranslationBundle = {
540540
},
541541
},
542542
},
543+
// Page component copy became declared surface with `pages.<name>.components`
544+
// (#6080), so these keys are born under the ratchet: leaving any of them
545+
// untranslated widens the frozen baseline and fails `check-i18n-coverage`.
546+
// The pages' own label/title/subtitle predate the ratchet and stay in the
547+
// frozen baseline, same as Revenue Pulse's older widget titles above.
548+
pages: {
549+
showcase_contact_form: {
550+
components: {
551+
field_name: { label: '姓名', placeholder: '艾达·洛夫莱斯' },
552+
field_email: { label: '邮箱', placeholder: 'ada@example.com' },
553+
field_company: { label: '公司', placeholder: '分析机有限公司' },
554+
field_message: { label: '留言', placeholder: '我们能帮您什么?' },
555+
submit_inquiry: { label: '提交咨询' },
556+
},
557+
},
558+
showcase_page_variables: {
559+
components: {
560+
project_picker: { label: '项目', placeholder: '选择项目…' },
561+
},
562+
},
563+
},
543564
},
544565
};

packages/cli/src/utils/i18n-extract.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
* dashboards.<dash>.widgets.<w>.title / .description
5050
* pages.<page>.label / .description
5151
* pages.<page>.title / .subtitle (from the page's `page:header` component)
52+
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
5253
* metadataForms.<type>.label / .description
5354
* metadataForms.<type>.sections.<section>.label / .description
5455
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
@@ -63,7 +64,7 @@
6364
*/
6465

6566
import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
66-
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
67+
import { METADATA_FORM_REGISTRY, PAGE_COMPONENT_COPY_KEYS } from '@objectstack/spec/system';
6768
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
6869
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
6970
import { expandViewContainer } from '@objectstack/spec/ui';
@@ -752,6 +753,34 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
752753
}
753754
}
754755
}
756+
757+
// Per-component copy, addressed by the component's own id (#6080). Without
758+
// this pass the face exists but nothing writes the skeleton, so a
759+
// translator would have to know the keys to hand-write them — which is
760+
// most of the reason the copy went untranslated in the first place.
761+
//
762+
// `page:header` is deliberately skipped: its copy is addressed by page
763+
// name above, and emitting it here too would offer one string under two
764+
// keys.
765+
for (const region of regions) {
766+
const components: any[] = Array.isArray(region?.components) ? region.components : [];
767+
for (const component of components) {
768+
if (component?.type === 'page:header') continue;
769+
const id = component?.id;
770+
if (typeof id !== 'string' || !id) continue;
771+
const props = component.properties ?? {};
772+
for (const key of PAGE_COMPONENT_COPY_KEYS) {
773+
// `label` may be authored on the component itself or in its props —
774+
// the same either/or `translatePage` resolves back onto.
775+
const value = key === 'label' && typeof component.label === 'string' && component.label
776+
? component.label
777+
: props[key];
778+
if (typeof value === 'string' && value) {
779+
pushEntry(out, ['pages', name, 'components', id, key], value, 'page');
780+
}
781+
}
782+
}
783+
}
755784
}
756785

757786
// ── Object sections (fieldGroups + authored form/page sections) ───

packages/spec/api-surface/system.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@
464464
"OpenTelemetryCompatibilityParsed (type)",
465465
"OpenTelemetryCompatibilitySchema (const)",
466466
"OtelExporterType (type)",
467+
"PAGE_COMPONENT_COPY_KEYS (const)",
467468
"PKG_CONVENTIONS (const)",
468469
"PLATFORM_OBJECTS_BY_PACKAGE (const)",
469470
"PLATFORM_OBJECT_PREFIXES (const)",
@@ -477,6 +478,7 @@
477478
"PackageFile (type)",
478479
"PackagePublishResult (type)",
479480
"PackagePublishResultSchema (const)",
481+
"PageComponentCopyKey (type)",
480482
"PageComponentLike (interface)",
481483
"PageLike (interface)",
482484
"PageRegionLike (interface)",

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

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,147 @@ describe('translatePage', () => {
989989
const out = translatePage(page, bundle, { locale: 'zh' });
990990
expect(out.label).toBe('连接智能体');
991991
});
992+
993+
// ──────────────────────────────────────────────────────────────────────────
994+
// #6080 — per-component copy, keyed by component id
995+
//
996+
// Modelled on the reported downstream case: hotcrm's `sales_home_page`
997+
// rendered a translated header above English cards and English KPI blocks,
998+
// because `pages` had four keys and none of them could reach a component.
999+
// ──────────────────────────────────────────────────────────────────────────
1000+
describe('per-component copy (#6080)', () => {
1001+
const homeBundle: TranslationBundle = {
1002+
'zh-CN': {
1003+
pages: {
1004+
sales_home_page: {
1005+
label: '销售看板',
1006+
subtitle: '欢迎回来',
1007+
components: {
1008+
quick_create: { title: '快速新建' },
1009+
kpi_revenue_won: { label: '已赢收入' },
1010+
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
1011+
lead_picker: { placeholder: '搜索线索…', emptyText: '暂无记录' },
1012+
new_lead_form: { submitLabel: '创建' },
1013+
},
1014+
},
1015+
},
1016+
},
1017+
en: {
1018+
pages: {
1019+
sales_home_page: {
1020+
components: { ai_briefing: { title: 'Ask the AI Assistant', description: 'Open the assistant panel.' } },
1021+
},
1022+
},
1023+
},
1024+
};
1025+
1026+
const homePage = () => ({
1027+
name: 'sales_home_page',
1028+
label: 'Sales Home',
1029+
regions: [{
1030+
name: 'main',
1031+
components: [
1032+
{ type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } },
1033+
{ type: 'page:card', id: 'quick_create', properties: { title: 'Quick Create', icon: 'plus' } },
1034+
{ type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } },
1035+
{ type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } },
1036+
{ type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } },
1037+
{ type: 'element:form', id: 'new_lead_form', properties: { object: 'lead', submitLabel: 'Create' } },
1038+
{ type: 'page:card', id: 'untranslated_card', properties: { title: 'Still English' } },
1039+
],
1040+
}],
1041+
});
1042+
1043+
const byId = (doc: any, id: string) =>
1044+
doc.regions[0].components.find((c: any) => c.id === id);
1045+
1046+
it('translates card title, KPI label, and description by component id', () => {
1047+
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
1048+
expect(byId(out, 'quick_create').properties.title).toBe('快速新建');
1049+
expect(byId(out, 'kpi_revenue_won').properties.label).toBe('已赢收入');
1050+
expect(byId(out, 'ai_briefing').properties.title).toBe('询问 AI 助手');
1051+
expect(byId(out, 'ai_briefing').properties.description).toBe('从右侧边缘打开助手面板。');
1052+
});
1053+
1054+
it('covers the whole measured key face, not just title/description', () => {
1055+
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
1056+
expect(byId(out, 'lead_picker').properties.placeholder).toBe('搜索线索…');
1057+
expect(byId(out, 'lead_picker').properties.emptyText).toBe('暂无记录');
1058+
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('创建');
1059+
});
1060+
1061+
it('preserves non-copy properties alongside the overlay', () => {
1062+
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
1063+
expect(byId(out, 'quick_create').properties.icon).toBe('plus');
1064+
expect(byId(out, 'kpi_revenue_won').properties.value).toBe(42);
1065+
expect(byId(out, 'lead_picker').properties.object).toBe('lead');
1066+
});
1067+
1068+
it('leaves a component with no entry — and one with no id — untouched', () => {
1069+
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
1070+
expect(byId(out, 'untranslated_card').properties.title).toBe('Still English');
1071+
// The header carries no id, so only the page-name route applies to it.
1072+
expect(out.regions[0].components[0].properties.title).toBe('销售看板');
1073+
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
1074+
});
1075+
1076+
it("lands `label` on the component's own label slot when it declares one", () => {
1077+
// `PageComponentSchema` has BOTH a top-level `label` and an open
1078+
// `properties` bag; copy must land where the author actually wrote it.
1079+
const doc = {
1080+
name: 'sales_home_page',
1081+
regions: [{
1082+
name: 'main',
1083+
components: [{ type: 'element:button', id: 'kpi_revenue_won', label: 'Revenue (Won)', properties: {} }],
1084+
}],
1085+
};
1086+
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
1087+
expect(out.regions[0].components[0].label).toBe('已赢收入');
1088+
// …and does not invent a second spelling in the props bag.
1089+
expect(out.regions[0].components[0].properties).not.toHaveProperty('label');
1090+
});
1091+
1092+
it('resolves key by key across the locale chain, not entry by entry', () => {
1093+
// `zh-CN` translates only `title` for this id; `description` must still
1094+
// fall back to `en` rather than being dropped because the zh entry won.
1095+
const partial: TranslationBundle = {
1096+
'zh-CN': { pages: { sales_home_page: { components: { ai_briefing: { title: '询问 AI 助手' } } } } },
1097+
en: { pages: { sales_home_page: { components: { ai_briefing: { description: 'Open the assistant panel.' } } } } },
1098+
};
1099+
const out = translatePage(homePage(), partial, { locale: 'zh-CN' });
1100+
expect(byId(out, 'ai_briefing').properties.title).toBe('询问 AI 助手');
1101+
expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.');
1102+
});
1103+
1104+
it('lets the id-addressed route win over the page-name route on a header that has an id', () => {
1105+
const doc = {
1106+
name: 'sales_home_page',
1107+
regions: [{
1108+
name: 'header',
1109+
// `properties` widened to the open bag it is: the overlay adds keys
1110+
// the literal does not spell out, and inferring it as `{title}` alone
1111+
// would make reading the result a type error.
1112+
components: [{
1113+
type: 'page:header',
1114+
id: 'quick_create',
1115+
properties: { title: 'Sales Home' } as Record<string, string>,
1116+
}],
1117+
}],
1118+
};
1119+
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
1120+
// `components.quick_create.title` is more specific than `pages.<name>.label`.
1121+
expect(out.regions[0].components[0].properties.title).toBe('快速新建');
1122+
// The page-name route still supplies what the id route did not.
1123+
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
1124+
});
1125+
1126+
it('does not mutate the input page', () => {
1127+
const doc = homePage();
1128+
const snapshot = JSON.parse(JSON.stringify(doc));
1129+
translatePage(doc, homeBundle, { locale: 'zh-CN' });
1130+
expect(doc).toEqual(snapshot);
1131+
});
1132+
});
9921133
});
9931134

9941135
describe('TranslationDataSchema pages', () => {

0 commit comments

Comments
 (0)