From 4c9a7d4eaa1b6a15f43ff86850e9b2649324c97d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:29:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20one=20translation=20shape=20?= =?UTF-8?q?=E2=80=94=20the=20`translation`=20type=20speaks=20`objects.`,?= =?UTF-8?q?=20not=20`o.`=20(#3778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A translation authored in the product saved successfully and rendered nothing. Not a resolver gap — a contract split. The `translation` metadata type (`allowRuntimeCreate: true`) was registered against `AppTranslationBundleSchema`, an object-first shape keyed on `o.`. Every resolver, `os i18n extract`, `os i18n check`, the objectui hooks, and all nine shipped bundles read `objects.`. Nothing bridged them, so the save path and the read path never met. A converter was the obvious fix and the wrong one: throwaway code that would start producing *working* `o.`-shaped rows, closing the migration-free window that exists precisely because the feature never functioned. The retired shape's real-world footprint was zero — all three `*.translation.ts` files in the tree were already `objects.`-shaped, contradicting the type's own registered schema. Converging is a registration fix, not a migration. BREAKING: `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema` and their types are deleted with no deprecation cycle — nothing worked end-to-end through them, so there is no consumer to protect, and a deprecated-but-present schema is exactly the exemplar an agent copies into new code. `II18nService.getAppBundle` / `loadAppBundle` go with them (zero implementers — a capability the runtime never delivered). `TranslationItemSchema` replaces them: one locale of the same `TranslationData` groups a file bundle uses, plus the `locale` it translates, with a `defineTranslation()` factory. Three details are deliberate, all aimed at making the failure loud instead of silent: - `locale` is required, not inferred from the item name. The sync skips an item whose locale it cannot resolve, and a skip is invisible to whoever authored it. - Retired keys are rejected, not stripped. Zod drops undeclared keys silently, which would reproduce this bug exactly. A pre-parse guard turns that silence into a 422 naming the right group, and runs ahead of the parse so the keys stay out of the schema — the generated JSON Schema and the Studio editor never advertise a shape that cannot work. - `ObjectTranslationData.label` becomes optional. Partial translation is the normal state; requiring it forced authors to restate the source label just to validate, filling bundles with fake translations that mask coverage gaps. Also here: the authored sync warns (naming the row and the fix) on a row still in the retired shape instead of loading it into nowhere, and no longer merges publish bookkeeping into the translation layer. `GET /i18n/labels/:object/:locale` reads the nested field data it is actually given — it scanned for flat dotted `o..fields.` keys, a third dialect no producer ever wrote, so it always returned `{}`. A `translation` create seed makes the Studio create flow round-trip. Docs, the i18n skill, and the sweep-test fixture no longer teach the retired shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017wPvotUdM6WFtJm1KKryLE --- .../i18n-translation-item-shape-3778.md | 70 ++++ .../docs/protocol/kernel/i18n-standard.mdx | 70 +++- content/docs/references/api/protocol.mdx | 2 +- .../docs/references/system/translation.mdx | 79 ++-- content/docs/releases/v15.mdx | 2 +- content/docs/ui/translations.mdx | 41 +- .../fallbacks/authored-translation-sync.ts | 49 ++- .../src/metadata-validation-sweep.test.ts | 9 +- .../src/authored-translations.test.ts | 105 +++++- .../src/i18n-service-plugin.test.ts | 15 +- .../service-i18n/src/i18n-service-plugin.ts | 18 +- packages/spec/api-surface.json | 9 +- packages/spec/json-schema.manifest.json | 3 +- .../spec/src/contracts/i18n-service.test.ts | 41 +- packages/spec/src/contracts/i18n-service.ts | 23 +- .../src/kernel/metadata-create-seeds.test.ts | 2 +- .../spec/src/kernel/metadata-create-seeds.ts | 10 + .../spec/src/kernel/metadata-type-schemas.ts | 4 +- packages/spec/src/system/translation.test.ts | 349 ++++++------------ packages/spec/src/system/translation.zod.ts | 308 ++++++---------- skills/objectstack-i18n/SKILL.md | 96 ++--- 21 files changed, 646 insertions(+), 659 deletions(-) create mode 100644 .changeset/i18n-translation-item-shape-3778.md diff --git a/.changeset/i18n-translation-item-shape-3778.md b/.changeset/i18n-translation-item-shape-3778.md new file mode 100644 index 0000000000..e31fc81382 --- /dev/null +++ b/.changeset/i18n-translation-item-shape-3778.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": minor +"@objectstack/core": minor +"@objectstack/service-i18n": minor +--- + +fix(i18n)!: the `translation` metadata type speaks the same `objects.` shape everything else does (#3778) + +A translation authored in the product saved successfully and then rendered +nothing. Not a resolver gap — a contract split. The `translation` metadata type +(`allowRuntimeCreate: true`, so Studio/the metadata API/an agent can author it) +was registered against `AppTranslationBundleSchema`, an object-first shape keyed +on `o.`. Every resolver, `os i18n extract`, `os i18n check`, the objectui +hooks, and all nine shipped bundles read `objects.`. Nothing bridged the +two, so the save path and the read path never met. + +**Why converge instead of bridge.** A converter was the obvious fix and the +wrong one: it would be throwaway code, and it would start producing *working* +`o.`-shaped rows — closing the migration-free window that exists precisely +because the feature never functioned. The retired shape's real-world footprint +was zero: all three `*.translation.ts` files in the tree (platform-objects, +CRM and todo examples) were already `objects.`-shaped, contradicting the type's +own registered schema. Converging is a registration fix, not a migration. + +**Breaking.** `AppTranslationBundleSchema`, `ObjectTranslationNodeSchema`, and +their types are **deleted** — no deprecation cycle. Nothing worked end-to-end +through them, so there is no functioning consumer to protect, and a +deprecated-but-present schema is exactly the exemplar an AI agent copies into +new code. The optional `II18nService.getAppBundle` / `loadAppBundle` methods go +with them: zero implementers, so they advertised a capability the runtime never +delivered. + +**The replacement.** `TranslationItemSchema` — one locale of the same +`TranslationData` groups a file bundle uses, plus the `locale` it translates, +with a `defineTranslation()` factory. An item is one entry of a +`TranslationBundle`; that is the whole type. + +Three details are deliberate, all aimed at the failure being silent rather than +loud: + +- **`locale` is required**, not inferred from the item name. The sync skips an + item whose locale it cannot resolve, and a skip is invisible to whoever — or + whatever — authored it. (The name fallback still covers rows written before + this.) +- **Retired keys are rejected, not stripped.** Zod drops undeclared keys + silently, which would reproduce this bug exactly: save succeeds, nothing + renders. A pre-parse guard turns that silence into a 422 naming the group to + use (`'o' … — use 'objects.'`). It runs ahead of the parse so the + retired keys stay out of the schema itself — the generated JSON Schema and the + Studio editor never advertise a shape that cannot work. +- **`ObjectTranslationData.label` is now optional.** Partial translation is the + normal state and every resolver already treats each key as independent. + Requiring it forced authors to restate the source label just to validate, + filling bundles with fake translations that mask real coverage gaps. + +Also in this change: the authored-translation sync warns (naming the row and the +fix) when it meets a row still in the retired shape instead of loading it into +nowhere, and no longer merges publish bookkeeping (`_lockReason`, +`_packageVersion`, …) into the translation layer. `GET +/i18n/labels/:object/:locale`'s fallback now reads the nested +`objects..fields..label` data it is actually given — it scanned for +flat dotted `o..fields.` keys, a third dialect no producer ever +wrote, so it always returned `{}`. + +Migration: author every translation — file or runtime item — under `objects.`. +`o` → `objects`, `app` → `apps`, `nav` → `apps..navigation..label`, +`dashboard` → `dashboards`, `_globalOptions` → +`objects..fields..options`, `_meta.locale` → top-level `locale`, +`_actions.confirmMessage` → `_actions.confirmText`. `reports`, `notifications`, +`errors`, and `namespace` had no runtime consumer and have no replacement. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index ca7a3e5efc..6cd3a0267a 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -154,19 +154,24 @@ Fallback to: en (system default) ✓ Translations are stored in **JSON files** organized by locale and namespace. -### Object-First Convention (Recommended) +### Object-First Convention ObjectStack uses an **object-first** convention where all translatable metadata -for an object is aggregated under `o.{object_name}`. Global (non-object-bound) -translations remain in dedicated top-level groups. This aligns with Salesforce DX -and Dynamics conventions, enabling efficient translation workbench editing -and automated coverage detection. +for an object is aggregated under `objects.{object_name}`. Global +(non-object-bound) translations remain in dedicated top-level groups. This +aligns with Salesforce DX and Dynamics conventions, enabling efficient +translation workbench editing and automated coverage detection. + +There is exactly **one** shape. A file-authored bundle is a map of locale code → +`TranslationData`; a `translation` metadata item authored at runtime is one +`TranslationData` plus the `locale` it translates. The resolvers, `os i18n +extract`, `os i18n check`, and the Studio editor all read the same keys. ```typescript -// AppTranslationBundle for a single locale (e.g. zh-CN) -const zh: AppTranslationBundle = { +// One locale of a TranslationBundle (e.g. zh-CN) +const zh: TranslationData = { // ── Object-first translations ───────────────────────────────── - o: { + objects: { account: { label: '客户', pluralLabel: '客户', @@ -174,21 +179,19 @@ const zh: AppTranslationBundle = { fields: { name: { label: '客户名称', help: '公司法定名称' }, industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, + status: { options: { active: '活跃', inactive: '停用' } }, }, - _options: { status: { active: '活跃', inactive: '停用' } }, _views: { all_accounts: { label: '全部客户' } }, _sections: { basic_info: { label: '基本信息' } }, - _actions: { convert: { label: '转换', confirmMessage: '确认转换?' } }, + _actions: { convert: { label: '转换', confirmText: '确认转换?' } }, }, }, // ── Global translations ─────────────────────────────────────── - _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, - app: { crm: { label: '客户关系管理' } }, - nav: { home: '首页', settings: '设置' }, - dashboard: { sales_overview: { label: '销售概览' } }, - reports: { pipeline_report: { label: '管道报表' } }, + apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } }, + dashboards: { sales_overview: { label: '销售概览' } }, pages: { landing: { title: '欢迎' } }, + globalActions: { export_csv: { label: '导出 CSV' } }, messages: { 'common.save': '保存' }, validationMessages: { 'discount_limit': '折扣不能超过40%' }, }; @@ -198,7 +201,42 @@ const zh: AppTranslationBundle = { - ✅ All translatable content for one object in one place - ✅ CLI can generate translation skeletons per object - ✅ Workbench can show per-object coverage and diffs -- ✅ No redundant category/fieldOptions/reports nodes +- ✅ One shape for files and runtime authoring — what you author is what renders + + + **Retired: the `o.{object}` dialect.** A second, object-first shape keyed on + `o.` (with `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`) was once + documented here for runtime-authored translations. No resolver ever read it, + so translations authored in that shape saved successfully and rendered + nothing. It was removed in #3778; the metadata door now rejects those keys + with a message naming the group to use instead. Author everything — + files and runtime items alike — under `objects.`. + + +### Authoring a `translation` item at runtime + +An admin (or an agent using the metadata API) authors one item per locale. +The only difference from a file bundle is the top-level `locale`: + +```typescript +import { defineTranslation } from '@objectstack/spec/system'; + +export default defineTranslation({ + locale: 'zh-CN', + objects: { + account: { + label: '客户', + fields: { name: { label: '客户名称' } }, + }, + }, + messages: { 'common.save': '保存' }, +}); +``` + +`locale` is required — the runtime sync skips an item whose locale it cannot +resolve, and a silent skip is exactly what makes a missing translation hard to +diagnose. Items are merged over the static file bundles, so an authored value +wins over a shipped one for the same key. ### Directory Structure diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ac7b9fab4c..a8953e7331 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -724,7 +724,7 @@ const result = AiInsightsRequest.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **locale** | `string` | ✅ | Locale code | -| **translations** | `{ objects?: Record }>; … }>; apps?: Record }>; messages?: Record; validationMessages?: Record; … }` | ✅ | Translation data | +| **translations** | `{ objects?: Record }>; … }>; apps?: Record }>; messages?: Record; validationMessages?: Record; … }` | ✅ | Translation data | --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index bcd40706fc..8660e32726 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -16,8 +16,8 @@ Translation data for a single field. ## TypeScript Usage ```typescript -import { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus } from '@objectstack/spec/system'; -import type { ActionResultDialogTranslation, AppTranslationBundle, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, ObjectTranslationNode, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus } from '@objectstack/spec/system'; +import { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; +import type { ActionResultDialogTranslation, CoverageBreakdownEntry, FieldTranslation, Locale, ObjectTranslationData, TranslationBundle, TranslationConfig, TranslationCoverageResult, TranslationData, TranslationDiffItem, TranslationDiffStatus, TranslationItem } from '@objectstack/spec/system'; // Validate data const result = ActionResultDialogTranslation.parse(data); @@ -39,31 +39,6 @@ Translations for an action result dialog | **fields** | `Record` | optional | Result field labels keyed by the literal field path declared in the action metadata (keys may contain dots) | ---- - -## AppTranslationBundle - -Object-first application translation bundle for a single locale - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **_meta** | `{ locale?: string; direction?: Enum<'ltr' \| 'rtl'> }` | optional | Bundle-level metadata (locale, bidi direction) | -| **namespace** | `string` | optional | Namespace for plugin isolation to avoid translation key collisions | -| **o** | `Record` | optional | Object-first translations keyed by object name | -| **_globalOptions** | `Record>` | optional | Global picklist option translations keyed by option set name | -| **app** | `Record` | optional | App translations keyed by app name | -| **nav** | `Record` | optional | Navigation item translations keyed by nav item name | -| **dashboard** | `Record` | optional | Dashboard translations keyed by dashboard name | -| **reports** | `Record` | optional | Report translations keyed by report name | -| **pages** | `Record` | optional | Page translations keyed by page name | -| **messages** | `Record` | optional | UI message translations keyed by message ID (supports ICU MessageFormat) | -| **validationMessages** | `Record` | optional | Validation error message translations keyed by rule name (supports ICU MessageFormat) | -| **notifications** | `Record` | optional | Global notification translations keyed by notification name | -| **errors** | `Record` | optional | Global error message translations keyed by error code | - - --- ## CoverageBreakdownEntry @@ -109,7 +84,7 @@ Translation data for a single object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **label** | `string` | ✅ | Translated singular label | +| **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record }>` | optional | Field-level translations | @@ -118,29 +93,6 @@ Translation data for a single object | **_sections** | `Record` | optional | Section translations keyed by section name | ---- - -## ObjectTranslationNode - -Object-first aggregated translation node - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **label** | `string` | ✅ | Translated singular label | -| **pluralLabel** | `string` | optional | Translated plural label | -| **description** | `string` | optional | Translated object description | -| **helpText** | `string` | optional | Translated help text for the object | -| **fields** | `Record }>` | optional | Field translations keyed by field name | -| **_options** | `Record>` | optional | Object-scoped picklist option translations keyed by field name | -| **_views** | `Record` | optional | View translations keyed by view name | -| **_sections** | `Record` | optional | Section translations keyed by section name | -| **_actions** | `Record }>; resultDialog?: object }>` | optional | Action translations keyed by action name | -| **_notifications** | `Record` | optional | Notification translations keyed by notification name | -| **_errors** | `Record` | optional | Error message translations keyed by error code | - - --- @@ -191,7 +143,7 @@ Translation data for objects, apps, and UI messages | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **objects** | `Record }>; … }>` | optional | Object translations keyed by object name | +| **objects** | `Record }>; … }>` | optional | Object translations keyed by object name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | | **validationMessages** | `Record` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) | @@ -237,3 +189,26 @@ Translation diff status: missing from bundle, redundant (no matching metadata), --- +## TranslationItem + +One locale of translations — the `translation` metadata type + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objects** | `Record }>; … }>` | optional | Object translations keyed by object name | +| **apps** | `Record }>` | optional | App translations keyed by app name | +| **messages** | `Record` | optional | UI message translations keyed by message ID | +| **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 | +| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | + + +--- + diff --git a/content/docs/releases/v15.mdx b/content/docs/releases/v15.mdx index 6cbc6860d8..b5a35c7027 100644 --- a/content/docs/releases/v15.mdx +++ b/content/docs/releases/v15.mdx @@ -532,7 +532,7 @@ search guide with `sys_user`/picker coverage and existing-row backfill notes ### i18n - **`os i18n extract` emits action-param keys** - (`o.._actions..params..*`), so action dialog forms — + (`objects.._actions..params..*`), so action dialog forms — e.g. Setup → Create User — are translatable; platform-objects bundles regenerated for en/zh-CN/ja-JP/es-ES (#3030, #3033). - **Audit activity summaries localize to the workspace locale** (ADR-0053 verb diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 1e0d25800d..5c40c51f4b 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -137,6 +137,39 @@ register. Common layouts: for two locales; unwieldy past that. - **per namespace** — split by module. +## Authoring in the product + +Files are not the only door. A `translation` metadata item — created in the +Studio, through the metadata API, or by an agent — carries one locale's worth +of the **same** groups a file bundle uses, plus the `locale` it translates: + +```ts +import { defineTranslation } from '@objectstack/spec/system'; + +export default defineTranslation({ + locale: 'zh-CN', + objects: { + crm_account: { + label: '客户', + fields: { name: { label: '客户名称' } }, + }, + }, +}); +``` + +Published items are picked up at boot and again on every publish, without a +restart. They layer **over** the file bundles, so an authored value wins over a +shipped one for the same key, and deleting the item restores the shipped value. + +Two things to know: + +- `locale` is required. An item whose locale can't be resolved is skipped, and + a silent skip is the hardest kind of missing translation to diagnose. +- Only the groups on this page are accepted. Keys from the retired `o.` + shape (`o`, `app`, `nav`, `dashboard`, `_globalOptions`, `_meta`, …) are + rejected at save time with a message naming the group to use instead — they + used to save cleanly and then render nothing (#3778). + ## Draft with the CLI, gate in CI ```bash @@ -198,10 +231,10 @@ Honest limits worth knowing before you plan around them: errors are not translated through bundles yet. - **No ICU MessageFormat** — plural/gender formatting isn't available; interpolation is always simple `{variable}` substitution. -- **Two bundle shapes exist.** File-authored bundles use the `objects.` - shape documented here, which is what the resolver reads. The `translation` - metadata type authored at runtime uses an object-first (`o.`) shape, - and the two are not bridged — author translations **as files** for now. +- **Runtime authoring is process-wide.** The authored layer is synced across all + organizations into one i18n map, so a `translation` item published in one + tenant resolves in every tenant on the same process. Ship per-tenant + translations as files until this is scoped. - AI-suggested translation fields (`aiSuggested`, `aiConfidence`) are schema only. diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts index c88737c428..73515e63a2 100644 --- a/packages/core/src/fallbacks/authored-translation-sync.ts +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -18,12 +18,16 @@ * computes the full authored layer from the rows and REPLACES it wholesale * (clear-then-reload), so deleted items/keys stop resolving. * - * Item payload is a single-locale `AppTranslationBundle` (the `translation` - * type's canonical schema). Locale resolution, in order: `_meta.locale`, a - * top-level `locale` string, then the item name when it looks like a BCP-47 - * tag (an item named `zh-CN` translates that locale). Items with no - * resolvable locale are skipped with a warning. Multiple items on one locale - * deep-merge in name order (deterministic). + * Item payload is a single-locale `TranslationItem` — the same `objects.` + * groups the file-authored bundles use, plus the `locale` it translates + * (#3778; before that, this type was registered against an object-first + * `o.` dialect no resolver read, so an authored translation saved + * cleanly and rendered nothing). Locale resolution: the top-level `locale`, + * then the item name when it looks like a BCP-47 tag — the name fallback + * covers rows written before `locale` became required. Rows still carrying + * the retired shape are skipped with a warning naming the row, since their + * content can never resolve. Multiple items on one locale deep-merge in name + * order (deterministic). * * Trigger points (wired by {@link wireAuthoredTranslationSync}): * • `kernel:ready` — cold-boot coverage; @@ -39,6 +43,8 @@ * the currently applied authored layer. */ +import { LEGACY_OBJECT_FIRST_KEYS } from '@objectstack/spec/system'; + import { deepMerge } from './memory-i18n.js'; type AnyRecord = Record; @@ -99,20 +105,41 @@ export async function readAuthoredTranslationLayer( continue; // malformed row — skip it, keep the rest } if (!data || typeof data !== 'object') continue; + + // Rows written against the retired object-first shape resolve to nothing + // no matter what locale they claim, so say that plainly rather than + // letting them look loaded. New saves are rejected at the metadata door + // by `TranslationItemSchema`; this covers rows that predate it. + const legacyKeys = LEGACY_OBJECT_FIRST_KEYS.filter((key) => data[key] !== undefined); + if (legacyKeys.length > 0) { + logger?.warn?.( + `[i18n] authored translation '${row?.name}' uses the retired object-first shape ` + + `(${legacyKeys.join(', ')}) — nothing resolves from it; re-author it under ` + + "'objects.' with a top-level 'locale' — skipped", + ); + continue; + } + const locale: string | undefined = - (typeof data?._meta?.locale === 'string' && data._meta.locale) - || (typeof data?.locale === 'string' && data.locale) + (typeof data?.locale === 'string' && data.locale) || (typeof row?.name === 'string' && LOCALE_LIKE.test(row.name) ? row.name : undefined) || undefined; if (!locale) { logger?.warn?.( `[i18n] authored translation '${row?.name}' has no resolvable locale ` - + '(set _meta.locale, or name the item after its BCP-47 locale) — skipped', + + "(set the top-level 'locale', or name the item after its BCP-47 locale) — skipped", ); continue; } - // Strip authoring bookkeeping; everything else is translation data. - const { name: _n, locale: _l, _packageId: _p, _provenance: _pr, _lock: _lk, ...payload } = data; + // Strip authoring bookkeeping; everything else is translation data. The + // lock/package fields are stamped by the metadata protocol on published + // rows — merging them would seed junk keys into the i18n layer. + const { + name: _n, locale: _l, + _packageId: _p, _packageVersion: _pv, _provenance: _pr, + _lock: _lk, _lockReason: _lr, _lockDocsUrl: _ld, _lockSource: _ls, + ...payload + } = data; byLocale[locale] = deepMerge(byLocale[locale] ?? {}, payload as Record); } return byLocale; diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 5c9170d5a7..3379d88006 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -184,11 +184,14 @@ const FIXTURES: Record = { }, translation: { valid: { - app: { sweep_app: { label: 'Sweep' } }, + locale: 'en', + apps: { sweep_app: { label: 'Sweep' } }, messages: { hello: 'Hello' }, }, - invalid: { app: 'not-a-record' }, - invalidatedField: 'app', + // `locale` is required — omitting it is the realistic authoring miss + // (the sync silently skips a locale-less item, so the door catches it). + invalid: { apps: { sweep_app: { label: 'Sweep' } } }, + invalidatedField: 'locale', }, email_template: { valid: { diff --git a/packages/services/service-i18n/src/authored-translations.test.ts b/packages/services/service-i18n/src/authored-translations.test.ts index b51f3fcf7b..0633b48a66 100644 --- a/packages/services/service-i18n/src/authored-translations.test.ts +++ b/packages/services/service-i18n/src/authored-translations.test.ts @@ -15,6 +15,8 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { translateMetadataDocument } from '@objectstack/spec/system'; +import type { TranslationBundle } from '@objectstack/spec/system'; import { FileI18nAdapter } from './file-i18n-adapter.js'; import { I18nServicePlugin } from './i18n-service-plugin.js'; @@ -136,21 +138,17 @@ describe('I18nServicePlugin authored-translation sync (#2591)', () => { expect(i18n.t('messages.save', 'zh-CN')).toBe('保存'); }); - it('prefers _meta.locale, then a top-level locale field, over the item name', async () => { + it('prefers a top-level locale field over the item name', async () => { const engine = { find: vi.fn(async (_obj: string, q: AnyRecord) => q?.where?.type === 'translation' - ? [ - translationRow('branding_strings', { _meta: { locale: 'fr' }, messages: { save: 'Enregistrer' } }), - translationRow('other_strings', { locale: 'de', messages: { save: 'Speichern' } }), - ] + ? [translationRow('other_strings', { locale: 'de', messages: { save: 'Speichern' } })] : []), }; const { harness, i18n } = await bootPlugin({ objectql: engine }); await harness.fire('kernel:ready'); - expect(i18n.t('messages.save', 'fr')).toBe('Enregistrer'); expect(i18n.t('messages.save', 'de')).toBe('Speichern'); }); @@ -274,3 +272,98 @@ describe('I18nServicePlugin authored-translation sync (#2591)', () => { expect(i18n.t('messages.legacy', 'en')).toBe('Old'); }); }); + +// ── Shape convergence (#3778) ────────────────────────────────────────────── + +describe('authored translations render end-to-end (#3778)', () => { + it('renders an authored item through translateMetadataDocument', async () => { + // The whole point of the type: what an admin authors in the product is + // what a client receives. Before #3778 the `translation` type was + // registered against an `o.` shape no resolver read, so this row + // saved cleanly and every consumer still rendered English. + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [translationRow('zh_CN_crm', { + locale: 'zh-CN', + objects: { + crm_account: { + label: '客户', + pluralLabel: '客户', + fields: { name: { label: '客户名称' } }, + _actions: { merge: { label: '合并客户', confirmText: '确认合并?' } }, + }, + }, + })] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + await harness.fire('kernel:ready'); + + // Build the bundle the REST layer builds, then translate a document with it. + const bundle = { 'zh-CN': i18n.getTranslations('zh-CN') } as TranslationBundle; + const translated = translateMetadataDocument('object', { + name: 'crm_account', + label: 'Account', + pluralLabel: 'Accounts', + fields: { name: { label: 'Account Name' } }, + actions: [{ name: 'merge', label: 'Merge', confirmText: 'Are you sure?' }], + }, bundle, { locale: 'zh-CN' }); + + expect(translated.label).toBe('客户'); + expect(translated.pluralLabel).toBe('客户'); + expect((translated.fields as AnyRecord).name.label).toBe('客户名称'); + expect(translated.actions?.[0].label).toBe('合并客户'); + expect(translated.actions?.[0].confirmText).toBe('确认合并?'); + }); + + it('skips a row still in the retired object-first shape, naming what to do', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [ + translationRow('legacy_zh', { locale: 'zh-CN', o: { crm_account: { label: '客户' } } }), + translationRow('good_zh', { locale: 'zh-CN', objects: { crm_task: { label: '任务' } } }), + ] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + // The retired row contributes nothing and says so; the valid row still loads. + expect((i18n.getTranslations('zh-CN') as AnyRecord).o).toBeUndefined(); + expect(i18n.t('objects.crm_task.label', 'zh-CN')).toBe('任务'); + expect(harness.ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('retired object-first shape'), + ); + }); + + it('keeps publish bookkeeping out of the translation layer', async () => { + const engine = { + find: vi.fn(async (_obj: string, q: AnyRecord) => + q?.where?.type === 'translation' + ? [translationRow('en', { + locale: 'en', + _packageId: 'pkg_1', + _packageVersion: '1.2.3', + _provenance: 'package', + _lock: true, + _lockReason: 'managed', + _lockDocsUrl: 'https://example.test/lock', + _lockSource: 'package', + objects: { crm_task: { label: 'Task' } }, + })] + : []), + }; + const { harness, i18n } = await bootPlugin({ objectql: engine }); + + await harness.fire('kernel:ready'); + + const data = i18n.getTranslations('en') as AnyRecord; + expect(data.objects.crm_task.label).toBe('Task'); + for (const key of ['_packageId', '_packageVersion', '_provenance', '_lock', '_lockReason', '_lockDocsUrl', '_lockSource', 'locale', 'name']) { + expect(data).not.toHaveProperty(key); + } + }); +}); diff --git a/packages/services/service-i18n/src/i18n-service-plugin.test.ts b/packages/services/service-i18n/src/i18n-service-plugin.test.ts index 2c7a0f2206..3f2952e8ff 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.test.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.test.ts @@ -178,8 +178,14 @@ describe('I18nServicePlugin', () => { await plugin.init!(ctx as any); // Load some translations after init so the service has data const i18n = ctx.registerService.mock.calls[0][1]; - i18n.loadTranslations('en', { greeting: 'Hello', 'o.account.fields.name': 'Account Name' }); - i18n.loadTranslations('zh-CN', { greeting: '你好', 'o.account.fields.name': '账户名称' }); + // Nested `objects.` data — the shape every producer actually writes and + // every resolver reads. The flat `o.account.fields.name` key this + // fixture used before was a dialect nothing emitted (#3778). + const accountName = (label: string) => ({ + objects: { account: { fields: { name: { label } } } }, + }); + i18n.loadTranslations('en', { greeting: 'Hello', ...accountName('Account Name') }); + i18n.loadTranslations('zh-CN', { greeting: '你好', ...accountName('账户名称') }); await plugin.start!(ctx as any); await ctx.trigger('kernel:ready'); return { plugin, i18n }; @@ -220,7 +226,10 @@ describe('I18nServicePlugin', () => { success: true, data: { locale: 'en', - translations: { greeting: 'Hello', 'o.account.fields.name': 'Account Name' }, + translations: { + greeting: 'Hello', + objects: { account: { fields: { name: { label: 'Account Name' } } } }, + }, }, }); }); diff --git a/packages/services/service-i18n/src/i18n-service-plugin.ts b/packages/services/service-i18n/src/i18n-service-plugin.ts index 36c7ac4892..f1f8df6088 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.ts @@ -4,6 +4,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { wireAuthoredTranslationSync } from '@objectstack/core'; import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts'; import type { II18nService } from '@objectstack/spec/contracts'; +import type { TranslationData } from '@objectstack/spec/system'; import { FileI18nAdapter } from './file-i18n-adapter.js'; import type { FileI18nAdapterOptions } from './file-i18n-adapter.js'; @@ -229,14 +230,17 @@ export class I18nServicePlugin implements Plugin { .getFieldLabels(objectName, locale); res.json({ success: true, data: { object: objectName, locale, labels } }); } else { - // Fallback: derive field labels from full translation bundle - const translations = i18n.getTranslations(locale); - const prefix = `o.${objectName}.fields.`; + // Fallback: read field labels out of the locale's translation data. + // That data is NESTED (`objects..fields..label`) — the + // flat dotted `o..fields.` keys this used to scan were a + // third translation dialect that no producer ever wrote, so the + // fallback always returned `{}` (#3778). + const data = i18n.getTranslations(locale) as TranslationData | undefined; + const fields = data?.objects?.[objectName]?.fields ?? {}; const labels: Record = {}; - for (const [key, value] of Object.entries(translations)) { - if (key.startsWith(prefix)) { - labels[key.substring(prefix.length)] = value as string; - } + for (const [fieldName, field] of Object.entries(fields)) { + const label = field?.label; + if (typeof label === 'string' && label.length > 0) labels[fieldName] = label; } res.json({ success: true, data: { object: objectName, locale, labels } }); } diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c4dc3ad89c..13f777e006 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -616,8 +616,6 @@ "AppLike (interface)", "AppManifest (type)", "AppManifestSchema (const)", - "AppTranslationBundle (type)", - "AppTranslationBundleSchema (const)", "AudienceBook (interface)", "AudienceCaller (interface)", "AuthConfig (type)", @@ -875,8 +873,10 @@ "KeyRotationPolicy (type)", "KeyRotationPolicyInput (type)", "KeyRotationPolicySchema (const)", + "LEGACY_OBJECT_FIRST_KEYS (const)", "LWWRegister (type)", "LWWRegisterSchema (const)", + "LegacyObjectFirstKey (type)", "License (type)", "LicenseMetricType (type)", "LicenseSchema (const)", @@ -1009,8 +1009,6 @@ "ObjectStorageConfigSchema (const)", "ObjectTranslationData (type)", "ObjectTranslationDataSchema (const)", - "ObjectTranslationNode (type)", - "ObjectTranslationNodeSchema (const)", "OidcProviderConfig (type)", "OidcProviderConfigSchema (const)", "OidcProvidersConfig (type)", @@ -1261,6 +1259,8 @@ "TranslationDiffItemSchema (const)", "TranslationDiffStatus (type)", "TranslationDiffStatusSchema (const)", + "TranslationItem (type)", + "TranslationItemSchema (const)", "UserActivityStatus (type)", "VectorClock (type)", "VectorClockSchema (const)", @@ -1276,6 +1276,7 @@ "defineBook (function)", "defineEmailTemplateDefinition (function)", "defineJob (function)", + "defineTranslation (function)", "defineTranslationBundle (function)", "deriveImplicitPackageBook (function)", "docAudienceAllows (function)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 24b5f6c3e5..241453face 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1261,7 +1261,6 @@ "system/AppInstallRequest", "system/AppInstallResult", "system/AppManifest", - "system/AppTranslationBundle", "system/AuthConfig", "system/AuthPluginConfig", "system/AuthProviderConfig", @@ -1451,7 +1450,6 @@ "system/ObjectMetadata", "system/ObjectStorageConfig", "system/ObjectTranslationData", - "system/ObjectTranslationNode", "system/OidcProviderConfig", "system/OidcProvidersConfig", "system/OnceSchedule", @@ -1563,6 +1561,7 @@ "system/TranslationData", "system/TranslationDiffItem", "system/TranslationDiffStatus", + "system/TranslationItem", "system/UserActivityStatus", "system/VectorClock", "system/WorkerStats", diff --git a/packages/spec/src/contracts/i18n-service.test.ts b/packages/spec/src/contracts/i18n-service.test.ts index 5baf9f694a..23c06304dc 100644 --- a/packages/spec/src/contracts/i18n-service.test.ts +++ b/packages/spec/src/contracts/i18n-service.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { II18nService } from './i18n-service'; -import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; +import type { TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; describe('I18n Service Contract', () => { it('should allow a minimal II18nService implementation with required methods', () => { @@ -87,37 +87,6 @@ describe('I18n Service Contract', () => { expect(service.getDefaultLocale!()).toBe('zh-CN'); }); - it('should allow implementation with getAppBundle and loadAppBundle', () => { - const bundles = new Map(); - - const service: II18nService = { - t: () => '', - getTranslations: () => ({}), - loadTranslations: () => {}, - getLocales: () => Array.from(bundles.keys()), - getAppBundle: (locale) => bundles.get(locale), - loadAppBundle: (locale, bundle) => { bundles.set(locale, bundle); }, - }; - - const zhBundle: AppTranslationBundle = { - o: { - account: { - label: '客户', - fields: { name: { label: '客户名称' } }, - _views: { all_accounts: { label: '全部客户' } }, - }, - }, - messages: { 'common.save': '保存' }, - }; - - service.loadAppBundle!('zh-CN', zhBundle); - const loaded = service.getAppBundle!('zh-CN'); - expect(loaded).toBeDefined(); - expect(loaded?.o?.account.label).toBe('客户'); - expect(loaded?.o?.account._views?.all_accounts.label).toBe('全部客户'); - expect(loaded?.messages?.['common.save']).toBe('保存'); - }); - it('should allow implementation with getCoverage', () => { const service: II18nService = { t: () => '', @@ -135,7 +104,7 @@ describe('I18n Service Contract', () => { staleKeys: 0, coveragePercent: 90, items: [ - { key: 'o.account.fields.website.label', status: 'missing', objectName: 'account', locale }, + { key: 'objects.account.fields.website.label', status: 'missing', objectName: 'account', locale }, ], }; return result; @@ -159,8 +128,6 @@ describe('I18n Service Contract', () => { getLocales: () => [], }; - expect(minimalService.getAppBundle).toBeUndefined(); - expect(minimalService.loadAppBundle).toBeUndefined(); expect(minimalService.getCoverage).toBeUndefined(); expect(minimalService.suggestTranslations).toBeUndefined(); }); @@ -181,11 +148,11 @@ describe('I18n Service Contract', () => { }; const items: TranslationDiffItem[] = [ - { key: 'o.account.fields.website.label', status: 'missing', locale: 'zh-CN' }, + { key: 'objects.account.fields.website.label', status: 'missing', locale: 'zh-CN' }, ]; const suggestions = await service.suggestTranslations!('zh-CN', items); expect(suggestions).toHaveLength(1); - expect(suggestions[0].aiSuggested).toBe('AI翻译: o.account.fields.website.label'); + expect(suggestions[0].aiSuggested).toBe('AI翻译: objects.account.fields.website.label'); expect(suggestions[0].aiConfidence).toBe(0.85); }); }); diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 4ed79b7c90..eb50b3d17b 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; +import type { TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; /** * II18nService - Internationalization Service Contract @@ -57,26 +57,7 @@ export interface II18nService { */ setDefaultLocale?(locale: string): void; - // ── Object-first aggregation & diff detection ────────────────────── - - /** - * Get object-first translation bundle for a locale. - * - * Returns all translations aggregated under `o.{objectName}` with - * global groups (app, nav, dashboard, etc.) at the top level. - * - * @param locale - BCP-47 locale code - * @returns Object-first AppTranslationBundle, or undefined if no data - */ - getAppBundle?(locale: string): AppTranslationBundle | undefined; - - /** - * Load an object-first translation bundle for a locale. - * - * @param locale - BCP-47 locale code - * @param bundle - Object-first AppTranslationBundle - */ - loadAppBundle?(locale: string, bundle: AppTranslationBundle): void; + // ── Diff detection ───────────────────────────────────────────────── /** * Get translation coverage for a locale, optionally scoped to a single object. diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index d1680b1db2..6c9eb80858 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -52,7 +52,7 @@ describe('metadata create seeds validate against their spec schemas', () => { // identity types legitimately have no static minimal create literal. const KNOWN_UNSEEDED = new Set([ 'report', // canvas-create: dataset/measures picked interactively - 'app', 'field', 'seed', 'job', 'datasource', 'translation', 'doc', 'book', + 'app', 'field', 'seed', 'job', 'datasource', 'doc', 'book', 'permission', 'position', 'agent', 'tool', 'skill', 'email_template', ]); const seeded = new Set(listMetadataCreateSeedTypes()); diff --git a/packages/spec/src/kernel/metadata-create-seeds.ts b/packages/spec/src/kernel/metadata-create-seeds.ts index c6cef2410e..d0cf40f939 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.ts @@ -143,6 +143,16 @@ const BUILTIN_METADATA_CREATE_SEEDS: Partial> = { // a permission set's per-object grant map; empty = no grants yet. objects: {}, }, + translation: { + name: 'new_translation', + label: 'New Translation', + // One item translates one locale, and `locale` is required — seeding it + // means a create round-trips instead of 422-ing on an empty body. The + // empty `objects` map is the shape hint that matters: it is where object, + // field, view, section, and action translations go (#3778). + locale: 'en', + objects: {}, + }, }; /** diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index f922247a3e..86ea94e565 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -46,7 +46,7 @@ import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; -import { AppTranslationBundleSchema } from '../system/translation.zod'; +import { TranslationItemSchema } from '../system/translation.zod'; import { DocSchema } from '../system/doc.zod'; import { BookSchema } from '../system/book.zod'; @@ -97,7 +97,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // System Protocol datasource: DatasourceSchema, - translation: AppTranslationBundleSchema, + translation: TranslationItemSchema, email_template: EmailTemplateDefinitionSchema, doc: DocSchema, // ADR-0046: flat Markdown package documentation book: BookSchema as unknown as z.ZodType, // ADR-0046 §6: documentation navigation spine diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 9e9b9ddfae..6ca35b9ec9 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; import { TranslationDataSchema, TranslationBundleSchema, @@ -6,8 +7,8 @@ import { FieldTranslationSchema, ObjectTranslationDataSchema, TranslationConfigSchema, - ObjectTranslationNodeSchema, - AppTranslationBundleSchema, + TranslationItemSchema, + defineTranslation, TranslationDiffStatusSchema, TranslationDiffItemSchema, TranslationCoverageResultSchema, @@ -15,8 +16,7 @@ import { type TranslationBundle, type ObjectTranslationData, type TranslationConfig, - type ObjectTranslationNode, - type AppTranslationBundle, + type TranslationItem, type TranslationDiffItem, type TranslationCoverageResult, type CoverageBreakdownEntry, @@ -546,10 +546,14 @@ describe('ObjectTranslationDataSchema', () => { expect(data.fields?.name.help).toBe('公司或组织的法定名称'); }); - it('should reject object translation without label', () => { - expect(() => - ObjectTranslationDataSchema.parse({ pluralLabel: 'Accounts' }), - ).toThrow(); + it('should accept a partial object translation with no label', () => { + // Partial translation is the normal state — see the schema's note on why + // `label` is optional. + const data = ObjectTranslationDataSchema.parse({ + fields: { name: { label: 'Account Name' } }, + }); + expect(data.label).toBeUndefined(); + expect(data.fields?.name.label).toBe('Account Name'); }); it('should compose into TranslationDataSchema via objects record', () => { @@ -607,260 +611,113 @@ describe('TranslationConfigSchema', () => { }); // ============================================================================ -// ObjectTranslationNodeSchema — object-first aggregated translation node -// ============================================================================ - -describe('ObjectTranslationNodeSchema', () => { - it('should accept minimal node with label only', () => { - const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ - label: 'Account', - }); - expect(node.label).toBe('Account'); - expect(node.pluralLabel).toBeUndefined(); - expect(node.description).toBeUndefined(); - expect(node.helpText).toBeUndefined(); - expect(node.fields).toBeUndefined(); - expect(node._options).toBeUndefined(); - expect(node._views).toBeUndefined(); - expect(node._sections).toBeUndefined(); - expect(node._actions).toBeUndefined(); - }); - - it('should accept full object-first node with all sub-groups', () => { - const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ - label: '客户', - pluralLabel: '客户', - description: '客户管理对象', - helpText: '用于管理公司的所有客户', - fields: { - name: { label: '客户名称', help: '公司或组织的法定名称' }, - industry: { - label: '行业', - options: { tech: '科技', finance: '金融' }, - }, - }, - _options: { - status: { active: '活跃', inactive: '停用' }, - }, - _views: { - all_accounts: { label: '全部客户', description: '查看所有客户' }, - }, - _sections: { - basic_info: { label: '基本信息' }, - }, - _actions: { - convert_lead: { label: '转换线索', confirmMessage: '确认转换?' }, - }, - }); - - expect(node.label).toBe('客户'); - expect(node.pluralLabel).toBe('客户'); - expect(node.description).toBe('客户管理对象'); - expect(node.helpText).toBe('用于管理公司的所有客户'); - expect(node.fields?.name.label).toBe('客户名称'); - expect(node.fields?.industry.options?.tech).toBe('科技'); - expect(node._options?.status.active).toBe('活跃'); - expect(node._views?.all_accounts.label).toBe('全部客户'); - expect(node._sections?.basic_info.label).toBe('基本信息'); - expect(node._actions?.convert_lead.label).toBe('转换线索'); - expect(node._actions?.convert_lead.confirmMessage).toBe('确认转换?'); - }); - - it('should reject node without label', () => { - expect(() => - ObjectTranslationNodeSchema.parse({ pluralLabel: 'Accounts' }), - ).toThrow(); - }); - - it('should accept node with only fields and views', () => { - const node = ObjectTranslationNodeSchema.parse({ - label: 'Opportunity', - fields: { - stage: { label: 'Stage', options: { open: 'Open', closed: 'Closed' } }, - }, - _views: { - pipeline: { label: 'Pipeline View' }, - }, - }); - expect(node.fields?.stage.label).toBe('Stage'); - expect(node._views?.pipeline.label).toBe('Pipeline View'); - }); - - it('should accept node with _notifications and _errors', () => { - const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ - label: 'Order', - _notifications: { - order_shipped: { title: 'Order Shipped', body: 'Your order has been shipped.' }, - order_cancelled: { title: 'Order Cancelled' }, - }, - _errors: { - insufficient_stock: 'Not enough stock for this order.', - payment_failed: 'Payment could not be processed.', - }, - }); - expect(node._notifications?.order_shipped.title).toBe('Order Shipped'); - expect(node._notifications?.order_shipped.body).toBe('Your order has been shipped.'); - expect(node._notifications?.order_cancelled.title).toBe('Order Cancelled'); - expect(node._errors?.insufficient_stock).toBe('Not enough stock for this order.'); - expect(node._errors?.payment_failed).toBe('Payment could not be processed.'); - }); -}); - -// ============================================================================ -// AppTranslationBundleSchema — object-first full app bundle +// TranslationItemSchema — the runtime-authored `translation` metadata type // ============================================================================ -describe('AppTranslationBundleSchema', () => { - it('should accept empty bundle', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({}); - expect(bundle).toBeDefined(); - }); - - it('should accept bundle with object-first translations', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ - o: { +describe('TranslationItemSchema', () => { + it('should accept a single-locale item in the runtime `objects.` shape', () => { + const item: TranslationItem = defineTranslation({ + locale: 'zh-CN', + objects: { account: { label: '客户', fields: { name: { label: '客户名称' } }, _views: { all_accounts: { label: '全部客户' } }, - }, - contact: { - label: '联系人', - fields: { email: { label: '邮箱' } }, + _actions: { merge: { label: '合并客户', confirmText: '确认合并?' } }, }, }, + apps: { crm: { label: '客户关系管理' } }, + messages: { 'common.save': '保存' }, }); - - expect(bundle.o?.account.label).toBe('客户'); - expect(bundle.o?.account.fields?.name.label).toBe('客户名称'); - expect(bundle.o?.account._views?.all_accounts.label).toBe('全部客户'); - expect(bundle.o?.contact.label).toBe('联系人'); - }); - - it('should accept bundle with global options', () => { - const bundle = AppTranslationBundleSchema.parse({ - _globalOptions: { - currency: { usd: '美元', eur: '欧元', gbp: '英镑' }, - country: { us: '美国', cn: '中国' }, - }, - }); - - expect(bundle._globalOptions?.currency.usd).toBe('美元'); - expect(bundle._globalOptions?.country.cn).toBe('中国'); - }); - - it('should accept bundle with all global groups', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ - app: { - crm: { label: 'CRM', description: 'Customer Relationship Management' }, - }, - nav: { home: 'Home', settings: 'Settings' }, - dashboard: { - sales_overview: { label: 'Sales Overview', description: 'Key sales metrics' }, - }, - reports: { - pipeline_report: { label: 'Pipeline Report' }, - }, - pages: { - landing: { title: 'Welcome', description: 'Landing page' }, - }, - messages: { - 'common.save': 'Save', - 'common.cancel': 'Cancel', - }, - validationMessages: { - 'discount_limit': 'Discount cannot exceed 40%', - }, - }); - - expect(bundle.app?.crm.label).toBe('CRM'); - expect(bundle.nav?.home).toBe('Home'); - expect(bundle.dashboard?.sales_overview.label).toBe('Sales Overview'); - expect(bundle.reports?.pipeline_report.label).toBe('Pipeline Report'); - expect(bundle.pages?.landing.title).toBe('Welcome'); - expect(bundle.messages?.['common.save']).toBe('Save'); - expect(bundle.validationMessages?.['discount_limit']).toBe('Discount cannot exceed 40%'); + expect(item.locale).toBe('zh-CN'); + expect(item.objects?.account.label).toBe('客户'); + expect(item.objects?.account._actions?.merge.confirmText).toBe('确认合并?'); + expect(item.apps?.crm.label).toBe('客户关系管理'); }); - it('should accept a complete Chinese translation bundle', () => { - const zh: AppTranslationBundle = AppTranslationBundleSchema.parse({ - o: { - account: { - label: '客户', - pluralLabel: '客户', - description: '客户管理对象', - fields: { - name: { label: '客户名称', help: '公司或组织的法定名称' }, - industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, - }, - _options: { status: { active: '活跃', inactive: '停用' } }, - _views: { all_accounts: { label: '全部客户' } }, - _sections: { basic_info: { label: '基本信息' } }, - _actions: { convert: { label: '转换', confirmMessage: '确认转换?' } }, - }, - opportunity: { - label: '商机', - fields: { - stage: { label: '阶段', options: { open: '打开', closed: '关闭' } }, - }, - }, - }, - _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, - app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, - nav: { home: '首页', settings: '设置' }, - dashboard: { sales_overview: { label: '销售概览' } }, - reports: { pipeline_report: { label: '管道报表' } }, - pages: { landing: { title: '欢迎' } }, - messages: { 'common.save': '保存', 'common.cancel': '取消' }, - validationMessages: { 'discount_limit': '折扣不能超过40%' }, + it('should require a locale — an unresolvable one is silently skipped at runtime', () => { + const result = TranslationItemSchema.safeParse({ + objects: { account: { label: '客户' } }, }); - - expect(zh.o?.account.label).toBe('客户'); - expect(zh.o?.account._options?.status.active).toBe('活跃'); - expect(zh.o?.opportunity.fields?.stage.options?.open).toBe('打开'); - expect(zh._globalOptions?.currency.usd).toBe('美元'); - expect(zh.app?.crm.label).toBe('客户关系管理'); - expect(zh.nav?.home).toBe('首页'); - expect(zh.messages?.['common.save']).toBe('保存'); + expect(result.success).toBe(false); + expect(result.error?.issues.some((i) => i.path[0] === 'locale')).toBe(true); }); - it('should accept bundle with _meta for RTL locale', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ - _meta: { locale: 'ar-SA', direction: 'rtl' }, - messages: { 'common.save': 'حفظ' }, + it('should accept a partial item that translates one field and nothing else', () => { + const item = TranslationItemSchema.parse({ + locale: 'ja-JP', + objects: { account: { fields: { name: { label: '取引先名' } } } }, }); - expect(bundle._meta?.locale).toBe('ar-SA'); - expect(bundle._meta?.direction).toBe('rtl'); + expect(item.objects?.account.label).toBeUndefined(); + expect(item.objects?.account.fields?.name.label).toBe('取引先名'); }); - it('should accept bundle with namespace for plugin isolation', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ - namespace: 'plugin-helpdesk', - o: { ticket: { label: 'Ticket' } }, + it.each([ + ['o', 'objects.'], + ['app', 'apps.'], + ['nav', 'apps..navigation'], + ['dashboard', 'dashboards.'], + ['_globalOptions', 'objects..fields..options'], + ['_meta', "top-level 'locale'"], + ['namespace', 'omit it'], + ])('should reject the retired object-first key `%s` with an actionable message', (key, hint) => { + const result = TranslationItemSchema.safeParse({ + locale: 'zh-CN', + [key]: { account: { label: '客户' } }, }); - expect(bundle.namespace).toBe('plugin-helpdesk'); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.path[0] === key); + expect(issue).toBeDefined(); + expect(issue?.message).toContain(hint); }); - it('should accept bundle with global notifications and errors', () => { - const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ - notifications: { - system_update: { title: 'System Update', body: 'A new version is available.' }, - }, - errors: { - unauthorized: 'You are not authorized to perform this action.', - not_found: 'The requested resource was not found.', - }, + it('should reject the retired shape rather than silently stripping it (#3778)', () => { + // The pre-fix failure mode: Zod strips undeclared keys, so an `o.`-shaped + // item saved cleanly and then resolved to nothing. A save that succeeds + // must be a save that renders. + const result = TranslationItemSchema.safeParse({ + locale: 'zh-CN', + o: { account: { label: '客户' } }, }); - expect(bundle.notifications?.system_update.title).toBe('System Update'); - expect(bundle.errors?.unauthorized).toBe('You are not authorized to perform this action.'); + expect(result.success).toBe(false); }); - it('should accept bundle with _meta direction ltr', () => { - const bundle = AppTranslationBundleSchema.parse({ - _meta: { direction: 'ltr' }, + it('should not leak the retired keys into a parsed item', () => { + const item = TranslationItemSchema.parse({ + locale: 'en', + objects: { account: { label: 'Account' } }, + }); + expect(Object.keys(item)).toEqual(expect.arrayContaining(['locale', 'objects'])); + for (const key of ['o', 'app', 'nav', 'dashboard', '_meta', 'namespace']) { + expect(item).not.toHaveProperty(key); + } + }); + + it('should not advertise the retired keys in its generated JSON Schema', () => { + // The JSON Schema is what `/meta/types/:type` hands the Studio editor and + // any agent authoring metadata — listing a retired key there would teach + // the shape the refinement then rejects. + for (const io of ['input', 'output'] as const) { + const json = z.toJSONSchema(TranslationItemSchema, { io, unrepresentable: 'any' }) as { + properties?: Record; + required?: string[]; + }; + expect(json.required).toContain('locale'); + expect(Object.keys(json.properties ?? {})).toEqual( + expect.not.arrayContaining(['o', 'app', 'nav', 'dashboard', '_meta', 'namespace']), + ); + expect(json.properties).toHaveProperty('objects'); + } + }); + + it('should compose into a TranslationBundle entry (item == one bundle locale)', () => { + const item = TranslationItemSchema.parse({ + locale: 'zh-CN', + objects: { account: { label: '客户' } }, }); - expect(bundle._meta?.direction).toBe('ltr'); - expect(bundle._meta?.locale).toBeUndefined(); + const { locale, ...data } = item; + const bundle = TranslationBundleSchema.parse({ [locale]: data }); + expect(bundle['zh-CN'].objects?.account.label).toBe('客户'); }); }); @@ -887,12 +744,12 @@ describe('TranslationDiffStatusSchema', () => { describe('TranslationDiffItemSchema', () => { it('should accept a missing translation diff item', () => { const item: TranslationDiffItem = TranslationDiffItemSchema.parse({ - key: 'o.account.fields.website.label', + key: 'objects.account.fields.website.label', status: 'missing', objectName: 'account', locale: 'zh-CN', }); - expect(item.key).toBe('o.account.fields.website.label'); + expect(item.key).toBe('objects.account.fields.website.label'); expect(item.status).toBe('missing'); expect(item.objectName).toBe('account'); expect(item.locale).toBe('zh-CN'); @@ -910,7 +767,7 @@ describe('TranslationDiffItemSchema', () => { it('should accept a stale diff item', () => { const item = TranslationDiffItemSchema.parse({ - key: 'o.contact.label', + key: 'objects.contact.label', status: 'stale', objectName: 'contact', locale: 'ja', @@ -926,7 +783,7 @@ describe('TranslationDiffItemSchema', () => { it('should accept diff item with sourceHash', () => { const item = TranslationDiffItemSchema.parse({ - key: 'o.account.label', + key: 'objects.account.label', status: 'stale', locale: 'zh-CN', sourceHash: 'sha256:abc123', @@ -936,7 +793,7 @@ describe('TranslationDiffItemSchema', () => { it('should accept diff item with AI suggestion fields', () => { const item: TranslationDiffItem = TranslationDiffItemSchema.parse({ - key: 'o.account.fields.website.label', + key: 'objects.account.fields.website.label', status: 'missing', locale: 'zh-CN', aiSuggested: '网站', @@ -949,7 +806,7 @@ describe('TranslationDiffItemSchema', () => { it('should reject AI confidence above 1', () => { expect(() => TranslationDiffItemSchema.parse({ - key: 'o.account.label', + key: 'objects.account.label', status: 'missing', locale: 'en', aiConfidence: 1.5, @@ -960,7 +817,7 @@ describe('TranslationDiffItemSchema', () => { it('should reject AI confidence below 0', () => { expect(() => TranslationDiffItemSchema.parse({ - key: 'o.account.label', + key: 'objects.account.label', status: 'missing', locale: 'en', aiConfidence: -0.1, @@ -984,7 +841,7 @@ describe('TranslationCoverageResultSchema', () => { staleKeys: 0, coveragePercent: 87.5, items: [ - { key: 'o.account.fields.website.label', status: 'missing', objectName: 'account', locale: 'zh-CN' }, + { key: 'objects.account.fields.website.label', status: 'missing', objectName: 'account', locale: 'zh-CN' }, { key: 'messages.old_key', status: 'redundant', locale: 'zh-CN' }, ], }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index ca791f6c10..5e81296cb9 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -31,8 +31,7 @@ export type FieldTranslation = z.infer; * * Translations for an action's post-success `resultDialog` (the one-shot * reveal of secrets like temporary passwords, client secrets, or backup - * codes). Shared by object `_actions`, `globalActions`, and the - * object-first `ObjectTranslationNode._actions`. + * codes). Shared by object `_actions` and `globalActions`. * * Convention: * …_actions..resultDialog.title @@ -76,8 +75,17 @@ export type ActionResultDialogTranslation = z.infer z.object({ - /** Translated singular label for the object */ - label: z.string().describe('Translated singular label'), + /** + * Translated singular label for the object. + * + * Optional because partial translation is the normal state — a bundle that + * only renames two fields is valid, and every resolver already treats each + * key as independently optional. Requiring it would force authors (and the + * AI agents that scaffold bundles) to restate the source label just to pass + * validation, filling bundles with fake translations that mask real + * coverage gaps. + */ + label: z.string().optional().describe('Translated singular label'), /** Translated plural label for the object */ pluralLabel: z.string().optional().describe('Translated plural label'), /** Translated description shown in list/detail headings */ @@ -409,222 +417,122 @@ export const TranslationConfigSchema = lazySchema(() => z.object({ export type TranslationConfig = z.infer; // ──────────────────────────────────────────────────────────────────────────── -// Object-First Translation Node (object-first aggregated structure) +// Translation Item (the runtime-authored `translation` metadata type) // ──────────────────────────────────────────────────────────────────────────── -/** Translatable option map: option value → translated label */ -const OptionTranslationMapSchema = z.record(z.string(), z.string()) - .describe('Option value to translated label map'); - /** - * ObjectTranslationNodeSchema - * - * Object-first aggregated translation node that groups **all** translatable - * content for a single object under one key. Aligns with Salesforce / Dynamics - * conventions where translations are organized per-object rather than per-category. - * - * Located at `o.{object_name}` inside an {@link AppTranslationBundle}. + * Top-level keys of the retired object-first (`o.`) dialect. * - * @example - * ```typescript - * const accountNode: ObjectTranslationNode = { - * label: '客户', - * pluralLabel: '客户', - * description: '客户管理对象', - * fields: { - * name: { label: '客户名称', help: '公司或组织的法定名称' }, - * industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, - * }, - * _options: { status: { active: '活跃', inactive: '停用' } }, - * _views: { all_accounts: { label: '全部客户' } }, - * _sections: { basic_info: { label: '基本信息' } }, - * _actions: { - * convert_lead: { label: '转换线索', confirmMessage: '确认转换?' }, - * }, - * }; - * ``` + * {@link TranslationItemSchema} checks for them *before* parsing, because Zod + * strips undeclared keys silently: an item authored in the old shape would + * otherwise save cleanly and then resolve to nothing — exactly the failure + * this type is being fixed for (#3778). Guarding ahead of the parse turns + * that silence into a 422 naming the correct group, while keeping the retired + * keys out of the schema itself, so neither the generated JSON Schema nor the + * Studio editor advertises a shape that cannot work. */ -export const ObjectTranslationNodeSchema = lazySchema(() => z.object({ - /** Translated singular label */ - label: z.string().describe('Translated singular label'), - /** Translated plural label */ - pluralLabel: z.string().optional().describe('Translated plural label'), - /** Translated object description */ - description: z.string().optional().describe('Translated object description'), - /** Translated help text shown in tooltips or guidance panels */ - helpText: z.string().optional().describe('Translated help text for the object'), - - /** Field-level translations keyed by field name (snake_case) */ - fields: z.record(z.string(), FieldTranslationSchema).optional() - .describe('Field translations keyed by field name'), - - /** - * Global picklist / select option overrides scoped to this object. - * Keyed by field name → { optionValue: translatedLabel }. - */ - _options: z.record(z.string(), OptionTranslationMapSchema).optional() - .describe('Object-scoped picklist option translations keyed by field name'), - - /** View translations keyed by view name */ - _views: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated view label'), - description: z.string().optional().describe('Translated view description'), - emptyState: z.object({ - title: z.string().optional().describe('Translated empty-state title'), - message: z.string().optional().describe('Translated empty-state message'), - }).optional().describe('Translated empty-state copy shown when the view has no rows'), - })).optional().describe('View translations keyed by view name'), - - /** Section (form section / tab) translations keyed by section name */ - _sections: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated section label'), - })).optional().describe('Section translations keyed by section name'), - - /** Action translations keyed by action name */ - _actions: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action label'), - confirmMessage: z.string().optional().describe('Translated confirmation message'), - params: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated action parameter label'), - helpText: z.string().optional().describe('Translated action parameter help/hint text'), - placeholder: z.string().optional().describe('Translated action parameter placeholder'), - options: z.record(z.string(), z.string()).optional().describe('Param select option value to translated label'), - })).optional().describe('Action parameter translations keyed by parameter name'), - resultDialog: ActionResultDialogTranslationSchema.optional() - .describe('Translations for the action result dialog'), - })).optional().describe('Action translations keyed by action name'), - - /** Notification message translations keyed by notification name */ - _notifications: z.record(z.string(), z.object({ - title: z.string().optional().describe('Translated notification title'), - body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), - })).optional().describe('Notification translations keyed by notification name'), - - /** Error message translations keyed by error code */ - _errors: z.record(z.string(), z.string()).optional() - .describe('Error message translations keyed by error code'), -}).describe('Object-first aggregated translation node')); - -export type ObjectTranslationNode = z.infer; - -// ──────────────────────────────────────────────────────────────────────────── -// App Translation Bundle (object-first, full application) -// ──────────────────────────────────────────────────────────────────────────── +export const LEGACY_OBJECT_FIRST_KEYS = [ + 'o', + 'app', + 'nav', + 'dashboard', + 'reports', + 'notifications', + 'errors', + '_globalOptions', + '_meta', + 'namespace', +] as const; + +export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number]; + +/** Where each retired key's content belongs now (or that it has no home). */ +const LEGACY_KEY_MIGRATION: Record = { + o: "use 'objects.'", + app: "use 'apps.'", + nav: "use 'apps..navigation..label'", + dashboard: "use 'dashboards.'", + reports: 'reports have no translation group — omit them', + notifications: 'notifications have no translation group — omit them', + errors: "use 'validationMessages' for rule messages; other errors have no translation group", + _globalOptions: "use 'objects..fields..options'", + _meta: "use the top-level 'locale' field", + namespace: 'namespaces are not part of the translation contract — omit it', +}; /** - * AppTranslationBundleSchema + * TranslationItemSchema + * + * The shape of a single `translation` metadata item — one locale's worth of + * translations, authored either as a file (`*.translation.ts`) or at runtime + * through the metadata door (Studio, the metadata API, an AI agent). * - * Complete application translation bundle for a **single locale** using - * the **object-first** convention. All per-object translatable content - * is aggregated under `o.{object_name}`, while global (non-object-bound) - * translations are kept in dedicated top-level groups. + * It is deliberately the SAME set of groups the file-authored bundles use and + * the resolvers read (`objects.`, `apps`, `messages`, …): one + * item is one entry of a {@link TranslationBundle}, plus the `locale` naming + * which entry it is. Before #3778 this type was registered against a second, + * object-first (`o.`) dialect that no resolver ever read, so a + * translation authored in the product saved successfully and then rendered + * nothing. That dialect is gone, and its keys are rejected outright rather + * than stripped — see {@link LEGACY_OBJECT_FIRST_KEYS}. * - * This schema is designed for: - * - Translation workbench UIs (object-level editing & coverage) - * - CLI skeleton generation (`objectstack i18n extract`) - * - Automated diff/coverage detection + * `locale` is required rather than inferred from the item name: the runtime + * sync skips an item whose locale it cannot resolve, and a skip is invisible + * to whoever — or whatever — authored it. * * @example * ```typescript - * const zh: AppTranslationBundle = { - * o: { + * const zhCN = defineTranslation({ + * locale: 'zh-CN', + * objects: { * account: { * label: '客户', * fields: { name: { label: '客户名称' } }, - * _options: { industry: { tech: '科技' } }, * _views: { all_accounts: { label: '全部客户' } }, - * _sections: { basic_info: { label: '基本信息' } }, - * _actions: { convert: { label: '转换' } }, + * _actions: { merge: { label: '合并客户', confirmText: '此操作无法撤销,确认合并?' } }, * }, * }, - * _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, - * app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, - * nav: { home: '首页', settings: '设置' }, - * dashboard: { sales_overview: { label: '销售概览' } }, - * reports: { pipeline_report: { label: '管道报表' } }, - * pages: { landing: { title: '欢迎' } }, + * apps: { crm: { label: '客户关系管理' } }, * messages: { 'common.save': '保存' }, - * validationMessages: { 'discount_limit': '折扣不能超过40%' }, - * }; + * }); * ``` */ -export const AppTranslationBundleSchema = lazySchema(() => z.object({ - /** - * Bundle-level metadata. - * Provides locale-aware rendering hints such as text direction (bidi) - * and the canonical locale code this bundle represents. - */ - _meta: z.object({ - /** BCP-47 locale code this bundle represents */ - locale: z.string().optional().describe('BCP-47 locale code for this bundle'), - /** Text direction for the locale */ - direction: z.enum(['ltr', 'rtl']).optional().describe('Text direction: left-to-right or right-to-left'), - }).optional().describe('Bundle-level metadata (locale, bidi direction)'), - - /** - * Namespace for plugin/extension isolation. - * When multiple plugins contribute translations, each should use a unique - * namespace to avoid key collisions (e.g. "crm", "helpdesk", "plugin-xyz"). - */ - namespace: z.string().optional() - .describe('Namespace for plugin isolation to avoid translation key collisions'), - - /** Object-first translations keyed by object name (snake_case) */ - o: z.record(z.string(), ObjectTranslationNodeSchema).optional() - .describe('Object-first translations keyed by object name'), - - /** Global picklist options not bound to any specific object */ - _globalOptions: z.record(z.string(), OptionTranslationMapSchema).optional() - .describe('Global picklist option translations keyed by option set name'), - - /** App-level translations */ - app: z.record(z.string(), z.object({ - label: z.string().describe('Translated app label'), - description: z.string().optional().describe('Translated app description'), - })).optional().describe('App translations keyed by app name'), - - /** Navigation menu translations */ - nav: z.record(z.string(), z.string()).optional() - .describe('Navigation item translations keyed by nav item name'), - - /** Dashboard translations keyed by dashboard name */ - dashboard: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated dashboard label'), - description: z.string().optional().describe('Translated dashboard description'), - })).optional().describe('Dashboard translations keyed by dashboard name'), - - /** Report translations keyed by report name */ - reports: z.record(z.string(), z.object({ - label: z.string().optional().describe('Translated report label'), - description: z.string().optional().describe('Translated report description'), - })).optional().describe('Report translations keyed by report name'), - - /** Page translations keyed by page name */ - pages: z.record(z.string(), z.object({ - title: z.string().optional().describe('Translated page title'), - description: z.string().optional().describe('Translated page description'), - })).optional().describe('Page translations keyed by page name'), - - /** UI message translations (supports ICU MessageFormat when enabled) */ - messages: z.record(z.string(), z.string()).optional() - .describe('UI message translations keyed by message ID (supports ICU MessageFormat)'), - - /** Validation error message translations (supports ICU MessageFormat when enabled) */ - validationMessages: z.record(z.string(), z.string()).optional() - .describe('Validation error message translations keyed by rule name (supports ICU MessageFormat)'), - - /** Global notification translations not bound to a specific object */ - notifications: z.record(z.string(), z.object({ - title: z.string().optional().describe('Translated notification title'), - body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), - })).optional().describe('Global notification translations keyed by notification name'), - - /** Global error message translations not bound to a specific object */ - errors: z.record(z.string(), z.string()).optional() - .describe('Global error message translations keyed by error code'), -}).describe('Object-first application translation bundle for a single locale')); +/** + * The item's own shape, without the retired-key guard. Private: it exists so + * the guard can wrap it (and so the factory below can type its argument) — + * {@link TranslationItemSchema} is the schema every caller should use. + */ +const TranslationItemDataSchema = lazySchema(() => TranslationDataSchema.extend({ + locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), +}).describe('One locale of translations — the `translation` metadata type')); + +export const TranslationItemSchema = lazySchema(() => z.preprocess((raw, ctx) => { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + for (const key of LEGACY_OBJECT_FIRST_KEYS) { + if ((raw as Record)[key] === undefined) continue; + ctx.addIssue({ + code: 'custom', + path: [key], + message: + `'${key}' belongs to the retired object-first translation shape, which no resolver ` + + `reads — ${LEGACY_KEY_MIGRATION[key]}.`, + }); + } + } + return raw; +}, TranslationItemDataSchema)); + +/** A single `translation` metadata item. */ +export type TranslationItem = z.infer; -export type AppTranslationBundle = z.infer; +/** + * Type-safe factory for a single-locale `translation` item. Validates at + * authoring time via `.parse()` — preferred over a bare `: TranslationItem` + * literal, which cannot catch a retired key. + */ +export function defineTranslation(config: z.input): TranslationItem { + return TranslationItemSchema.parse(config); +} // ──────────────────────────────────────────────────────────────────────────── // Translation Diff & Coverage @@ -652,7 +560,7 @@ export type TranslationDiffStatus = z.infer; * @example * ```typescript * const item: TranslationDiffItem = { - * key: 'o.account.fields.website.label', + * key: 'objects.account.fields.website.label', * status: 'missing', * objectName: 'account', * locale: 'zh-CN', @@ -660,7 +568,7 @@ export type TranslationDiffStatus = z.infer; * ``` */ export const TranslationDiffItemSchema = lazySchema(() => z.object({ - /** Dot-path translation key (e.g. "o.account.fields.website.label") */ + /** Dot-path translation key (e.g. "objects.account.fields.website.label") */ key: z.string().describe('Dot-path translation key'), /** Diff status */ status: TranslationDiffStatusSchema.describe('Diff status of this translation key'), diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index d9f85faae2..8447bd0d0c 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -60,10 +60,9 @@ and integration with the I18nService. 3. **Coverage detection**: `os i18n check` compares registered bundles against source metadata to report missing keys per locale. -4. **Secondary format — `o.*` (`AppTranslationBundle`)**: a separate object-first, - single-locale format aimed at translation-workbench UIs, Studio-authored - `translation` metadata, and the coverage-diff schemas. It is **not** what the stack - `translations` array consumes — see "Secondary Format: AppTranslationBundle" below. +4. **Runtime authoring — `TranslationItem`**: a `translation` metadata item authored + in the Studio / metadata API carries the **same** `objects.*` groups plus the + `locale` it translates. There is only one shape; see "Authoring at Runtime" below. --- @@ -290,59 +289,64 @@ For the exact Zod shape (and any field that may have been added since), read --- -## Secondary Format: AppTranslationBundle (`o.*`) +## Authoring at Runtime: the `translation` Item -`AppTranslationBundle` is a **separate, object-first format for a single locale** -where per-object content lives under `o.{object_name}`. It targets translation -workbench UIs, Studio-authored `translation` metadata, and the coverage/diff -schemas. **Do not** use it in the files you register through -`defineStack({ translations: [...] })` — the runtime resolvers read `objects.*` -(`TranslationData`). - -Differences from the runtime format worth knowing: - -- Objects live under `o.*` (not `objects.*`); extra groups are `_meta`, - `_globalOptions`, `app`, `nav`, `dashboard`, `reports`, `pages`, - `notifications`, `errors`. -- `_options` is keyed by **field name** → `{ optionValue: label }` (not by picklist name). -- Actions use `confirmMessage` (the runtime format's `_actions` use `confirmText` / `successMessage`). -- `namespace` is a **declared** isolation field for multi-plugin bundles; no shipped - code prefixes keys with it. -- `_meta.direction: 'rtl'` lets UI frameworks apply RTL CSS for locales like Arabic. +Translations do not have to ship as files. A **`translation` metadata item** — +created in the Studio, through the metadata API, or by an agent — is one +locale's worth of the **same** `objects.*` groups documented above, plus the +`locale` it translates. There is exactly one shape; nothing converts between +formats. ```typescript -import type { AppTranslationBundle } from '@objectstack/spec/system'; +import { defineTranslation } from '@objectstack/spec/system'; -const zh: AppTranslationBundle = { - _meta: { locale: 'zh-CN', direction: 'ltr' }, - o: { +export default defineTranslation({ + locale: 'zh-CN', + objects: { account: { label: '客户', pluralLabel: '客户', fields: { name: { label: '客户名称', help: '公司或组织的法定名称' }, industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, - }, - _options: { - status: { active: '活跃', inactive: '停用' }, // keyed by FIELD name + status: { options: { active: '活跃', inactive: '停用' } }, }, _views: { all_accounts: { label: '全部客户' } }, _sections: { basic_info: { label: '基本信息' } }, _actions: { - merge: { label: '合并客户', confirmMessage: '此操作无法撤销,确认合并?' }, + merge: { label: '合并客户', confirmText: '此操作无法撤销,确认合并?' }, }, }, }, - _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, - app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, - nav: { home: '首页', settings: '设置' }, + apps: { crm: { label: '客户关系管理', navigation: { home: { label: '首页' } } } }, messages: { 'common.save': '保存' }, -}; +}); ``` +Rules that differ from a file bundle: + +- **`locale` is required.** A file bundle names its locales as map keys; an item + carries its own. An item whose locale cannot be resolved is skipped by the + runtime sync — a silent skip, which is why the field is mandatory rather + than inferred from the item name. +- **One locale per item.** Author `zh-CN` and `ja-JP` as two items. +- Published items are loaded at boot and on every publish (no restart), and + layer **over** the file bundles — an authored value wins over a shipped one + for the same key; deleting the item restores the shipped value. + Exact Zod shape: `node_modules/@objectstack/spec/src/system/translation.zod.ts` — -`AppTranslationBundleSchema` and `ObjectTranslationNodeSchema`. +`TranslationItemSchema`. + +### Retired: the `o.*` dialect + +A second object-first shape keyed on `o.{object_name}` (with `app`, `nav`, +`dashboard`, `reports`, `notifications`, `errors`, `_globalOptions`, `_meta`, +`namespace`, and `_actions.confirmMessage`) was once documented for +Studio-authored translations. **No resolver ever read it**, so items authored +that way saved successfully and rendered nothing. It was removed in #3778 — +those keys are now rejected at save time with a message naming the group to +use instead. Never author them, in files or at runtime. --- @@ -484,9 +488,10 @@ registers when no i18n plugin is present): The in-memory fallback additionally resolves locale codes (exact → case-insensitive → base language `zh-CN` → `zh` → variant `zh` → `zh-CN`). -The contract also declares optional methods — `getAppBundle`, `loadAppBundle`, -`getCoverage`, `suggestTranslations` — that **no shipped implementation provides**. -Treat them as extension points for a custom workbench or TMS adapter. +The contract also declares optional methods — `getCoverage`, +`suggestTranslations` — that **no shipped implementation provides**. Treat them +as extension points for a custom workbench or TMS adapter. (`getAppBundle` / +`loadAppBundle` were removed in #3778 along with the `o.*` shape they returned.) ### Plugin Setup @@ -573,19 +578,26 @@ before release. ## Common Pitfalls -### ❌ Studio Shape in Runtime Bundles +### ❌ The Retired `o.*` Shape -The runtime resolvers read `objects.*` — the `o.*` shape belongs to the -secondary `AppTranslationBundle` format only: +Everything reads `objects.*`. The `o.*` dialect was removed in #3778 — it is +not a "Studio format", not a secondary format, just gone. Files registered in +that shape resolve to nothing; runtime items in that shape are rejected at +save time. ```typescript -// Registered via defineStack({ translations }) — WRONG +// WRONG — in a file bundle AND in a `translation` item { o: { account: { label: '客户' } } } // CORRECT (TranslationData) { objects: { account: { label: '客户' } } } ``` +Same rule for its sibling keys: `app` → `apps`, `nav` → +`apps..navigation..label`, `dashboard` → `dashboards`, +`_globalOptions` → `objects..fields..options`, `_meta.locale` → +top-level `locale`, and `_actions.confirmMessage` → `_actions.confirmText`. + ### ❌ Mismatched Object Names Translation keys must match metadata exactly: