Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/translation-pages-components-facet.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
---
"@objectstack/spec": minor
---

feat(spec): page component copy is translatable — `pages.<name>.components.<id>` (#6080)

A page's cards, KPI blocks, pickers and forms had **no translation key at all**.
Not a drifted key — no key: `pages` was a `.strict()` four-key record whose
`title`/`subtitle` mean the page's `page:header`, so every other component's
user-visible string reached the user as whatever literal the `*.page.ts` author
typed, in every locale, and `.strict()` (correctly) refused the keys a
translator invented.

The asymmetry was the giveaway: `dashboards.<name>.widgets.<widgetId>` has
carried `title`/`description` all along, and a page's components have stable
`id`s exactly like a widget does. Downstream, hotcrm's `sales_home_page` — the
`isDefault` landing page for sales reps — rendered a translated header above
four English cards and four English KPI blocks in zh/ja/es (12 strings across 8
pages).

```ts
pages: {
sales_home_page: {
label: '销售看板',
components: {
quick_create: { title: '快速新建' },
kpi_revenue_won: { label: '已赢收入' },
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
},
},
}
```

**Declared AND resolved in the same change.** `translatePage`
(`system/i18n-resolver.ts`) overlays the entry onto the component's
`properties`, so the face is not a declaration waiting for a reader.

**The key face is measured against `ComponentPropsMap`, not mirrored from the
issue's sketch** — `title`, `description`, `label`, `placeholder`, `emptyText`,
`submitLabel`, each one a copy prop some component actually declares as a plain
string with no inline `{en, zh}` form, i.e. one whose only localization route is
this bundle. Two deliberate exclusions:

- **`help` is not declared.** No component in the model has it; it would parse
clean and translate nothing (ADR-0078). It is an alias onto `description`.
- **`subtitle` is not declared.** `page:header` is its only declarer and is
addressed by page name, so a per-component `subtitle` would give one string
two spellings — which is how this asymmetry started.

Resolution rules, all tested: `label` lands on the component's own top-level
`label` when it declares one and in `properties.label` otherwise (copy goes
where the author wrote it); keys resolve **individually** across the locale
chain, so a partially-translated `zh` entry still falls back to `en` per key;
and the id-addressed route beats the page-name route wherever both could apply
(a `page:header` that does carry an `id`).

Purely additive and `.strict()` is unchanged — `components` is optional, every
previously-valid bundle still parses, and every previously-rejected key is still
rejected.
4 changes: 2 additions & 2 deletions content/docs/references/system/translation.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -144,7 +144,7 @@ Translation data for objects, apps, and UI messages
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **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 |
| **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 |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **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 |
| **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 |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
Expand DownExpand Up@@ -197,7 +197,7 @@ One locale of translations — the `translation` metadata type
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
| **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 |
| **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 |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **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 |
| **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 |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -273,4 +273,4 @@ directory rather than per file.
| `kernel/` | 319 |
| `qa/` | 6 |
| `shared/` | 20 |
| `system/` | 366 |
| `system/` | 367 |
21 changes: 21 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -540,5 +540,26 @@ export const ShowcaseTranslationBundle = {
},
},
},
// Page component copy became declared surface with `pages.<name>.components`
// (#6080), so these keys are born under the ratchet: leaving any of them
// untranslated widens the frozen baseline and fails `check-i18n-coverage`.
// The pages' own label/title/subtitle predate the ratchet and stay in the
// frozen baseline, same as Revenue Pulse's older widget titles above.
pages: {
showcase_contact_form: {
components: {
field_name: { label: '姓名', placeholder: '艾达·洛夫莱斯' },
field_email: { label: '邮箱', placeholder: 'ada@example.com' },
field_company: { label: '公司', placeholder: '分析机有限公司' },
field_message: { label: '留言', placeholder: '我们能帮您什么?' },
submit_inquiry: { label: '提交咨询' },
},
},
showcase_page_variables: {
components: {
project_picker: { label: '项目', placeholder: '选择项目…' },
},
},
},
},
};
31 changes: 30 additions & 1 deletion packages/cli/src/utils/i18n-extract.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@
* dashboards.<dash>.widgets.<w>.title / .description
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* pages.<page>.components.<id>.<key> (per-component copy, #6080)
* metadataForms.<type>.label / .description
* metadataForms.<type>.sections.<section>.label / .description
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
Expand All@@ -63,7 +64,7 @@
*/

import type { TranslationBundle, TranslationData } from '@objectstack/spec/system';
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
import { METADATA_FORM_REGISTRY, PAGE_COMPONENT_COPY_KEYS } from '@objectstack/spec/system';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { deriveFieldGroupLayout } from '@objectstack/spec/data';
import { expandViewContainer } from '@objectstack/spec/ui';
Expand DownExpand Up@@ -752,6 +753,34 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
}
}
}

