diff --git a/.changeset/page-header-i18n-3589.md b/.changeset/page-header-i18n-3589.md new file mode 100644 index 0000000000..69df2d5e29 --- /dev/null +++ b/.changeset/page-header-i18n-3589.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": minor +"@objectstack/cli": minor +"@objectstack/rest": patch +--- + +feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589) + +Custom system pages authored as metadata (Installed Apps, Cloud Connection, +Connect an Agent) hard-code their `page:header` copy in +`properties.title` / `properties.subtitle`. Every other metadata type is +localized at the REST boundary, but `page` was not: the `pages` namespace +existed only on `AppTranslationBundleSchema` — a schema no runtime reads — +with no resolver behind it, so those headers stayed English in every locale +while the matching nav labels translated correctly. + +- `TranslationDataSchema` (the shape the i18n service actually serves) gains a + `pages` namespace: `pages..{label,description,title,subtitle}`. +- New `translatePage` in `@objectstack/spec/system` translates a page's own + `label` / `description` and overlays `title` / `subtitle` onto every + `page:header` in the page's regions. Registered in + `translateMetadataDocument`, so it rides the existing read path. +- `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale + extraction, the locale-keyed ETag, and `Vary: Accept-Language` already + covered every metadata type — no new plumbing. +- `objectstack i18n extract` now emits page entries, including the + `page:header` copy, so the new namespace is not invisible to the tooling. +- zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus + the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these + existed only in zh-CN). + +Header copy is keyed by **page name**, not by component id: `page:header` +instances carry no stable id. `title` falls back to `pages..label`, since +a page's header title and its nav label are normally the same string. + +Authoring is unchanged and English literals stay in metadata as the fallback — +a page with no `pages` entry renders exactly as before. Consumers of +`@object-ui` need no change: pages arrive already localized from the server. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 0362693837..bcd40706fc 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -197,6 +197,7 @@ Translation data for objects, apps, and UI messages | **validationMessages** | `Record` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) | | **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 | | **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/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 6e92c28636..5912231eef 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -73,10 +73,21 @@ export default defineStack({ | Form sections | `objects.._sections.
` | | App navigation | `apps..navigation..label` | | Dashboards and widgets | `dashboards.` | +| Page labels and `page:header` copy | `pages..label` / `description` / `title` / `subtitle` | | Global actions, settings, messages | `globalActions`, `settings`, `messages` | -The metadata types resolved per request are **object, view, action, app, and -dashboard** — a field's labels are translated as part of its object document. +The metadata types resolved per request are **object, view, action, app, +dashboard, and page** — a field's labels are translated as part of its object +document. + + +**Page headers are keyed by page name (#3589).** A page's `page:header` +component has no stable id, so its `properties.title` / `properties.subtitle` +are addressed through the page itself: `pages..title` / `subtitle`. +`title` falls back to `pages..label`, so a page whose header title +matches its nav label needs only `label`. Every `page:header` in the page's +regions receives the same copy. + **One-shot result dialogs are translatable (#3347).** The post-success diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 06e1f8a30f..011e518727 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -35,6 +35,8 @@ * apps..navigation..label * dashboards..label / .description * dashboards..widgets..title / .description + * pages..label / .description + * pages..title / .subtitle (from the page's `page:header` component) * metadataForms..label / .description * metadataForms..sections.
.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -72,6 +74,7 @@ export interface ExpectedEntry { | 'navigation' | 'dashboard' | 'widget' + | 'page' | 'metadataType' | 'metadataFormSection' | 'metadataFormField'; @@ -396,6 +399,36 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { } } + // ── Pages + their `page:header` copy ────────────────────────────── + const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; + for (const page of pages) { + if (!page?.name) continue; + const name = page.name as string; + if (page.label) pushEntry(out, ['pages', name, 'label'], page.label, 'page'); + if (page.description) { + pushEntry(out, ['pages', name, 'description'], page.description, 'page'); + } + // Header copy is authored inside the page's `page:header` component but + // is addressed by page name — `translatePage` overlays it back onto every + // header in the page's regions. + const regions: any[] = Array.isArray(page.regions) ? page.regions : []; + 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 props = component.properties ?? {}; + // `title` duplicating `label` is the common case and resolves via the + // label fallback — only emit it when the two genuinely differ. + if (typeof props.title === 'string' && props.title && props.title !== page.label) { + pushEntry(out, ['pages', name, 'title'], props.title, 'page'); + } + if (typeof props.subtitle === 'string' && props.subtitle) { + pushEntry(out, ['pages', name, 'subtitle'], props.subtitle, 'page'); + } + } + } + } + // ── Metadata configuration forms (Studio admin UI) ──────────────── // Registry-driven: always included, independent of stack config. These // emit under `metadataForms..*` so the generic renderer can pick diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 4e7f806568..2d3802d76c 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -171,6 +171,61 @@ describe('collectExpectedEntries', () => { // Fields without a label emit nothing. expect(byPath[`${base}.fields.unlabeled`]).toBeUndefined(); }); + + it('walks pages and their page:header copy (objectstack#3589)', () => { + const pageConfig: any = { + pages: [ + { + name: 'connect_agent', + label: 'Connect an Agent', + description: 'Agent onboarding', + regions: [ + { + name: 'header', + components: [ + { + type: 'page:header', + // `title` duplicates `label` — resolved by the label + // fallback, so it must NOT emit its own entry. + properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' }, + }, + ], + }, + { name: 'main', components: [{ type: 'mcp:connect-agent', properties: {} }] }, + ], + }, + { + name: 'renamed_header', + label: 'Nav Label', + regions: [ + { name: 'header', components: [{ type: 'page:header', properties: { title: 'Different Title' } }] }, + ], + }, + { name: 'bare_page', label: 'Bare' }, + ], + }; + const entries = collectExpectedEntries(pageConfig); + const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue])); + + expect(byPath['pages.connect_agent.label']).toBe('Connect an Agent'); + expect(byPath['pages.connect_agent.description']).toBe('Agent onboarding'); + expect(byPath['pages.connect_agent.subtitle']).toBe('Governed MCP access.'); + // title === label → no redundant entry for translators to fill twice. + expect(byPath['pages.connect_agent.title']).toBeUndefined(); + // A header title that genuinely differs from the label does emit. + expect(byPath['pages.renamed_header.title']).toBe('Different Title'); + // Non-header components and non-translatable props (icon) contribute + // nothing — the page namespace holds only the four translatable keys. + const pagePaths = Object.keys(byPath).filter((p) => p.startsWith('pages.')); + expect(pagePaths.sort()).toEqual([ + 'pages.bare_page.label', + 'pages.connect_agent.description', + 'pages.connect_agent.label', + 'pages.connect_agent.subtitle', + 'pages.renamed_header.label', + 'pages.renamed_header.title', + ]); + }); }); describe('extractTranslations', () => { diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 292390c18a..39435ac0a9 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -60,6 +60,7 @@ export const en: TranslationData = { // Apps / Marketplace nav_marketplace_browse: { label: 'Browse Marketplace' }, nav_marketplace_installed: { label: 'Installed Apps' }, + nav_cloud_connection: { label: 'Cloud Connection' }, // People & Organization nav_users: { label: 'Users' }, @@ -75,6 +76,7 @@ export const en: TranslationData = { nav_sharing_rules: { label: 'Sharing Rules' }, nav_record_shares: { label: 'Record Shares' }, nav_api_keys: { label: 'API Keys' }, + nav_connect_agent: { label: 'Connect an Agent' }, // Approvals nav_approval_processes: { label: 'Processes' }, @@ -195,4 +197,27 @@ export const en: TranslationData = { }, }, }, + + // Setup pages contributed as metadata by capability plugins. The English + // entries mirror the literals authored in the plugins' page metadata + // (@objectstack/cloud-connection, @objectstack/mcp) and exist so the other + // locales have a complete key set to translate against. + pages: { + marketplace_installed: { + label: 'Installed Apps', + subtitle: "Marketplace packages currently installed into this runtime's kernel.", + }, + cloud_connection_settings: { + label: 'Cloud Connection', + subtitle: + 'Connect this runtime to an ObjectStack control plane to browse your ' + + "organization's private packages and install them here.", + }, + connect_agent: { + label: 'Connect an Agent', + subtitle: + 'Give any MCP-capable AI client governed access to this environment — ' + + "every call runs under the caller's own permissions and row-level security.", + }, + }, }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index ca3c06708d..c2c1aed425 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -34,6 +34,7 @@ export const esES: TranslationData = { group_apps: { label: 'Aplicaciones' }, nav_marketplace_browse: { label: 'Explorar Marketplace' }, nav_marketplace_installed: { label: 'Aplicaciones instaladas' }, + nav_cloud_connection: { label: 'Conexión a la nube' }, group_people_org: { label: 'Personas y Organización' }, group_access_control: { label: 'Control de Acceso' }, group_approvals: { label: 'Aprobaciones' }, @@ -56,6 +57,7 @@ export const esES: TranslationData = { nav_sharing_rules: { label: 'Reglas de Compartición' }, nav_record_shares: { label: 'Registros Compartidos' }, nav_api_keys: { label: 'Claves API' }, + nav_connect_agent: { label: 'Conectar un agente' }, nav_approval_processes: { label: 'Procesos' }, nav_approval_requests: { label: 'Solicitudes' }, @@ -141,4 +143,23 @@ export const esES: TranslationData = { }, }, }, + + pages: { + marketplace_installed: { + label: 'Aplicaciones instaladas', + subtitle: 'Paquetes del marketplace instalados actualmente en el kernel de este runtime.', + }, + cloud_connection_settings: { + label: 'Conexión a la nube', + subtitle: + 'Conecta este runtime a un plano de control de ObjectStack para explorar los paquetes ' + + 'privados de tu organización e instalarlos aquí.', + }, + connect_agent: { + label: 'Conectar un agente', + subtitle: + 'Concede a cualquier cliente de IA compatible con MCP acceso controlado a este entorno: ' + + 'cada llamada se ejecuta con los permisos propios de quien la realiza y con seguridad a nivel de fila.', + }, + }, }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index aa808a4515..55fd3d9341 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -34,6 +34,7 @@ export const jaJP: TranslationData = { group_apps: { label: 'アプリ' }, nav_marketplace_browse: { label: 'マーケットプレイスを閲覧' }, nav_marketplace_installed: { label: 'インストール済みアプリ' }, + nav_cloud_connection: { label: 'クラウド接続' }, group_people_org: { label: 'ユーザーと組織' }, group_access_control: { label: 'アクセス制御' }, group_approvals: { label: '承認' }, @@ -56,6 +57,7 @@ export const jaJP: TranslationData = { nav_sharing_rules: { label: '共有ルール' }, nav_record_shares: { label: 'レコード共有' }, nav_api_keys: { label: 'API キー' }, + nav_connect_agent: { label: 'エージェントを接続' }, nav_approval_processes: { label: 'プロセス' }, nav_approval_requests: { label: 'リクエスト' }, @@ -141,4 +143,21 @@ export const jaJP: TranslationData = { }, }, }, + + pages: { + marketplace_installed: { + label: 'インストール済みアプリ', + subtitle: 'このランタイムのカーネルに現在インストールされているマーケットプレイスパッケージ。', + }, + cloud_connection_settings: { + label: 'クラウド接続', + subtitle: + 'このランタイムを ObjectStack コントロールプレーンに接続すると、組織のプライベートパッケージを閲覧してここにインストールできます。', + }, + connect_agent: { + label: 'エージェントを接続', + subtitle: + 'MCP 対応の AI クライアントにこの環境への統制されたアクセスを許可します。すべての呼び出しは、呼び出し元自身の権限と行レベルセキュリティのもとで実行されます。', + }, + }, }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index a0df06a66b..0421487e68 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -146,4 +146,19 @@ export const zhCN: TranslationData = { }, }, }, + + pages: { + marketplace_installed: { + label: '已安装应用', + subtitle: '当前已安装到此运行时内核的应用市场包。', + }, + cloud_connection_settings: { + label: '云连接', + subtitle: '将此运行时连接到 ObjectStack 控制平面,即可浏览并安装组织的私有包。', + }, + connect_agent: { + label: '连接智能体', + subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境——每次调用都在调用者自身的权限与行级安全范围内执行。', + }, + }, }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 2e9df4245f..906f65151c 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -48,7 +48,7 @@ const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (g * via `translateMetadataDocument`. Keep in sync with the type dispatch in * `@objectstack/spec/system`'s `translateMetadataDocument`. */ -const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard']); +const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard', 'page']); /** diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index a45136a172..1ff62afea3 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2535,6 +2535,65 @@ describe('RestServer metadata translation — envelope unwrap', () => { }); }); +// ────────────────────────────────────────────────────────────────────────── +// Page metadata translation (objectstack#3589) +// +// Plugin-carried Setup pages hard-code their `page:header` copy in metadata. +// `page` was missing from TRANSLATABLE_META_TYPES, so the REST boundary +// returned those pages untranslated no matter what the bundle held. +// ────────────────────────────────────────────────────────────────────────── +describe('RestServer metadata translation — page documents', () => { + const fakeI18n = { + getLocales: () => ['zh-CN'], + getDefaultLocale: () => 'zh-CN', + getTranslations: (locale: string) => + locale === 'zh-CN' + ? { + pages: { + connect_agent: { + label: '连接智能体', + subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境。', + }, + }, + } + : undefined, + }; + const zhReq = { headers: { 'accept-language': 'zh-CN' } }; + const makePage = () => ({ + name: 'connect_agent', + label: 'Connect an Agent', + regions: [ + { + name: 'header', + components: [ + { + type: 'page:header', + properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', icon: 'bot' }, + }, + ], + }, + ], + }); + + it('translates the page:header copy inside a getMetaItem envelope', async () => { + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); + const envelope = { type: 'page', name: 'connect_agent', item: makePage(), lock: null }; + const out = await (rest as any).translateMetaItem(zhReq, 'page', undefined, envelope, fakeI18n); + expect(out.name).toBe('connect_agent'); + expect(out.item.label).toBe('连接智能体'); + expect(out.item.regions[0].components[0].properties.title).toBe('连接智能体'); + expect(out.item.regions[0].components[0].properties.subtitle).toBe('让任意支持 MCP 的 AI 客户端受控访问此环境。'); + expect(out.item.regions[0].components[0].properties.icon).toBe('bot'); + }); + + it('translates page documents in a list response', async () => { + const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); + (rest as any).resolveI18nService = async () => fakeI18n; + const out = await (rest as any).translateMetaItems(zhReq, 'page', undefined, [makePage()]); + expect(out[0].regions[0].components[0].properties.title).toBe('连接智能体'); + }); +}); + // --------------------------------------------------------------------------- // ADR-0045 — hidden-app visibility gate (filterAppForUser) // --------------------------------------------------------------------------- diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 29ddafa93b..df571f7cb5 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1012,6 +1012,9 @@ "PackageFile (type)", "PackagePublishResult (type)", "PackagePublishResultSchema (const)", + "PageComponentLike (interface)", + "PageLike (interface)", + "PageRegionLike (interface)", "Plan (type)", "PlanInput (type)", "PlanSchema (const)", @@ -1293,6 +1296,7 @@ "translateDashboard (function)", "translateMetadataDocument (function)", "translateObject (function)", + "translatePage (function)", "translateView (function)" ], "./kernel": [ diff --git a/packages/spec/src/system/i18n-resolver.test.ts b/packages/spec/src/system/i18n-resolver.test.ts index 1c0d113491..43cc323e87 100644 --- a/packages/spec/src/system/i18n-resolver.test.ts +++ b/packages/spec/src/system/i18n-resolver.test.ts @@ -745,6 +745,143 @@ describe('translateDashboard', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// translatePage — page label/description + `page:header` title/subtitle +// ──────────────────────────────────────────────────────────────────────────── + +import { translatePage } from './i18n-resolver'; + +describe('translatePage', () => { + const bundle: TranslationBundle = { + 'zh-CN': { + pages: { + connect_agent: { + label: '连接智能体', + subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境。', + }, + cloud_connection_settings: { + label: '云连接', + title: '云连接设置', + description: '绑定控制平面', + }, + }, + }, + }; + + const page = { + name: 'connect_agent', + label: 'Connect an Agent', + regions: [ + { + name: 'header', + components: [ + { + type: 'page:header', + properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', icon: 'bot' }, + }, + ], + }, + { + name: 'main', + components: [{ type: 'mcp:connect-agent', properties: {} }], + }, + ], + }; + + it('translates the page label and its page:header title/subtitle', () => { + const out = translatePage(page, bundle, { locale: 'zh-CN' }); + expect(out.label).toBe('连接智能体'); + expect(out.regions[0].components[0].properties.title).toBe('连接智能体'); + expect(out.regions[0].components[0].properties.subtitle).toBe('让任意支持 MCP 的 AI 客户端受控访问此环境。'); + }); + + it('preserves non-translatable header properties such as icon', () => { + const out = translatePage(page, bundle, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.icon).toBe('bot'); + }); + + it('leaves non-header components untouched', () => { + const out = translatePage(page, bundle, { locale: 'zh-CN' }); + expect(out.regions[1].components[0]).toEqual({ type: 'mcp:connect-agent', properties: {} }); + }); + + it('prefers an explicit title over the label fallback', () => { + const settingsPage = { + name: 'cloud_connection_settings', + label: 'Cloud Connection', + regions: [ + { name: 'header', components: [{ type: 'page:header', properties: { title: 'Cloud Connection' } }] }, + ], + }; + const out = translatePage(settingsPage, bundle, { locale: 'zh-CN' }); + expect(out.label).toBe('云连接'); + expect(out.description).toBe('绑定控制平面'); + expect(out.regions[0].components[0].properties.title).toBe('云连接设置'); + }); + + it('leaves pages without a translation entry unchanged', () => { + const other = { + name: 'some_other_page', + label: 'Other', + regions: [{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Other' } }] }], + }; + const out = translatePage(other, bundle, { locale: 'zh-CN' }); + expect(out.label).toBe('Other'); + expect(out.regions[0].components[0].properties.title).toBe('Other'); + }); + + it('does not mutate the input page', () => { + const snapshot = JSON.parse(JSON.stringify(page)); + translatePage(page, bundle, { locale: 'zh-CN' }); + expect(page).toEqual(snapshot); + }); + + it('falls back to literal copy when no bundle is present', () => { + const out = translatePage(page, undefined, { locale: 'zh-CN' }); + expect(out.label).toBe('Connect an Agent'); + expect(out.regions[0].components[0].properties.title).toBe('Connect an Agent'); + }); + + it('handles pages with no regions', () => { + const bare = { name: 'connect_agent', label: 'Connect an Agent' }; + const out = translatePage(bare, bundle, { locale: 'zh-CN' }); + expect(out.label).toBe('连接智能体'); + expect(out).not.toHaveProperty('regions'); + }); + + it('works through translateMetadataDocument with page type', () => { + const out = translateMetadataDocument('page', page, bundle, { locale: 'zh-CN' }); + expect(out.regions[0].components[0].properties.title).toBe('连接智能体'); + }); + + it('falls back through the BCP-47 locale chain (zh → zh-CN)', () => { + const out = translatePage(page, bundle, { locale: 'zh' }); + expect(out.label).toBe('连接智能体'); + }); +}); + +describe('TranslationDataSchema pages', () => { + it('accepts a fully-populated pages entry', () => { + const data = TranslationDataSchema.parse({ + pages: { + connect_agent: { + label: '连接智能体', + description: '为 MCP 客户端授予访问权限', + title: '连接智能体', + subtitle: '每次调用都在调用者自身的权限范围内执行。', + }, + }, + }); + expect(data.pages?.connect_agent?.title).toBe('连接智能体'); + expect(data.pages?.connect_agent?.subtitle).toBe('每次调用都在调用者自身的权限范围内执行。'); + }); + + it('all pages fields are optional', () => { + expect(() => TranslationDataSchema.parse({ pages: {} })).not.toThrow(); + expect(() => TranslationDataSchema.parse({ pages: { connect_agent: {} } })).not.toThrow(); + }); +}); + // ──────────────────────────────────────────────────────────────────────────── // translateObject — built-in system-field label fallback // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 3ad83d1c74..8079a88ace 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -376,6 +376,7 @@ export function translateMetadataDocument( if (type === 'object') return translateObject(doc, bundle, opts); if (type === 'app') return translateApp(doc, bundle, opts); if (type === 'dashboard') return translateDashboard(doc, bundle, opts); + if (type === 'page') return translatePage(doc, bundle, opts); return doc; } @@ -564,6 +565,110 @@ export function translateDashboard( }; } +// ──────────────────────────────────────────────────────────────────────────── +// Page metadata resolvers (label / description / page:header title + subtitle) +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal page-component shape consumed by `translatePage`. */ +export interface PageComponentLike { + type?: string; + properties?: Record; + [key: string]: any; +} + +/** Minimal page-region shape consumed by `translatePage`. */ +export interface PageRegionLike { + name?: string; + components?: PageComponentLike[]; + [key: string]: any; +} + +/** Minimal page metadata shape consumed by `translatePage`. */ +export interface PageLike { + name: string; + label?: string; + description?: string; + regions?: PageRegionLike[]; + [key: string]: any; +} + +/** The component type whose header copy `translatePage` localizes. */ +const PAGE_HEADER_COMPONENT = 'page:header'; + +function lookupPageAttr( + bundle: TranslationBundle | undefined, + name: string, + attr: 'label' | 'description' | 'title' | 'subtitle', + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.pages?.[name]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Apply the active locale to a page metadata document — translates the page's + * own `label` / `description` and the `properties.title` / `properties.subtitle` + * of every `page:header` component in its regions, against + * `pages..{label,description,title,subtitle}`. The input document is not + * mutated. + * + * Header copy is addressed by **page name** rather than by component id because + * `page:header` instances carry no stable id in practice. `title` falls back to + * `pages..label` so translators need not repeat a string that is normally + * identical to the page's nav label. + * + * 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. + */ +export function translatePage( + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!doc || typeof doc !== 'object') return doc; + const name = doc.name; + if (!name || !bundle) return doc; + + const label = lookupPageAttr(bundle, name, 'label', opts) ?? doc.label; + const description = lookupPageAttr(bundle, name, 'description', opts) ?? doc.description; + const headerTitle = lookupPageAttr(bundle, name, 'title', opts) + ?? lookupPageAttr(bundle, name, 'label', opts); + const headerSubtitle = lookupPageAttr(bundle, name, 'subtitle', opts); + + 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; + return { + ...component, + properties: { + ...component.properties, + ...(headerTitle !== undefined ? { title: headerTitle } : {}), + ...(headerSubtitle !== undefined ? { subtitle: headerSubtitle } : {}), + }, + }; + }; + + 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) }; + }) + : doc.regions; + + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + ...(regions !== undefined ? { regions } : {}), + }; +} + // ──────────────────────────────────────────────────────────────────────────── // Object metadata resolvers (label / pluralLabel / description / fields / options) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 27b311bbd1..ca791f6c10 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -220,6 +220,30 @@ export const TranslationDataSchema = lazySchema(() => z.object({ })).optional().describe('Widget translations keyed by widget id'), })).optional().describe('Dashboard translations keyed by dashboard name'), + /** + * Page translations keyed by page name (`Page.name`). + * + * Convention (auto-resolved by `translatePage`): + * pages..label → the page document's own `label` + * pages..description → the page document's own `description` + * pages..title → the page's `page:header` `properties.title` + * pages..subtitle → the page's `page:header` `properties.subtitle` + * + * `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. + */ + pages: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated page label (nav / breadcrumb)'), + 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'), + })).optional().describe('Page translations keyed by page name'), + /** * Settings manifest translations keyed by settings namespace * (matches `SettingsManifest.namespace`, e.g. "mail", "branding").