Skip to content

Commit 9e6767a

Browse files
committed
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, so those headers stayed English in every locale while the matching nav labels translated correctly. The `pages` namespace existed only on `AppTranslationBundleSchema` — a schema no runtime reads — with no resolver behind it. This wires the read side and the schema together rather than adding another namespace nothing consumes. - `TranslationDataSchema` (the shape the i18n service actually serves) gains `pages.<name>.{label,description,title,subtitle}`. - `translatePage` translates a page's own label/description and overlays title/subtitle onto every `page:header` in its 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, so there is no new plumbing. - `objectstack i18n extract` emits page entries, so the new namespace is not invisible to the tooling. - zh-CN / ja-JP / es-ES copy for the three Setup pages, plus the missing `nav_cloud_connection` / `nav_connect_agent` labels (zh-CN-only until now). Header copy is keyed by page name, not component id: `page:header` instances carry no stable id. `title` falls back to `pages.<name>.label`, since a page's header title and its nav label are normally the same string. English literals stay in metadata as the fallback — a page with no `pages` entry renders exactly as before — and the console needs no change, since pages arrive already localized from the server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TubWYdWquVkS9dj733sDmC
1 parent 6ba3788 commit 9e6767a

12 files changed

Lines changed: 533 additions & 1 deletion

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.

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
/**

packages/rest/src/rest.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2535,6 +2535,65 @@ describe('RestServer metadata translation — envelope unwrap', () => {
25352535
});
25362536
});
25372537

2538+
// ──────────────────────────────────────────────────────────────────────────
2539+
// Page metadata translation (objectstack#3589)
2540+
//
2541+
// Plugin-carried Setup pages hard-code their `page:header` copy in metadata.
2542+
// `page` was missing from TRANSLATABLE_META_TYPES, so the REST boundary
2543+
// returned those pages untranslated no matter what the bundle held.
2544+
// ──────────────────────────────────────────────────────────────────────────
2545+
describe('RestServer metadata translation — page documents', () => {
2546+
const fakeI18n = {
2547+
getLocales: () => ['zh-CN'],
2548+
getDefaultLocale: () => 'zh-CN',
2549+
getTranslations: (locale: string) =>
2550+
locale === 'zh-CN'
2551+
? {
2552+
pages: {
2553+
connect_agent: {
2554+
label: '连接智能体',
2555+
subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境。',
2556+
},
2557+
},
2558+
}
2559+
: undefined,
2560+
};
2561+
const zhReq = { headers: { 'accept-language': 'zh-CN' } };
2562+
const makePage = () => ({
2563+
name: 'connect_agent',
2564+
label: 'Connect an Agent',
2565+
regions: [
2566+
{
2567+
name: 'header',
2568+
components: [
2569+
{
2570+
type: 'page:header',
2571+
properties: { title: 'Connect an Agent', subtitle: 'Give any MCP-capable client…', icon: 'bot' },
2572+
},
2573+
],
2574+
},
2575+
],
2576+
});
2577+
2578+
it('translates the page:header copy inside a getMetaItem envelope', async () => {
2579+
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any);
2580+
const envelope = { type: 'page', name: 'connect_agent', item: makePage(), lock: null };
2581+
const out = await (rest as any).translateMetaItem(zhReq, 'page', undefined, envelope, fakeI18n);
2582+
expect(out.name).toBe('connect_agent');
2583+
expect(out.item.label).toBe('连接智能体');
2584+
expect(out.item.regions[0].components[0].properties.title).toBe('连接智能体');
2585+
expect(out.item.regions[0].components[0].properties.subtitle).toBe('让任意支持 MCP 的 AI 客户端受控访问此环境。');
2586+
expect(out.item.regions[0].components[0].properties.icon).toBe('bot');
2587+
});
2588+
2589+
it('translates page documents in a list response', async () => {
2590+
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any);
2591+
(rest as any).resolveI18nService = async () => fakeI18n;
2592+
const out = await (rest as any).translateMetaItems(zhReq, 'page', undefined, [makePage()]);
2593+
expect(out[0].regions[0].components[0].properties.title).toBe('连接智能体');
2594+
});
2595+
});
2596+
25382597
// ---------------------------------------------------------------------------
25392598
// ADR-0045 — hidden-app visibility gate (filterAppForUser)
25402599
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)