// Per-component copy, addressed by the component's own id (#6080). Without
// this pass the face exists but nothing writes the skeleton, so a
// translator would have to know the keys to hand-write them — which is
// most of the reason the copy went untranslated in the first place.
//
// `page:header` is deliberately skipped: its copy is addressed by page
// name above, and emitting it here too would offer one string under two
// keys.
for (const region of regions) {
const components: any[] = Array.isArray(region?.components) ? region.components : [];
for (const component of components) {
if (component?.type === 'page:header') continue;
const id = component?.id;
if (typeof id !== 'string' || !id) continue;
const props = component.properties ?? {};
for (const key of PAGE_COMPONENT_COPY_KEYS) {
// `label` may be authored on the component itself or in its props —
// the same either/or `translatePage` resolves back onto.
const value = key === 'label' && typeof component.label === 'string' && component.label
? component.label
: props[key];
if (typeof value === 'string' && value) {
pushEntry(out, ['pages', name, 'components', id, key], value, 'page');
}
}
}
}
}

// ── Object sections (fieldGroups + authored form/page sections) ───
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface/system.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -464,6 +464,7 @@
"OpenTelemetryCompatibilityParsed (type)",
"OpenTelemetryCompatibilitySchema (const)",
"OtelExporterType (type)",
"PAGE_COMPONENT_COPY_KEYS (const)",
"PKG_CONVENTIONS (const)",
"PLATFORM_OBJECTS_BY_PACKAGE (const)",
"PLATFORM_OBJECT_PREFIXES (const)",
Expand All@@ -477,6 +478,7 @@
"PackageFile (type)",
"PackagePublishResult (type)",
"PackagePublishResultSchema (const)",
"PageComponentCopyKey (type)",
"PageComponentLike (interface)",
"PageLike (interface)",
"PageRegionLike (interface)",
Expand Down
141 changes: 141 additions & 0 deletions packages/spec/src/system/i18n-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -989,6 +989,147 @@ describe('translatePage', () => {
const out = translatePage(page, bundle, { locale: 'zh' });
expect(out.label).toBe('连接智能体');
});

