Skip to content

Commit 67452d1

Browse files
authored
feat(spec): resolve page metadata i18n — page:header title/subtitle (#3589) (#3648)
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, so those headers stayed English in every locale while the matching nav labels translated correctly. - `TranslationDataSchema` gains `pages.<name>.{label,description,title,subtitle}`. The pre-existing `pages` key lived on `AppTranslationBundleSchema`, which no runtime reads (`getAppBundle`/`loadAppBundle` are optional and unimplemented). - New `translatePage` overlays the copy onto every `page:header` in a page's regions; registered in `translateMetadataDocument`. - `page` added to the REST boundary's TRANSLATABLE_META_TYPES — locale extraction, the locale-keyed ETag and `Vary: Accept-Language` already applied to every metadata type. - `objectstack i18n extract` emits page entries. - zh-CN / ja-JP / es-ES copy for the three pages, plus the missing `nav_cloud_connection` / `nav_connect_agent` labels. Header copy is keyed by page name (page:header carries no stable id); `title` falls back to `label`. English literals stay in metadata as the fallback and the console needs no change.
1 parent 415254c commit 67452d1

15 files changed

Lines changed: 551 additions & 3 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/cli": minor
5+
"@objectstack/rest": patch
6+
---
7+
8+
feat(spec): resolve page metadata i18n — `page:header` title/subtitle (#3589)
9+
10+
Custom system pages authored as metadata (Installed Apps, Cloud Connection,
11+
Connect an Agent) hard-code their `page:header` copy in
12+
`properties.title` / `properties.subtitle`. Every other metadata type is
13+
localized at the REST boundary, but `page` was not: the `pages` namespace
14+
existed only on `AppTranslationBundleSchema` — a schema no runtime reads —
15+
with no resolver behind it, so those headers stayed English in every locale
16+
while the matching nav labels translated correctly.
17+
18+
- `TranslationDataSchema` (the shape the i18n service actually serves) gains a
19+
`pages` namespace: `pages.<name>.{label,description,title,subtitle}`.
20+
- New `translatePage` in `@objectstack/spec/system` translates a page's own
21+
`label` / `description` and overlays `title` / `subtitle` onto every
22+
`page:header` in the page's regions. Registered in
23+
`translateMetadataDocument`, so it rides the existing read path.
24+
- `page` added to the REST boundary's `TRANSLATABLE_META_TYPES`. Locale
25+
extraction, the locale-keyed ETag, and `Vary: Accept-Language` already
26+
covered every metadata type — no new plumbing.
27+
- `objectstack i18n extract` now emits page entries, including the
28+
`page:header` copy, so the new namespace is not invisible to the tooling.
29+
- zh-CN / ja-JP / es-ES translations shipped for the three Setup pages, plus
30+
the missing `nav_cloud_connection` / `nav_connect_agent` nav labels (these
31+
existed only in zh-CN).
32+
33+
Header copy is keyed by **page name**, not by component id: `page:header`
34+
instances carry no stable id. `title` falls back to `pages.<name>.label`, since
35+
a page's header title and its nav label are normally the same string.
36+
37+
Authoring is unchanged and English literals stay in metadata as the fallback —
38+
a page with no `pages` entry renders exactly as before. Consumers of
39+
`@object-ui` need no change: pages arrive already localized from the server.

content/docs/references/system/translation.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ Translation data for objects, apps, and UI messages
197197
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
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 |
200201
| **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 |
201202
| **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 |
202203
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |

content/docs/ui/translations.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,21 @@ export default defineStack({
7373
| Form sections | `objects.<name>._sections.<section>` |
7474
| App navigation | `apps.<app>.navigation.<id>.label` |
7575
| Dashboards and widgets | `dashboards.<name>` |
76+
| Page labels and `page:header` copy | `pages.<name>.label` / `description` / `title` / `subtitle` |
7677
| Global actions, settings, messages | `globalActions`, `settings`, `messages` |
7778

78-
The metadata types resolved per request are **object, view, action, app, and
79-
dashboard** — a field's labels are translated as part of its object document.
79+
The metadata types resolved per request are **object, view, action, app,
80+
dashboard, and page** — a field's labels are translated as part of its object
81+
document.
82+
83+
<Callout type="info">
84+
**Page headers are keyed by page name (#3589).** A page's `page:header`
85+
component has no stable id, so its `properties.title` / `properties.subtitle`
86+
are addressed through the page itself: `pages.<name>.title` / `subtitle`.
87+
`title` falls back to `pages.<name>.label`, so a page whose header title
88+
matches its nav label needs only `label`. Every `page:header` in the page's
89+
regions receives the same copy.
90+
</Callout>
8091

8192
<Callout type="info">
8293
**One-shot result dialogs are translatable (#3347).** The post-success

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
* apps.<app>.navigation.<id>.label
3636
* dashboards.<dash>.label / .description
3737
* dashboards.<dash>.widgets.<w>.title / .description
38+
* pages.<page>.label / .description
39+
* pages.<page>.title / .subtitle (from the page's `page:header` component)
3840
* metadataForms.<type>.label / .description
3941
* metadataForms.<type>.sections.<section>.label / .description
4042
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
@@ -72,6 +74,7 @@ export interface ExpectedEntry {
7274
| 'navigation'
7375
| 'dashboard'
7476
| 'widget'
77+
| 'page'
7578
| 'metadataType'
7679
| 'metadataFormSection'
7780
| 'metadataFormField';
@@ -396,6 +399,36 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
396399
}
397400
}
398401

402+
// ── Pages + their `page:header` copy ──────────────────────────────
403+
const pages: any[] = Array.isArray(config?.pages) ? config.pages : [];
404+
for (const page of pages) {
405+
if (!page?.name) continue;
406+
const name = page.name as string;
407+
if (page.label) pushEntry(out, ['pages', name, 'label'], page.label, 'page');
408+
if (page.description) {
409+
pushEntry(out, ['pages', name, 'description'], page.description, 'page');
410+
}
411+
// Header copy is authored inside the page's `page:header` component but
412+
// is addressed by page name — `translatePage` overlays it back onto every
413+
// header in the page's regions.
414+
const regions: any[] = Array.isArray(page.regions) ? page.regions : [];
415+
for (const region of regions) {
416+
const components: any[] = Array.isArray(region?.components) ? region.components : [];
417+
for (const component of components) {
418+
if (component?.type !== 'page:header') continue;
419+
const props = component.properties ?? {};
420+
// `title` duplicating `label` is the common case and resolves via the
421+
// label fallback — only emit it when the two genuinely differ.
422+
if (typeof props.title === 'string' && props.title && props.title !== page.label) {
423+
pushEntry(out, ['pages', name, 'title'], props.title, 'page');
424+
}
425+
if (typeof props.subtitle === 'string' && props.subtitle) {
426+
pushEntry(out, ['pages', name, 'subtitle'], props.subtitle, 'page');
427+
}
428+
}
429+
}
430+
}
431+
399432
// ── Metadata configuration forms (Studio admin UI) ────────────────
400433
// Registry-driven: always included, independent of stack config. These
401434
// emit under `metadataForms.<type>.*` so the generic renderer can pick

packages/cli/test/i18n-extract.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,61 @@ describe('collectExpectedEntries', () => {
171171
// Fields without a label emit nothing.
172172
expect(byPath[`${base}.fields.unlabeled`]).toBeUndefined();
173173
});
174+
175+
it('walks pages and their page:header copy (objectstack#3589)', () => {
176+
const pageConfig: any = {
177+
pages: [
178+
{
179+
name: 'connect_agent',
180+
label: 'Connect an Agent',
181+
description: 'Agent onboarding',
182+
regions: [
183+
{
184+
name: 'header',
185+
components: [
186+
{
187+
type: 'page:header',
188+
// `title` duplicates `label` — resolved by the label
189+
// fallback, so it must NOT emit its own entry.
190+
properties: { title: 'Connect an Agent', subtitle: 'Governed MCP access.', icon: 'bot' },
191+
},
192+
],
193+
},
194+
{ name: 'main', components: [{ type: 'mcp:connect-agent', properties: {} }] },
195+
],
196+
},
197+
{
198+
name: 'renamed_header',
199+
label: 'Nav Label',
200+
regions: [
201+
{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Different Title' } }] },
202+
],
203+
},
204+
{ name: 'bare_page', label: 'Bare' },
205+
],
206+
};
207+
const entries = collectExpectedEntries(pageConfig);
208+
const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue]));
209+
210+
expect(byPath['pages.connect_agent.label']).toBe('Connect an Agent');
211+
expect(byPath['pages.connect_agent.description']).toBe('Agent onboarding');
212+
expect(byPath['pages.connect_agent.subtitle']).toBe('Governed MCP access.');
213+
// title === label → no redundant entry for translators to fill twice.
214+
expect(byPath['pages.connect_agent.title']).toBeUndefined();
215+
// A header title that genuinely differs from the label does emit.
216+
expect(byPath['pages.renamed_header.title']).toBe('Different Title');
217+
// Non-header components and non-translatable props (icon) contribute
218+
// nothing — the page namespace holds only the four translatable keys.
219+
const pagePaths = Object.keys(byPath).filter((p) => p.startsWith('pages.'));
220+
expect(pagePaths.sort()).toEqual([
221+
'pages.bare_page.label',
222+
'pages.connect_agent.description',
223+
'pages.connect_agent.label',
224+
'pages.connect_agent.subtitle',
225+
'pages.renamed_header.label',
226+
'pages.renamed_header.title',
227+
]);
228+
});
174229
});
175230

176231
describe('extractTranslations', () => {

packages/platform-objects/src/apps/translations/en.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const en: TranslationData = {
6060
// Apps / Marketplace
6161
nav_marketplace_browse: { label: 'Browse Marketplace' },
6262
nav_marketplace_installed: { label: 'Installed Apps' },
63+
nav_cloud_connection: { label: 'Cloud Connection' },
6364

6465
// People & Organization
6566
nav_users: { label: 'Users' },
@@ -75,6 +76,7 @@ export const en: TranslationData = {
7576
nav_sharing_rules: { label: 'Sharing Rules' },
7677
nav_record_shares: { label: 'Record Shares' },
7778
nav_api_keys: { label: 'API Keys' },
79+
nav_connect_agent: { label: 'Connect an Agent' },
7880

7981
// Approvals
8082
nav_approval_processes: { label: 'Processes' },
@@ -195,4 +197,27 @@ export const en: TranslationData = {
195197
},
196198
},
197199
},
200+
201+
// Setup pages contributed as metadata by capability plugins. The English
202+
// entries mirror the literals authored in the plugins' page metadata
203+
// (@objectstack/cloud-connection, @objectstack/mcp) and exist so the other
204+
// locales have a complete key set to translate against.
205+
pages: {
206+
marketplace_installed: {
207+
label: 'Installed Apps',
208+
subtitle: "Marketplace packages currently installed into this runtime's kernel.",
209+
},
210+
cloud_connection_settings: {
211+
label: 'Cloud Connection',
212+
subtitle:
213+
'Connect this runtime to an ObjectStack control plane to browse your '
214+
+ "organization's private packages and install them here.",
215+
},
216+
connect_agent: {
217+
label: 'Connect an Agent',
218+
subtitle:
219+
'Give any MCP-capable AI client governed access to this environment — '
220+
+ "every call runs under the caller's own permissions and row-level security.",
221+
},
222+
},
198223
};

packages/platform-objects/src/apps/translations/es-ES.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const esES: TranslationData = {
3434
group_apps: { label: 'Aplicaciones' },
3535
nav_marketplace_browse: { label: 'Explorar Marketplace' },
3636
nav_marketplace_installed: { label: 'Aplicaciones instaladas' },
37+
nav_cloud_connection: { label: 'Conexión a la nube' },
3738
group_people_org: { label: 'Personas y Organización' },
3839
group_access_control: { label: 'Control de Acceso' },
3940
group_approvals: { label: 'Aprobaciones' },
@@ -56,6 +57,7 @@ export const esES: TranslationData = {
5657
nav_sharing_rules: { label: 'Reglas de Compartición' },
5758
nav_record_shares: { label: 'Registros Compartidos' },
5859
nav_api_keys: { label: 'Claves API' },
60+
nav_connect_agent: { label: 'Conectar un agente' },
5961

6062
nav_approval_processes: { label: 'Procesos' },
6163
nav_approval_requests: { label: 'Solicitudes' },
@@ -141,4 +143,23 @@ export const esES: TranslationData = {
141143
},
142144
},
143145
},
146+
147+
pages: {
148+
marketplace_installed: {
149+
label: 'Aplicaciones instaladas',
150+
subtitle: 'Paquetes del marketplace instalados actualmente en el kernel de este runtime.',
151+
},
152+
cloud_connection_settings: {
153+
label: 'Conexión a la nube',
154+
subtitle:
155+
'Conecta este runtime a un plano de control de ObjectStack para explorar los paquetes '
156+
+ 'privados de tu organización e instalarlos aquí.',
157+
},
158+
connect_agent: {
159+
label: 'Conectar un agente',
160+
subtitle:
161+
'Concede a cualquier cliente de IA compatible con MCP acceso controlado a este entorno: '
162+
+ 'cada llamada se ejecuta con los permisos propios de quien la realiza y con seguridad a nivel de fila.',
163+
},
164+
},
144165
};

packages/platform-objects/src/apps/translations/ja-JP.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const jaJP: TranslationData = {
3434
group_apps: { label: 'アプリ' },
3535
nav_marketplace_browse: { label: 'マーケットプレイスを閲覧' },
3636
nav_marketplace_installed: { label: 'インストール済みアプリ' },
37+
nav_cloud_connection: { label: 'クラウド接続' },
3738
group_people_org: { label: 'ユーザーと組織' },
3839
group_access_control: { label: 'アクセス制御' },
3940
group_approvals: { label: '承認' },
@@ -56,6 +57,7 @@ export const jaJP: TranslationData = {
5657
nav_sharing_rules: { label: '共有ルール' },
5758
nav_record_shares: { label: 'レコード共有' },
5859
nav_api_keys: { label: 'API キー' },
60+
nav_connect_agent: { label: 'エージェントを接続' },
5961

6062
nav_approval_processes: { label: 'プロセス' },
6163
nav_approval_requests: { label: 'リクエスト' },
@@ -141,4 +143,21 @@ export const jaJP: TranslationData = {
141143
},
142144
},
143145
},
146+
147+
pages: {
148+
marketplace_installed: {
149+
label: 'インストール済みアプリ',
150+
subtitle: 'このランタイムのカーネルに現在インストールされているマーケットプレイスパッケージ。',
151+
},
152+
cloud_connection_settings: {
153+
label: 'クラウド接続',
154+
subtitle:
155+
'このランタイムを ObjectStack コントロールプレーンに接続すると、組織のプライベートパッケージを閲覧してここにインストールできます。',
156+
},
157+
connect_agent: {
158+
label: 'エージェントを接続',
159+
subtitle:
160+
'MCP 対応の AI クライアントにこの環境への統制されたアクセスを許可します。すべての呼び出しは、呼び出し元自身の権限と行レベルセキュリティのもとで実行されます。',
161+
},
162+
},
144163
};

packages/platform-objects/src/apps/translations/zh-CN.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,19 @@ export const zhCN: TranslationData = {
146146
},
147147
},
148148
},
149+
150+
pages: {
151+
marketplace_installed: {
152+
label: '已安装应用',
153+
subtitle: '当前已安装到此运行时内核的应用市场包。',
154+
},
155+
cloud_connection_settings: {
156+
label: '云连接',
157+
subtitle: '将此运行时连接到 ObjectStack 控制平面,即可浏览并安装组织的私有包。',
158+
},
159+
connect_agent: {
160+
label: '连接智能体',
161+
subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境——每次调用都在调用者自身的权限与行级安全范围内执行。',
162+
},
163+
},
149164
};

packages/rest/src/rest-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (g
4848
* via `translateMetadataDocument`. Keep in sync with the type dispatch in
4949
* `@objectstack/spec/system`'s `translateMetadataDocument`.
5050
*/
51-
const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard']);
51+
const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard', 'page']);
5252

5353

5454
/**

0 commit comments

Comments
 (0)