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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/page-header-i18n-3589.md
Original file line number Diff line number Diff line change
@@ -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.<name>.{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.<name>.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.
1 change: 1 addition & 0 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ Translation data for objects, apps, and UI messages
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
| **globalActions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string; params?: Record<string, { label?: string; helpText?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Global action translations keyed by action name |
| **dashboards** | `Record<string, { label?: string; description?: string; actions?: Record<string, { label?: string }>; widgets?: Record<string, { title?: string; description?: string }> }>` | optional | Dashboard translations keyed by dashboard name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string }>` | optional | Page translations keyed by page name |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, { title?: string; description?: string }>; keys?: Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>; … }>` | optional | Settings manifest translations keyed by namespace |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, { label?: string; description?: string }>; fields?: Record<string, { label?: string; helpText?: string; placeholder?: string }> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
Expand Down
15 changes: 13 additions & 2 deletions content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,21 @@ export default defineStack({
| Form sections | `objects.<name>._sections.<section>` |
| App navigation | `apps.<app>.navigation.<id>.label` |
| Dashboards and widgets | `dashboards.<name>` |
| Page labels and `page:header` copy | `pages.<name>.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.

<Callout type="info">
**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.<name>.title` / `subtitle`.
`title` falls back to `pages.<name>.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.
</Callout>

<Callout type="info">
**One-shot result dialogs are translatable (#3347).** The post-success
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
* apps.<app>.navigation.<id>.label
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* pages.<page>.label / .description
* pages.<page>.title / .subtitle (from the page's `page:header` component)
* metadataForms.<type>.label / .description
* metadataForms.<type>.sections.<section>.label / .description
* metadataForms.<type>.fields.<dotPath>.label / .helpText / .placeholder
Expand Down Expand Up @@ -72,6 +74,7 @@ export interface ExpectedEntry {
| 'navigation'
| 'dashboard'
| 'widget'
| 'page'
| 'metadataType'
| 'metadataFormSection'
| 'metadataFormField';
Expand Down Expand Up @@ -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.<type>.*` so the generic renderer can pick
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/test/i18n-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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.",
},
},
};
21 changes: 21 additions & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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.',
},
},
};
19 changes: 19 additions & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '承認' },
Expand All @@ -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: 'リクエスト' },
Expand Down Expand Up @@ -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 クライアントにこの環境への統制されたアクセスを許可します。すべての呼び出しは、呼び出し元自身の権限と行レベルセキュリティのもとで実行されます。',
},
},
};
15 changes: 15 additions & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 客户端受控访问此环境——每次调用都在调用者自身的权限与行级安全范围内执行。',
},
},
};
2 changes: 1 addition & 1 deletion packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);


/**
Expand Down
Loading
Loading