// ──────────────────────────────────────────────────────────────────────────
// #6080 — per-component copy, keyed by component id
//
// Modelled on the reported downstream case: hotcrm's `sales_home_page`
// rendered a translated header above English cards and English KPI blocks,
// because `pages` had four keys and none of them could reach a component.
// ──────────────────────────────────────────────────────────────────────────
describe('per-component copy (#6080)', () => {
const homeBundle: TranslationBundle = {
'zh-CN': {
pages: {
sales_home_page: {
label: '销售看板',
subtitle: '欢迎回来',
components: {
quick_create: { title: '快速新建' },
kpi_revenue_won: { label: '已赢收入' },
ai_briefing: { title: '询问 AI 助手', description: '从右侧边缘打开助手面板。' },
lead_picker: { placeholder: '搜索线索…', emptyText: '暂无记录' },
new_lead_form: { submitLabel: '创建' },
},
},
},
},
en: {
pages: {
sales_home_page: {
components: { ai_briefing: { title: 'Ask the AI Assistant', description: 'Open the assistant panel.' } },
},
},
},
};

const homePage = () => ({
name: 'sales_home_page',
label: 'Sales Home',
regions: [{
name: 'main',
components: [
{ type: 'page:header', properties: { title: 'Sales Home', subtitle: 'Welcome back' } },
{ type: 'page:card', id: 'quick_create', properties: { title: 'Quick Create', icon: 'plus' } },
{ type: 'element:kpi', id: 'kpi_revenue_won', properties: { label: 'Revenue (Won)', value: 42 } },
{ type: 'page:card', id: 'ai_briefing', properties: { title: 'Ask the AI Assistant', description: 'Open the assistant panel from the right edge…' } },
{ type: 'element:record_picker', id: 'lead_picker', properties: { object: 'lead', placeholder: 'Search leads…', emptyText: 'No records' } },
{ type: 'element:form', id: 'new_lead_form', properties: { object: 'lead', submitLabel: 'Create' } },
{ type: 'page:card', id: 'untranslated_card', properties: { title: 'Still English' } },
],
}],
});

const byId = (doc: any, id: string) =>
doc.regions[0].components.find((c: any) => c.id === id);

it('translates card title, KPI label, and description by component id', () => {
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
expect(byId(out, 'quick_create').properties.title).toBe('快速新建');
expect(byId(out, 'kpi_revenue_won').properties.label).toBe('已赢收入');
expect(byId(out, 'ai_briefing').properties.title).toBe('询问 AI 助手');
expect(byId(out, 'ai_briefing').properties.description).toBe('从右侧边缘打开助手面板。');
});

it('covers the whole measured key face, not just title/description', () => {
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
expect(byId(out, 'lead_picker').properties.placeholder).toBe('搜索线索…');
expect(byId(out, 'lead_picker').properties.emptyText).toBe('暂无记录');
expect(byId(out, 'new_lead_form').properties.submitLabel).toBe('创建');
});

it('preserves non-copy properties alongside the overlay', () => {
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
expect(byId(out, 'quick_create').properties.icon).toBe('plus');
expect(byId(out, 'kpi_revenue_won').properties.value).toBe(42);
expect(byId(out, 'lead_picker').properties.object).toBe('lead');
});

it('leaves a component with no entry — and one with no id — untouched', () => {
const out = translatePage(homePage(), homeBundle, { locale: 'zh-CN' });
expect(byId(out, 'untranslated_card').properties.title).toBe('Still English');
// The header carries no id, so only the page-name route applies to it.
expect(out.regions[0].components[0].properties.title).toBe('销售看板');
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
});

it("lands `label` on the component's own label slot when it declares one", () => {
// `PageComponentSchema` has BOTH a top-level `label` and an open
// `properties` bag; copy must land where the author actually wrote it.
const doc = {
name: 'sales_home_page',
regions: [{
name: 'main',
components: [{ type: 'element:button', id: 'kpi_revenue_won', label: 'Revenue (Won)', properties: {} }],
}],
};
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
expect(out.regions[0].components[0].label).toBe('已赢收入');
// …and does not invent a second spelling in the props bag.
expect(out.regions[0].components[0].properties).not.toHaveProperty('label');
});

it('resolves key by key across the locale chain, not entry by entry', () => {
// `zh-CN` translates only `title` for this id; `description` must still
// fall back to `en` rather than being dropped because the zh entry won.
const partial: TranslationBundle = {
'zh-CN': { pages: { sales_home_page: { components: { ai_briefing: { title: '询问 AI 助手' } } } } },
en: { pages: { sales_home_page: { components: { ai_briefing: { description: 'Open the assistant panel.' } } } } },
};
const out = translatePage(homePage(), partial, { locale: 'zh-CN' });
expect(byId(out, 'ai_briefing').properties.title).toBe('询问 AI 助手');
expect(byId(out, 'ai_briefing').properties.description).toBe('Open the assistant panel.');
});

it('lets the id-addressed route win over the page-name route on a header that has an id', () => {
const doc = {
name: 'sales_home_page',
regions: [{
name: 'header',
// `properties` widened to the open bag it is: the overlay adds keys
// the literal does not spell out, and inferring it as `{title}` alone
// would make reading the result a type error.
components: [{
type: 'page:header',
id: 'quick_create',
properties: { title: 'Sales Home' } as Record<string, string>,
}],
}],
};
const out = translatePage(doc, homeBundle, { locale: 'zh-CN' });
// `components.quick_create.title` is more specific than `pages.<name>.label`.
expect(out.regions[0].components[0].properties.title).toBe('快速新建');
// The page-name route still supplies what the id route did not.
expect(out.regions[0].components[0].properties.subtitle).toBe('欢迎回来');
});

it('does not mutate the input page', () => {
const doc = homePage();
const snapshot = JSON.parse(JSON.stringify(doc));
translatePage(doc, homeBundle, { locale: 'zh-CN' });
expect(doc).toEqual(snapshot);
});
});
});

describe('TranslationDataSchema pages', () => {
Expand Down
Loading
Loading