diff --git a/.changeset/translation-pages-components-facet.md b/.changeset/translation-pages-components-facet.md new file mode 100644 index 0000000000..e7b214ba0e --- /dev/null +++ b/.changeset/translation-pages-components-facet.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): page component copy is translatable — `pages..components.` (#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..widgets.` 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. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d43139e3e2..41115a4ab9 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -144,7 +144,7 @@ Translation data for objects, apps, and UI messages | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record }>; … }>` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | -| **pages** | `Record` | optional | Page translations keyed by page name | +| **pages** | `Record` | optional | Page translations keyed by page name | | **settings** | `Record; keys?: Record }>; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -197,7 +197,7 @@ One locale of translations — the `translation` metadata type | **messages** | `Record` | optional | UI message translations keyed by message ID | | **globalActions** | `Record }>; … }>` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | -| **pages** | `Record` | optional | Page translations keyed by page name | +| **pages** | `Record` | optional | Page translations keyed by page name | | **settings** | `Record; keys?: Record }>; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 825d56f8e2..a53a64fed3 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -273,4 +273,4 @@ directory rather than per file. | `kernel/` | 319 | | `qa/` | 6 | | `shared/` | 20 | -| `system/` | 366 | +| `system/` | 367 | diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index aca0cb46b7..4bc38c1655 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -540,5 +540,26 @@ export const ShowcaseTranslationBundle = { }, }, }, + // Page component copy became declared surface with `pages..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: '选择项目…' }, + }, + }, + }, }, }; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 23fd76fe87..855f8ed6d9 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -49,6 +49,7 @@ * dashboards..widgets..title / .description * pages..label / .description * pages..title / .subtitle (from the page's `page:header` component) + * pages..components.. (per-component copy, #6080) * metadataForms..label / .description * metadataForms..sections.
.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -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'; @@ -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) ─── diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 95b84f5c04..9f9a215fdd 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -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)", @@ -477,6 +478,7 @@ "PackageFile (type)", "PackagePublishResult (type)", "PackagePublishResultSchema (const)", + "PageComponentCopyKey (type)", "PageComponentLike (interface)", "PageLike (interface)", "PageRegionLike (interface)", diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index a57a5c145f..2c394515e8 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -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, + }], + }], + }; + const out = translatePage(doc, homeBundle, { locale: 'zh-CN' }); + // `components.quick_create.title` is more specific than `pages..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', () => { diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 6425f2a93f..dd1899842d 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -676,6 +676,10 @@ export function translateDashboard( /** Minimal page-component shape consumed by `translatePage`. */ export interface PageComponentLike { type?: string; + /** `PageComponentSchema.id` — the key `pages..components` addresses (#6080). */ + id?: string; + /** The component's own top-level label, where `label` copy lands when present. */ + label?: string; properties?: Record; [key: string]: any; } @@ -713,6 +717,54 @@ function lookupPageAttr( return undefined; } +/** + * The copy keys `pages..components.` carries (#6080). Measured + * against `ComponentPropsMap` — see the schema's own note for which component + * declares which. + * + * Exported because the CLI's `i18n-extract` writes exactly these keys into the + * skeleton bundle. Two hand-maintained copies of this list would drift into the + * classic pair of failures — the extractor offering a key the resolver ignores, + * or omitting one it reads — so there is one list and both sides import it. + * `translation.zod.ts` declares the same six; `translation.test.ts` pins the + * two in agreement. + */ +export const PAGE_COMPONENT_COPY_KEYS = [ + 'title', 'description', 'label', 'placeholder', 'emptyText', 'submitLabel', +] as const; + +export type PageComponentCopyKey = typeof PAGE_COMPONENT_COPY_KEYS[number]; + +/** + * Per-component copy for one component id, resolved across the locale chain. + * + * Resolved KEY BY KEY rather than by taking the first locale that has an entry + * for the id: a partially-translated `zh` entry must still fall back to `en` + * for the keys it omits, which is how every other resolver on this surface + * behaves. + */ +function lookupPageComponentCopy( + bundle: TranslationBundle | undefined, + name: string, + id: string, + opts?: ResolveOptions, +): Partial> | undefined { + if (!bundle) return undefined; + let found: Partial> | undefined; + for (const code of localeChain(opts)) { + const entry = pickData(bundle, code)?.pages?.[name]?.components?.[id]; + if (!entry || typeof entry !== 'object') continue; + for (const key of PAGE_COMPONENT_COPY_KEYS) { + if (found?.[key] !== undefined) continue; + const candidate = (entry as Record)[key]; + if (typeof candidate === 'string' && candidate.length > 0) { + (found ??= {})[key] = candidate; + } + } + } + return found; +} + /** * Apply the active locale to a page metadata document — translates the page's * own `label` / `description` and the `properties.title` / `properties.subtitle` @@ -725,6 +777,13 @@ function lookupPageAttr( * `pages..label` so translators need not repeat a string that is normally * identical to the page's nav label. * + * Every OTHER component is addressed by its own `id` through + * `pages..components.` (#6080), which overlays that component's + * `properties` — the page half of what `dashboards..widgets.` has + * always given dashboards. Because the id route is the more specific of the + * 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. @@ -746,13 +805,44 @@ export function translatePage( const translateComponent = (component: PageComponentLike): PageComponentLike => { if (!component || typeof component !== 'object') return component; - if (component.type !== PAGE_HEADER_COMPONENT) return component; - if (headerTitle === undefined && headerSubtitle === undefined) return 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; + + let next = component; + if (copy) { + const { label: copyLabel, ...propCopy } = copy; + // `label` lands wherever the author declared it. `PageComponentSchema` + // has a top-level `label` AND an open `properties` bag, and different + // components use different slots — writing both would invent a key the + // author never authored, and writing only one would silently miss half + // the components. + const labelAtTopLevel = copyLabel !== undefined && typeof component.label === 'string'; + next = { + ...component, + ...(labelAtTopLevel ? { label: copyLabel } : {}), + properties: { + ...component.properties, + ...propCopy, + ...(copyLabel !== undefined && !labelAtTopLevel ? { label: copyLabel } : {}), + }, + }; + } + + if (next.type !== PAGE_HEADER_COMPONENT) return next; + if (headerTitle === undefined && headerSubtitle === undefined) return next; return { - ...component, + ...next, properties: { - ...component.properties, - ...(headerTitle !== undefined ? { title: headerTitle } : {}), + ...next.properties, + // The id-addressed copy above is more specific — do not overwrite what + // it already resolved for this header. + ...(headerTitle !== undefined && copy?.title === undefined ? { title: headerTitle } : {}), ...(headerSubtitle !== undefined ? { subtitle: headerSubtitle } : {}), }, }; diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index cb484b9d2e..f99ed2026f 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; +import { PAGE_COMPONENT_COPY_KEYS } from './i18n-resolver'; import { TranslationDataSchema, TranslationBundleSchema, @@ -815,6 +816,82 @@ describe('translation unknown-key strictness (#4001)', () => { .toContain('`help` → `helpText`'); }); + // ────────────────────────────────────────────────────────────────────────── + // #6080 — `pages..components.`, the page half of `dashboards.widgets` + // ────────────────────────────────────────────────────────────────────────── + describe('page component copy (#6080)', () => { + const parse = (components: unknown) => + TranslationDataSchema.safeParse({ pages: { sales_home_page: { label: 'Sales', components } } }); + + it('accepts the measured key face, keyed by component id', () => { + const result = parse({ + quick_create: { title: 'Quick Create' }, + kpi_revenue_won: { label: 'Revenue (Won)' }, + ai_briefing: { title: 'Ask the AI', description: 'Open the panel.' }, + lead_picker: { placeholder: 'Search…', emptyText: 'No records' }, + new_lead_form: { submitLabel: 'Create' }, + }); + expect(result.success).toBe(true); + }); + + it('stays `.strict()` — an invented key is still refused', () => { + const result = parse({ quick_create: { tooltip: 'Create a record' } }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); + + it('sends `help` to `description` rather than declaring a key no component has', () => { + // The issue proposed `help` in this face. No component in + // `ComponentPropsMap` declares it, so declaring it would parse clean and + // translate nothing (ADR-0078). It is an alias instead. + const result = parse({ ai_briefing: { help: 'Open the panel.' } }); + expect(result.success).toBe(false); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`help` → `description`'); + }); + + it('keeps `subtitle` at the page level — one string, one spelling', () => { + // `page:header` is `subtitle`'s only declarer and is addressed by page + // name, so a per-component `subtitle` would be a second route to it. + expect(parse({ some_header: { subtitle: 'Welcome back' } }).success).toBe(false); + expect(TranslationDataSchema.safeParse({ + pages: { sales_home_page: { subtitle: 'Welcome back' } }, + }).success).toBe(true); + }); + + it('names this surface in the error, not the dashboard widget one', () => { + const result = parse({ quick_create: { titel: 'Quick Create' } }); + expect(result.error?.issues[0]?.message).toContain('this page component translation'); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('→ `title`'); + }); + + it('declares exactly the keys the resolver and the extractor act on', () => { + // `PAGE_COMPONENT_COPY_KEYS` drives both `translatePage`'s overlay and + // the CLI's skeleton extraction. If this schema declared a key missing + // from that list the extractor would offer a slot nothing reads; if the + // list carried one this schema lacks, `.strict()` would reject the very + // key the extractor just wrote. Pin the two together. + for (const key of PAGE_COMPONENT_COPY_KEYS) { + expect(parse({ some_component: { [key]: 'x' } }).success, `\`${key}\` must be declared`).toBe(true); + } + const declared = Object.keys( + (TranslationDataSchema.safeParse({ + pages: { p: { components: { c: Object.fromEntries(PAGE_COMPONENT_COPY_KEYS.map((k) => [k, 'x'])) } } }, + }) as { success: true; data: any }).data.pages.p.components.c, + ).sort(); + expect(declared).toEqual([...PAGE_COMPONENT_COPY_KEYS].sort()); + }); + + it('carries the `label`/`title` trap alias the dashboard widget face carries', () => { + // A page's headline is `label`; a component's is `title`. One level + // apart, opposite spellings — the same trap `dashboards.widgets` names. + const result = parse({ quick_create: { name: 'Quick Create' } }); + expect(result.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`name` → `title`'); + }); + }); + it('names which action surface the key landed on', () => { const onObject = TranslationDataSchema.safeParse({ objects: { account: { _actions: { merge: { confirm: 'ok?' } } } }, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 8d3e0246e7..04f144fe52 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -436,13 +436,17 @@ const translationDataShape = () => ({ * pages..title → the page's `page:header` `properties.title` * pages..subtitle → the page's `page:header` `properties.subtitle` * + * pages..components.. + * → that component's `properties.` (#6080) + * * `title` falls back to `label` when omitted, since a page's header title * and its nav/breadcrumb label are usually the same string — translators * only author `title` separately when the two genuinely differ. * - * Header copy lives here rather than under a per-component key because - * `page:header` instances carry no stable `id`; the page name is the only - * addressable identifier on the metadata document. + * Header copy lives at the TOP level here rather than under `components` + * because `page:header` instances carry no stable `id`; the page name is the + * only addressable identifier for them. Every other component does have one, + * which is what `components` addresses — see its own note. */ pages: z.record(z.string(), strictObject({ surface: 'this page translation', @@ -453,6 +457,69 @@ const translationDataShape = () => ({ description: z.string().optional().describe('Translated page description'), title: z.string().optional().describe('Translated `page:header` title (defaults to `label`)'), subtitle: z.string().optional().describe('Translated `page:header` subtitle'), + /** + * Per-component copy, keyed by the component's `id` + * (`PageComponentSchema.id`) — the page half of what + * `dashboards..widgets.` has always given dashboards + * (#6080). + * + * **Why this existed as a hole.** The two component trees are near-identical + * in shape and a dashboard widget's `title`/`description` were translatable + * while a page component's were not, so a page's cards, KPI blocks and + * related lists had no key to write at all — not a drifted key, no key. The + * strings then reached the user as whatever literal the `*.page.ts` author + * typed, in every locale. It bit hardest on landing pages: hotcrm's + * `sales_home_page` rendered a translated header above four English cards + * and four English KPI blocks in zh/ja/es (12 strings across 8 pages). + * Nothing was misconfigured — the contract had nowhere to put them, and + * `.strict()` (correctly) refused the keys a translator invented. + * + * **The key face is measured, not mirrored.** Each key below is a copy prop + * that some component in `ComponentPropsMap` (`ui/component.zod.ts`) + * actually declares — every one is a plain `z.string()`/`I18nLabelSchema`, + * i.e. a literal with no inline `{en, zh}` form, so the bundle is its ONLY + * localization route: + * + * | key | declared by | + * |:---|:---| + * | `title` | `page:card`, `record:related_list` (and `page:header`, see below) | + * | `label` | `page:tabs`, `page:accordion`, `record:details`, `record:related_list`, `record:path`, `element:button`, `element:record_picker`, `element:text_input` | + * | `description` | `element:text_input` | + * | `placeholder` | `element:record_picker`, `element:text_input` | + * | `emptyText` | `element:record_picker` | + * | `submitLabel` | `element:form` | + * + * Two deliberate exclusions, both of which a mirror of the issue's proposed + * shape would have got wrong: + * + * - **`help` is not here** — no component in the model declares it. It + * would parse clean and translate nothing, which is the ADR-0078 shape + * this file keeps paying to remove. (`helpText` exists on an ACTION + * PARAM, a different surface with its own translation face.) + * - **`subtitle` is not here** — `page:header` is its only declarer, and + * that component is addressed by page name above. Declaring it in both + * places would give one string two spellings, which is how the + * dashboards/pages asymmetry started. + * + * `properties` is an open record and custom component types are legal, so + * these keys are also the route for a bespoke component that speaks the + * same vocabulary (hotcrm's `ai_briefing` carries `title` + `description`). + */ + components: z.record(z.string(), strictObject({ + surface: 'this page component translation', + history: TRANSLATION_HISTORY, + // A page component's headline is `title`; the PAGE's is `label`. Same + // document one level apart with opposite spellings — the same trap + // `dashboards.widgets` names, so it gets the same alias table. + aliases: { name: 'title', heading: 'title', text: 'label', caption: 'description', help: 'description', helpText: 'description', empty: 'emptyText', emptyState: 'emptyText', submit: 'submitLabel' }, + }, { + title: z.string().optional().describe('Translated component title (`page:card`, `record:related_list`, …)'), + description: z.string().optional().describe('Translated component description / supporting copy'), + label: z.string().optional().describe("Translated component label — overlays the component's own `label` when it declares one, else `properties.label`"), + placeholder: z.string().optional().describe('Translated input placeholder (`element:record_picker`, `element:text_input`)'), + emptyText: z.string().optional().describe('Translated empty-state text (`element:record_picker`)'), + submitLabel: z.string().optional().describe('Translated submit button label (`element:form`)'), + })).optional().describe('Per-component copy keyed by component id (`PageComponentSchema.id`)'), })).optional().describe('Page translations keyed by page name'), /**