diff --git a/.changeset/26690-translation-books.md b/.changeset/26690-translation-books.md new file mode 100644 index 00000000000..dc63c26821d --- /dev/null +++ b/.changeset/26690-translation-books.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/cli': minor +'@objectstack/lint': minor +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +feat(spec,rest,cli): a doc book's title and section titles are translatable — `books.` in the translation bundle + +Clause-②: yes (widening) +Metadata-protocol: added + +A package that ships its docs in two languages showed a half-translated manual: each doc's body followed the reader's locale (`doc.translations`, `..md`), but the book title and every sidebar section title stayed in the authoring language. The book's own inline `translations` map was retired in 17.0.0 because nothing read it, and the translation bundle had no key for a book. + +- **`@objectstack/spec`:** `TranslationData` (and the registered `translation` item and the platform bundle, which share its groups) gains `books`, keyed by the book's `name`: `label`, `description`, and `groups..label` for each group by its stable `key` (strict: any other key is refused; `name` / `title` point at `label`, and `sections` / `chapters` / `toc` point at `groups`; the singular group spelling `book` points at `books`). A new translator, `translateBook`, overlays them and is registered in the metadata-document dispatch, so `GET /meta/book` (list and item) is served in the request's locale. A key the bundle does not carry keeps the authored string. The rejection for the retired inline `translations` map on a book now names the bundle keys to move the strings to. +- **`@objectstack/rest`:** `GET /meta/book/:name/tree` translates the book it resolves through the same dispatch before rendering the spine, so the tree, the book list and the console's docs sidebar agree. `MetaBookTreeSources` gains `resolveI18nService()`, which both transports supply. +- **`@objectstack/runtime`:** the dispatcher's book-tree route supplies the same i18n service, so it answers the localized tree `RestServer` answers. +- **`@objectstack/cli`:** `os i18n extract` scaffolds `books..label` / `.description` and `books..groups..label` from the stack's `books[]`, and `os i18n check` / `os lint` coverage report an untranslated one as `i18n/missing-book`. +- **`@objectstack/lint`:** `translation-target-unknown` resolves `books.` against the declared books (a sibling package's included, and a package id for its implicit book) and `books..groups.` against that book's group `key`s, so a mistyped book name or group key is an error instead of a translation nothing reads. +- **`@object-ui/i18n`:** the console's language loader recognises `books` as a translation group, so a served document that carries only book translations is namespaced like any other. + +Example (`zh-CN` bundle of an English-authored package): + +```ts +books: { + crm_manual: { + label: '使用手册', + groups: { start: { label: '快速上手' }, pipeline: { label: '销售管道' } }, + }, +}, +``` diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 7979b59f9d2..56672622c34 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -406,8 +406,8 @@ Three rules the shape enforces, all of them closed since #4001: - **The top level is the declared group set and nothing else** — `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, - `reports`, `pages`, `flows`, `positions`, `permissions`, `metadataForms`, - `settingsCommon`. A group invented beside them + `reports`, `pages`, `flows`, `positions`, `permissions`, `books`, + `metadataForms`, `settingsCommon`. A group invented beside them (`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name** at both authoring doors, with the group to use instead named in the rejection. @@ -496,7 +496,7 @@ what the overlay writes back as the node's `title`. The contract is `t(key, locale, params?)` — the target locale is always passed explicitly, and the key itself is returned when no translation is found (there is no `defaultValue` option). The key is a **dot path into `TranslationData`**, -so it starts at one of the fourteen declared groups. +so it starts at one of the fifteen declared groups. ```typescript const i18n = kernel.getService('i18n'); diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index fd5d355ab29..ec6bd77e93b 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1783,6 +1783,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 48e785108dc..0cab1ce8a51 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -47,7 +47,7 @@ const result = BookSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Book name (namespace prefix recommended, like every metadata name) | -| **label** | `string` | optional | Display title | +| **label** | `string` | optional | Display title — translated by `books..label` in the translation bundle | | **description** | `string` | optional | | | **slug** | `string` | optional | Portal URL segment; defaults to name sans prefix | | **icon** | `string` | optional | | @@ -67,8 +67,8 @@ const result = BookSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | -| **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | +| **label** | `string` | ✅ | Section title — first-class, i18n-homed: translated by `books..groups..label` in the translation bundle | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it, so a localized book shipped its authoring-locale strings to every reader. Delete the key and move each locale's strings into the translation bundle: `books..label` / `.description` and `books..groups..label` (`os i18n extract` seeds them). The book list, the book tree and the docs sidebar resolve those per reader locale. A doc's own title and body still localize through `doc.translations`. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | @@ -115,8 +115,8 @@ Type: `'public'` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | -| **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | +| **label** | `string` | ✅ | Section title — first-class, i18n-homed: translated by `books..groups..label` in the translation bundle | +| **translations** | `never` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (ADR-0049) — no resolver ever read it, so a localized book shipped its authoring-locale strings to every reader. Delete the key and move each locale's strings into the translation bundle: `books..label` / `.description` and `books..groups..label` (`os i18n extract` seeds them). The book list, the book tree and the docs sidebar resolve those per reader locale. A doc's own title and body still localize through `doc.translations`. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index beafb946cec..4224f0eaa11 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -185,6 +185,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -295,6 +296,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `PlatformTranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `PlatformTranslationData.metadataForms[string]` | Property | Type | Required | Description | @@ -344,6 +353,7 @@ Served translation data — the platform groups the browser reads, without the s | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -453,6 +463,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `ServedTranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `ServedTranslationData.settingsCommon` | Property | Type | Required | Description | @@ -564,6 +582,7 @@ Per-app translation data for objects, apps, and UI messages | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -673,6 +692,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `TranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `TranslationData.metadataForms[string]` | Property | Type | Required | Description | @@ -743,6 +770,7 @@ One locale of translations — the `translation` metadata type | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **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") | @@ -862,6 +890,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `TranslationItem.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `TranslationItem.metadataForms[string]` | Property | Type | Required | Description | diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx index 601777e831a..fa8e86f55e1 100644 --- a/content/docs/ui/doc-pages.mdx +++ b/content/docs/ui/doc-pages.mdx @@ -302,11 +302,34 @@ expands to *the rest* — every doc the group's rule would have claimed but that names, in `order`-then-label sequence. An entry can also be an object to attach a `label` override, a `badge` or an `icon`, or to point at an external `href` instead of a doc. +#### Translating the spine + +A book's title, its description and each group's section title translate through the +translation bundle, like an app's label and its navigation labels — keyed by the book's +`name` and each group's `key`: + +```typescript +translations: [{ + 'zh-CN': { + books: { + crm_docs: { + label: 'CRM 文档', + groups: { overview: { label: '概览' }, guides: { label: '使用指南' }, admin: { label: '管理' } }, + }, + }, + }, +}], +``` + +`GET /meta/book`, the book tree and the docs sidebar serve the reader's locale; a key the +bundle does not carry keeps the authored string. `os i18n extract` seeds the keys, and +`os i18n check` reports a missing one as `i18n/missing-book`. The docs themselves localize +through `doc.translations` and `..md` variants. + Inline `translations` on a **book** or a **book group** is rejected: no resolver ever read -it, so a localized spine shipped its authoring-locale strings to every reader. The near -neighbour that *does* work is `doc.translations`, read on every doc render path — localize -the docs themselves. +it, so a localized spine shipped its authoring-locale strings to every reader. Move those +strings into the bundle's `books` group above. ### How `doc` and `book` compose diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 56b18df8b84..6a572ff3959 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -89,12 +89,13 @@ export default defineStack({ | Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows..label` / `flows..screens..title` / `.description` / `.fields..label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below | | Position label / description | `positions..label` / `description` — keyed by the position's `name`; overlaid on every served position, which is the list the console's position picker draws | | Permission-set label / description | `permissions..label` / `description` — the group is spelled as the stack spells the collection (`permissions[]`), keyed by the set's `name` | +| Doc book title, description and section titles | `books..label` / `description` / `groups..label` — keyed by the book's `name` and each group's `key`; overlaid on every served book and on the book tree, which is what the docs sidebar draws. A doc's own title and body localize through `doc.translations` instead | | Global actions, messages | `globalActions`, `messages` | | Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle | | A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below | The metadata types resolved per request are **object, view, action, app, -dashboard, dataset, report, page, picklist, position and permission** — a field's labels are translated as part of +dashboard, dataset, report, page, picklist, position, permission and book** — a field's labels are translated as part of its object document, and so are the labels of any actions the object declares inline (#3370). Before that, an object document went out with its authored action diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index eb3d3289225..c9ba2459292 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 384 | +| `system/` | 386 | diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b00559fb8f3..f890fc39408 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -1365,5 +1365,19 @@ export const ShowcaseTranslationBundle = { }, showcase_hours_by_status_chart: { label: '按状态统计工时(图表)', description: '以柱状图展示按状态统计的预计工时。' }, }, + // The docs book: its title and the docs sidebar's section titles, keyed by + // the book's `name` and each group's `key`. The docs themselves localize + // through `doc.translations` and `.zh-CN.md` variants. + books: { + showcase_manual: { + label: '示例手册', + description: '这个综合示例工作区里的一切。', + groups: { + start: { label: '入门' }, + tour: { label: '导览' }, + guides: { label: '指南' }, + }, + }, + }, }, }; diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 5afbd13d342..d6f085d71bb 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -73,6 +73,7 @@ export interface CoverageIssue { | 'flow' | 'position' | 'permission' + | 'book' | 'metadataForm'; /** Human-readable explanation. */ message: string; @@ -385,6 +386,10 @@ const COVERAGE_SOURCE: Record // `i18n/missing-position` / `i18n/missing-permission`. position: 'position', permission: 'permission', + // Doc-book spine copy (`books..label` / `.description`, + // `books..groups..label`) — the title and section titles of a docs + // sidebar, reported as `i18n/missing-book` (#26690). + book: 'book', metadataType: 'metadataForm', metadataFormSection: 'metadataForm', metadataFormField: 'metadataForm', @@ -409,6 +414,7 @@ const SOURCE_NOUN: Record = { flow: 'Flow', position: 'Position', permission: 'Permission set', + book: 'Book', metadataForm: 'Metadata form', }; @@ -454,6 +460,7 @@ const SOURCE_SURFACE: Record = { flow: 'flow screens, approval steps, toasts and refusals', position: 'positions', permission: 'permission sets', + book: 'doc books', metadataForm: 'metadata forms', }; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 887c3182bf2..323e1975f90 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -126,6 +126,8 @@ * (see `authorWarnedTranslationGroups`) * positions..label / .description * permissions..label / .description + * books..label / .description + * books..groups..label (a doc book's section title, #26690) * metadataForms..label / .description * metadataForms..sections.
.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -232,6 +234,7 @@ export interface ExpectedEntry { | 'flow' | 'position' | 'permission' + | 'book' | 'metadataType' | 'metadataFormSection' | 'metadataFormField'; @@ -1638,6 +1641,9 @@ export function collectExpectedEntries( // ── Security catalog (`positions..…`, `permissions..…`) ─ walkSecurityCatalog(config, out); + // ── Doc books (`books..…`) ────────────────────────────────── + walkBooks(config, out); + // ── Pages + their `page:header` copy ────────────────────────────── const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; for (const page of pages) { @@ -1953,6 +1959,34 @@ function walkSecurityCatalog(config: any, out: ExpectedEntry[]): void { } } +// ─── Doc books (`books..…`) ────────────────────────────────────── + +/** + * [#26690] Emit a doc book's spine copy: `books..label` / + * `.description` and `books..groups..label`, the keys + * `translateBook` reads. A group is addressed by its stable `key`, never its + * position. The book's own label and description are optional on the schema, + * so they are recorded with {@link pushOptional}; a group's label is required, + * so it is the author's literal ({@link pushEntry}). + */ +function walkBooks(config: any, out: ExpectedEntry[]): void { + const books: any[] = Array.isArray(config?.books) ? config.books : []; + for (const book of books) { + if (!book || typeof book !== 'object') continue; + const name = book.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushOptional(out, ['books', name, 'label'], book.label, 'book'); + pushOptional(out, ['books', name, 'description'], book.description, 'book'); + const groups: any[] = Array.isArray(book.groups) ? book.groups : []; + for (const group of groups) { + if (!group || typeof group !== 'object') continue; + const key = group.key; + if (typeof key !== 'string' || key.length === 0) continue; + pushEntry(out, ['books', name, 'groups', key, 'label'], group.label, 'book'); + } + } +} + // ─── Screen flows (`flows..screens..…`) ───────────────── /** diff --git a/packages/cli/test/i18n-book-walk.test.ts b/packages/cli/test/i18n-book-walk.test.ts new file mode 100644 index 00000000000..d352c245df4 --- /dev/null +++ b/packages/cli/test/i18n-book-walk.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `books..…` — the extractor walks a doc book's translation group. +// +// A book's title and section titles frame every page of a docs sidebar, and +// `translateBook` overlays them from the bundle. Without this walk +// `os i18n extract` would scaffold no key for them and `os i18n check` would +// measure a debt of zero while the sidebar shipped in the authoring language. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +const config = () => ({ + books: [ + { + name: 'crm_manual', + label: 'User Manual', + description: 'Everything a sales rep needs', + groups: [ + { key: 'start', label: 'Getting started', include: 'crm_start_*' }, + { key: 'pipeline', label: 'Pipeline', include: 'crm_pipe_*' }, + ], + }, + // No label, no description: nothing an author wrote, so nothing is demanded. + { name: 'crm_admin_manual', groups: [{ key: 'setup', label: 'Setup' }] }, + ], +}); + +const keys = () => + collectExpectedEntries(config()) + .filter((e) => e.path[0] === 'books') + .map((e) => e.path.join('.')) + .sort(); + +describe('i18n extract — the `books` group', () => { + it('walks each book\'s label, description and every group label by its key', () => { + expect(keys()).toEqual([ + 'books.crm_admin_manual.description', + 'books.crm_admin_manual.groups.setup.label', + 'books.crm_admin_manual.label', + 'books.crm_manual.description', + 'books.crm_manual.groups.pipeline.label', + 'books.crm_manual.groups.start.label', + 'books.crm_manual.label', + ]); + }); + + it('seeds authored text and records an unwritten string without demanding it', () => { + const byKey = new Map(collectExpectedEntries(config()).map((e) => [e.path.join('.'), e])); + expect(byKey.get('books.crm_manual.label')) + .toMatchObject({ sourceValue: 'User Manual', inline: 'User Manual', source: 'book' }); + expect(byKey.get('books.crm_manual.groups.start.label')) + .toMatchObject({ sourceValue: 'Getting started', inline: 'Getting started', source: 'book' }); + const unwritten = byKey.get('books.crm_admin_manual.label'); + expect(unwritten?.sourceValue).toBeUndefined(); + expect(unwritten?.inline).toBeUndefined(); + }); + + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(Object.keys(zh?.books ?? {}).sort()).toEqual(['crm_admin_manual', 'crm_manual']); + expect(Object.keys(zh?.books?.crm_manual?.groups ?? {}).sort()).toEqual(['pipeline', 'start']); + expect(TranslationDataSchema.safeParse({ books: zh.books }).success).toBe(true); + }); + + it('reports an untranslated label under its own bucket', () => { + const report = computeI18nCoverage({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { books: { crm_manual: { label: '使用手册', groups: { start: { label: '快速上手' } } } } } }, + ], + }); + const zhIssues = report.issues.filter((i) => i.locale === 'zh-CN'); + const missing = zhIssues.map((i) => i.key); + expect(missing).toContain('books.crm_manual.groups.pipeline.label'); + expect(missing).toContain('books.crm_manual.description'); + expect(missing).toContain('books.crm_admin_manual.groups.setup.label'); + expect(missing).not.toContain('books.crm_manual.label'); + expect(missing).not.toContain('books.crm_manual.groups.start.label'); + // Nothing authored ⇒ nothing to translate. + expect(missing).not.toContain('books.crm_admin_manual.label'); + expect(zhIssues.find((i) => i.key === 'books.crm_manual.groups.pipeline.label')?.source).toBe('book'); + }); +}); diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index c6e1e73b6a9..619a8bbdcfc 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3541,7 +3541,9 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = { 'stack declares. `positions.NAME` a position\'s `name` that a package of this stack ' + 'declares, or an audience anchor (`everyone`, `guest`); `permissions.NAME` a permission ' + 'set\'s `name` that a package of this stack declares. `reports.NAME` a report\'s `name` that ' + - 'a package of this stack declares.', + 'a package of this stack declares. `books.BOOK` a doc book\'s `name` that a package of this ' + + 'stack declares, or a package id (its implicit book), and `.groups.KEY` a group `key` that ' + + 'book declares, not its label.', // The terminal toast keys are held to `FLOW_TERMINAL_MESSAGE_KEYS` by the rule's test. '`flows.FLOW` a flow\'s machine `name`; `.successMessage` / `.errorMessage` a toast the flow ' + 'itself authors (a bundle cannot add one); `.screens.NODE` the `id` of a `type: \'screen\'` ' + diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 837615236d7..59b1ce6e9b1 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; // The contract the object branch's coverage is pinned against (#13835) — see // the coverage describe block at the foot of this file. `@objectstack/spec` is // already a runtime dependency of this package, so the pin adds no edge. -import { FLOW_TERMINAL_MESSAGE_KEYS, ObjectTranslationDataSchema, TranslationDataSchema } from '@objectstack/spec/system'; +import { FLOW_TERMINAL_MESSAGE_KEYS, ObjectTranslationDataSchema, TranslationDataSchema, deriveImplicitPackageBook } from '@objectstack/spec/system'; // The schema the #18441 fold is SIZED against — see the surface pin in that // block. Same package, already a runtime dependency, so no new edge either. import { ObjectExtensionSchema } from '@objectstack/spec/data'; @@ -4513,3 +4513,63 @@ describe('validateTranslationReferences — reports', () => { expect(findings).toEqual([]); }); }); + +describe('validateTranslationReferences — doc books (#26690)', () => { + const stack = (data: Record, extra: Record = {}) => ({ + books: [{ + name: 'crm_manual', + label: 'User Manual', + groups: [ + { key: 'start', label: 'Getting started', include: 'crm_start_*' }, + { key: 'pipeline', label: 'Pipeline', include: 'crm_pipe_*' }, + ], + }], + ...extra, + translations: [{ en: data }], + }); + + it('a declared book and its declared group keys are clean', () => { + expect(validateTranslationReferences(stack({ + books: { crm_manual: { label: '使用手册', description: '说明', groups: { start: { label: '快速上手' }, pipeline: { label: '销售管道' } } } }, + }))).toEqual([]); + }); + + it('a book name no package declares is translation-target-unknown, at error', () => { + const findings = validateTranslationReferences(stack({ books: { crm_manaul: { label: 'x' } } })); + expect(findings.map((f) => [f.rule, f.severity, f.path, f.where])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0].en.books.crm_manaul', 'locale "en" · book "crm_manaul"'], + ]); + expect(findings[0].message).toContain('Did you mean "crm_manual"?'); + }); + + it('a group key the book does not declare is translation-target-unknown, even when it is a group LABEL', () => { + const findings = validateTranslationReferences(stack({ + books: { crm_manual: { groups: { start: { label: 'ok' }, strat: { label: 'x' }, Pipeline: { label: 'y' } } } }, + })); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0].en.books.crm_manual.groups.strat'], + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0].en.books.crm_manual.groups.Pipeline'], + ]); + expect(findings[0].message).toContain('Did you mean "start"?'); + }); + + it('resolves a book a sibling package of the artifact declares, with its group keys', () => { + expect(validateTranslationReferences(stack( + { books: { ops_guide: { label: '运维指南', groups: { deploy: { label: '部署' } } } } }, + { packages: [{ manifest: { id: 'com.example.ops', books: [{ name: 'ops_guide', groups: [{ key: 'deploy', label: 'Deploy', include: '*' }] }] } }] }, + ))).toEqual([]); + }); + + it("resolves the implicit per-package book: the package id, and its one group's key", () => { + const implicitKey = deriveImplicitPackageBook('com.example.crm').groups[0].key; + expect(validateTranslationReferences(stack( + { books: { 'com.example.crm': { label: '帮助', groups: { [implicitKey]: { label: '全部文档' } } } } }, + { manifest: { id: 'com.example.crm' } }, + ))).toEqual([]); + // … and a group key the implicit book does not carry is still an orphan. + expect(validateTranslationReferences(stack( + { books: { 'com.example.crm': { groups: { start: { label: 'x' } } } } }, + { manifest: { id: 'com.example.crm' } }, + )).map((f) => f.path)).toEqual(['translations[0].en.books.com.example.crm.groups.start']); + }); +}); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index a3540c8fe1d..16b04eea9a6 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -162,6 +162,16 @@ * this stack declares is an orphan, `translation-target-unknown` (`error`). * Leaf keys below the name are the schema's (`label` / `description`, strict). * + * ── Doc books ──────────────────────────────────────────────────────────── + * + * `books.` resolves against `Book.name`, the name `translateBook` looks + * a served book up by, and `books..groups.` against that book's + * group `key`s (#26690). A book no package declares, or a group key the book + * does not declare, is an orphan, `translation-target-unknown` (`error`). The + * implicit per-package book (`deriveImplicitPackageBook`: named by the package + * id, one group keyed `all`) is what the tree serves where a package authors + * no book of that name, so its keys resolve too. + * * ── Cross-package objects ──────────────────────────────────────────────── * * A stack legitimately translates objects it does not define — `sys_user`'s @@ -207,7 +217,7 @@ * is a key whose `COMPOSE_KEY_DISPOSITIONS` disposition puts it inside an * ADR-0130 D4 entry's assembled body: `objects` (#19064) plus `views`, * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349), - * `positions` and `permissions`, and `reports`. + * `positions` and `permissions`, `reports`, and `books`. * * ⚠️ `apps` reads BOTH that carrier and `navigationContributions`, and they * answer different questions — what a sibling DECLARES, and what this package @@ -228,6 +238,7 @@ import { AUDIENCE_ANCHOR_POSITIONS, expandViewContainer } from '@objectstack/spe import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { FLOW_TERMINAL_MESSAGE_KEYS, + deriveImplicitPackageBook, PAGE_TABS_COMPONENT_TYPE, hasPlatformObjectPrefix, isPlatformProvidedObjectName, @@ -268,6 +279,11 @@ const END_NODE_TYPE = 'end'; */ const PAGE_ACCORDION_COMPONENT_TYPE = 'page:accordion'; const REFUSED_OUTCOME = 'refused'; +/** + * The one group key of the implicit per-package book, read off the spec's own + * synthesis so the two cannot drift (#26690). + */ +const IMPLICIT_BOOK_GROUP_KEY = deriveImplicitPackageBook('_').groups[0].key; export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown'; export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown'; @@ -516,6 +532,11 @@ interface Universe { permissionSets: Set; /** Report names (`Report.name`) a `reports.` key may name. */ reports: Set; + /** + * Book name (`Book.name`) → the group `key`s a `books..groups.` + * key may name; the implicit per-package book included (#26690). + */ + books: Map>; /** * Page name (`Page.name`) → every component id the page ADDRESSES * (`pages..components.`), with that component's type and, for a @@ -1692,6 +1713,30 @@ function buildUniverse(stack: AnyRec): Universe { // `reports` carries disposition `concat` too, so a sibling's reports resolve. const reports = namesOf('reports'); + // ── Doc books: the names `books.` may name, and their group keys (#26690) ── + // + // `books` carries disposition `concat` too. A package's implicit book (named + // by its id, one `all` group) is served wherever no book of that name is + // authored, so its keys resolve as well. + const books = new Map>(); + for (const record of [...recordsOf(stack.books), ...artifactProvidedRecords(stack, 'books')]) { + const name = strName(record.name); + if (!name) continue; + const keys = books.get(name) ?? new Set(); + for (const group of Array.isArray(record.groups) ? record.groups : []) { + const key = isRec(group) ? strName(group.key) : undefined; + if (key) keys.add(key); + } + books.set(name, keys); + } + const packageIds = [ + isRec(stack.manifest) ? strName(stack.manifest.id) : undefined, + ...packagesOf(stack).map((entry) => (isRec(entry.manifest) ? strName(entry.manifest.id) : undefined)), + ]; + for (const id of packageIds) { + if (id && !books.has(id)) books.set(id, new Set([IMPLICIT_BOOK_GROUP_KEY])); + } + return { objects, extended, @@ -1706,6 +1751,7 @@ function buildUniverse(stack: AnyRec): Universe { positions, permissionSets, reports, + books, pages, }; } @@ -2178,6 +2224,35 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind } } + // ── books.[.groups.] (#26690) ────────────────────────── + for (const [bookName, rawBook] of Object.entries(asRecord(rawData.books))) { + const bookPath = `${base}.books.${bookName}`; + const groupKeys = universe.books.get(bookName); + if (!groupKeys) { + orphan( + `${inLocale} · book "${bookName}"`, + bookPath, + `Translations are keyed to book "${bookName}", which no package in this stack declares, ` + + `so nothing resolves this key and the book keeps its source-locale title.` + + suggest(bookName, universe.books.keys()), + "Match the key to a book's `name`, or drop it.", + ); + continue; + } + if (!isRec(rawBook)) continue; + for (const groupKey of Object.keys(asRecord(rawBook.groups))) { + if (groupKeys.has(groupKey)) continue; + orphan( + `${inLocale} · book "${bookName}" · group "${groupKey}"`, + `${bookPath}.groups.${groupKey}`, + `Translations are keyed to group "${groupKey}" of book "${bookName}", which declares no group ` + + `with that \`key\`, so the docs sidebar keeps the section's source-locale title.` + + suggest(groupKey, groupKeys), + "Match the key to a group's `key` (not its label), or drop it.", + ); + } + } + // ── flows.[.screens.[.fields.]] ──────── for (const [flowName, rawFlow] of Object.entries(asRecord(rawData.flows))) { const flowPath = `${base}.flows.${flowName}`; diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index d261d27030f..0bd3fcaa294 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -3926,7 +3926,8 @@ export function createMetaLayeredAnswer( // ── THE book tree ───────────────────────────────────────────────────────────── /** Everything {@link createMetaBookTreeAnswer} reads, supplied by the transport. */ -export interface MetaBookTreeSources extends MetaReadGateAudienceSources { +export interface MetaBookTreeSources + extends MetaReadGateAudienceSources, Pick { /** * One list read of `type` for the tree, env-wide, scoped to `packageId` when * the request names one (`?package=`, ADR-0048). A THROW propagates: an @@ -3972,7 +3973,13 @@ export type MetaBookTreeAnswer = * (fail closed when holdings cannot be resolved, ADR-0049). * 4. Every doc (the same package scope), collapsed to the request's locale and * reduced to the header the tree renders. - * 5. The tree, its ENTRIES narrowed by each doc's effective audience, so a + * 5. [#26690] The book itself in the request's locale — its `label` and each + * group's `label` from the bundle's `books` group — through + * {@link translateMetaDocument}, the table every `/meta/book` read + * translates through, so the tree and the list the console's sidebar + * draws cannot disagree. Translated AFTER the gate: the audience is + * judged on the book as stored, and a label is not an access input. + * 6. The tree, its ENTRIES narrowed by each doc's effective audience, so a * reader never sees an entry that would refuse them on fetch. */ export async function createMetaBookTreeAnswer( @@ -4003,5 +4010,7 @@ export async function createMetaBookTreeAnswer( tags: d.tags, packageId: d._packageId, })); - return { ok: true, tree: audience.readableTree(book, docs) }; + // A book has no packaged base (`PACKAGED_BASE_ACCESSORS`), so the protocol is never asked for. + const served = await translateMetaDocument({ ...sources, resolveProtocol: async () => undefined }, 'book', book); + return { ok: true, tree: audience.readableTree(served as typeof book, docs) }; } diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index 5f398b04809..ff28f31f741 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -389,6 +389,8 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M return prot.getMetaItems(request); }, requestLocale: (i18n) => this.extractLocale(req, i18n), + // [#26690] The book's own labels, from the bundle's `books` group. + resolveI18nService: () => this.resolveI18nService(environmentId, req), }, { name: req.params.name, packageId }, ); diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index 1a8af3f249f..d9b721d3667 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -226,6 +226,8 @@ const BUNDLE: Record = { positions: { sales_rep: { label: '销售代表' } }, permissions: { crm_sales_user: { label: '销售用户' } }, reports: { opps_by_stage: { label: '各阶段商机' } }, + // [#26690] A doc book's title and section titles. + books: { help_center: { label: '帮助中心', groups: { start: { label: '开始' } } } }, }, }; @@ -688,6 +690,12 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ // [#25788] So is a report — the list the console's report index reads. const zhReports = await read('/meta/reports', {}, { 'accept-language': 'zh-CN' }); expect(zhReports.items?.find((r: any) => r.name === 'opps_by_stage')?.label).toBe('各阶段商机'); + // [#26690] So is a doc book — the list the console's docs sidebar is built from. + const zhBooks = await read('/meta/books', {}, { 'accept-language': 'zh-CN' }); + const zhHelp = zhBooks.items?.find((b: any) => b.name === 'help_center'); + expect([zhHelp?.label, zhHelp?.groups?.map((g: any) => g.label)]).toEqual(['帮助中心', ['开始']]); + const enHelp = (await read('/meta/book', {}, { 'accept-language': 'en-US' })).items?.find((b: any) => b.name === 'help_center'); + expect([enHelp?.label, enHelp?.groups?.map((g: any) => g.label)]).toEqual(['Help Centre', ['Start']]); }); }); @@ -1005,6 +1013,7 @@ describe('[#20408] the item read: the reference moves — each probe changes Res expect((await read('/meta/position/sales_rep', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售代表'); expect((await read('/meta/permission/crm_sales_user', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售用户'); expect((await read('/meta/report/opps_by_stage', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('各阶段商机'); + expect((await read('/meta/book/help_center', {}, { 'accept-language': 'zh-CN' })).item?.groups?.[0]?.label).toBe('开始'); const zhDoc = await read('/meta/doc/crm_intro', { locale: 'zh-CN' }); expect(zhDoc.item?.label).toBe('入门'); expect(text(zhDoc)).not.toContain('"translations"'); @@ -1236,6 +1245,20 @@ describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route Re expect({ status: member.status, code: member.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); expect(text(await bootRest('holder').read('/meta/book/admin_guide/tree'))).toContain('crm_admin_runbook'); }); + + it('[#26690] the tree serves the request locale: a zh-CN reader gets the book title and section titles, an en-US reader the authored ones', async () => { + for (const [transport, boot] of [['rest', bootRest], ['dispatcher', bootDispatcher]] as const) { + // The authored group only: the synthetic `uncategorized` orphan group is not a book key. + const authored = (tree: any) => tree?.groups?.filter((g: any) => g.key === 'start'); + const zh = served(await boot('holder').read('/meta/book/help_center/tree', {}, { 'accept-language': 'zh-CN' }), transport) as any; + expect([zh?.label, authored(zh)?.map((g: any) => g.label)], transport).toEqual(['帮助中心', ['开始']]); + // The entry beneath keeps its own `doc.translations` localization. + expect(authored(zh)?.[0]?.entries?.map((e: any) => e.label), transport).toEqual(['入门']); + const en = served(await boot('holder').read('/meta/book/help_center/tree', {}, { 'accept-language': 'en-US' }), transport) as any; + expect([en?.label, authored(en)?.map((g: any) => g.label)], transport).toEqual(['Help Centre', ['Start']]); + expect(authored(en)?.[0]?.entries?.map((e: any) => e.label), transport).toEqual(['Getting started']); + } + }); }); describe('[#20408] an undetermined field visibility (ADR-0106 D6 tier 2) serves the object schema `private, no-store` on both transports', () => { diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index d7f49d46d24..364cbbee92d 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -1141,9 +1141,12 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin const answer = await createMetaBookTreeAnswer( { ...metaItemReadGateSources(deps, _context, protocol), + // [#26690] The translation I/O the item and list reads use: the + // tree translates its book (the bundle's `books` group) the same way. + ...metaTranslationSources(deps, _context, protocol, (i18n) => + metaRequestLocale({ headers: _context.request?.headers, query }, i18n)), listTreeInput: (type, scopedPackageId) => Promise.resolve(protocol.getMetaItems!({ type, ...(scopedPackageId ? { packageId: scopedPackageId } : {}) })), - requestLocale: (i18n) => metaRequestLocale({ headers: _context.request?.headers, query }, i18n), }, { name: decodeMetaNameSegment(parts[1]), packageId }, ); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 982d40c2cbe..c102c7586d5 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -73,9 +73,11 @@ "BookAudience (type)", "BookAudienceSchema (const)", "BookGroup (type)", + "BookGroupLike (interface)", "BookGroupSchema (const)", "BookInclude (type)", "BookIncludeSchema (const)", + "BookLike (interface)", "BookNode (type)", "BookNodeSchema (const)", "BookSchema (const)", @@ -891,6 +893,7 @@ "toLocaleDescriptors (function)", "translateAction (function)", "translateApp (function)", + "translateBook (function)", "translateDashboard (function)", "translateDataset (function)", "translateFlow (function)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 1be1a850ce8..17dfed4a5bc 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -903,6 +903,7 @@ "system/Plan:priceMonthly", "system/Plan:priceYearly", "system/PlatformTranslationData:apps", + "system/PlatformTranslationData:books", "system/PlatformTranslationData:dashboards", "system/PlatformTranslationData:datasets", "system/PlatformTranslationData:flows", @@ -1031,6 +1032,7 @@ "system/SecurityEventCorrelation:linkEncryptionToAudit", "system/SecurityEventCorrelation:linkMaskingToAudit", "system/ServedTranslationData:apps", + "system/ServedTranslationData:books", "system/ServedTranslationData:dashboards", "system/ServedTranslationData:datasets", "system/ServedTranslationData:flows", @@ -1333,6 +1335,7 @@ "system/TranslationCoverageResult:totalKeys", "system/TranslationCoverageResult:translatedKeys", "system/TranslationData:apps", + "system/TranslationData:books", "system/TranslationData:dashboards", "system/TranslationData:datasets", "system/TranslationData:flows", @@ -1361,6 +1364,7 @@ "system/TranslationItem:_packageVersion", "system/TranslationItem:_provenance", "system/TranslationItem:apps", + "system/TranslationItem:books", "system/TranslationItem:dashboards", "system/TranslationItem:datasets", "system/TranslationItem:flows", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index d5a7c120bc8..9b4f706b011 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -72,9 +72,11 @@ "BookAudience": "src/system/book.zod.ts#BookAudience (type)", "BookAudienceSchema": "src/system/book.zod.ts#BookAudienceSchema (const)", "BookGroup": "src/system/book.zod.ts#BookGroup (type)", + "BookGroupLike": "src/system/i18n-resolver.ts#BookGroupLike (interface)", "BookGroupSchema": "src/system/book.zod.ts#BookGroupSchema (const)", "BookInclude": "src/system/book.zod.ts#BookInclude (type)", "BookIncludeSchema": "src/system/book.zod.ts#BookIncludeSchema (const)", + "BookLike": "src/system/i18n-resolver.ts#BookLike (interface)", "BookNode": "src/system/book.zod.ts#BookNode (type)", "BookNodeSchema": "src/system/book.zod.ts#BookNodeSchema (const)", "BookSchema": "src/system/book.zod.ts#BookSchema (const)", @@ -852,6 +854,7 @@ "toLocaleDescriptors": "src/system/i18n-resolver.ts#toLocaleDescriptors (function)", "translateAction": "src/system/i18n-resolver.ts#translateAction (function)", "translateApp": "src/system/i18n-resolver.ts#translateApp (function)", + "translateBook": "src/system/i18n-resolver.ts#translateBook (function)", "translateDashboard": "src/system/i18n-resolver.ts#translateDashboard (function)", "translateDataset": "src/system/i18n-resolver.ts#translateDataset (function)", "translateFlow": "src/system/i18n-resolver.ts#translateFlow (function)", diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 3979c0c9e77..87b8234dd48 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 38 | 0 | 0 | 0 | 0 | 38 | +| `translation` | 41 | 0 | 0 | 0 | 0 | 41 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index e8e745cbf50..d4dd272ab1a 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -266,6 +266,40 @@ } } }, + "books": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateBook (`books..label` / `.description` and `books..groups..label`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/rest/src/meta-item-read-gate.ts#translateMetaList (the /meta list read that applies it per request locale); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (the book tree translates the book it resolves through the same table)", + "note": "Seeded LIVE with its readers in the same change (the datasets precedent): a served `book` document is localized at the REST boundary, list and item, and `GET /meta/book` is what the console's docs portal builds its sidebar from; the tree route translates the resolved book before rendering the spine. Retires nothing: the book's inline `translations` map stays a tombstone, and its prescription now points here.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupBookAttr (`books..label`, read by translateBook)", + "note": "Overlays the served book's own `label` — the docs sidebar title." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupBookAttr (`books..description`, read by translateBook)", + "note": "Overlays the served book's own `description`." + }, + "groups": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupBookGroupLabel (`books..groups..label`, read by translateBook)", + "note": "Overlays each group's `label`, addressed by the group's stable `key` — the sidebar's section titles.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupBookGroupLabel (`books..groups..label`, read by translateBook)", + "note": "The group's section title in the docs sidebar and in the resolved tree's `groups[].label`." + } + } + } + } + }, "metadataForms": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index 16f47667ea0..d3001a5dbdc 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -59,10 +59,17 @@ export type BookInclude = string | { tag: string }; * The trap here was PROXIMITY, not plausibility. `doc.translations` — two files * over, the same shape, the same name — is read on every path that renders a * doc. So a book's own map reads as the same feature switched on one level up. - * It never was: the tree endpoint and the portal render `label` / `description` - * verbatim, and the generic bundle translator covers view / action / object / - * app / dashboard / page only (`i18n-resolver.ts`). A localized book was parsed, - * stored, round-tripped — and rendered in the authoring locale to every reader. + * It never was: no resolver read a book's own map, and the tree endpoint and + * the portal rendered `label` / `description` verbatim. A localized book was + * parsed, stored, round-tripped — and rendered in the authoring locale to every + * reader. + * + * The spine's strings now localize through the one path the rest of the + * platform uses: the translation bundle's `books` group + * (`books..label` / `.description`, `books..groups..label`), + * overlaid by `translateBook` (`i18n-resolver.ts`) on `GET /meta/book` and on + * the tree. So the prescription is a move, not only a delete — and the inline + * map stays retired, because a second translation path is the trap. * * Shared by both levels deliberately: an author who localized one almost * certainly localized the other, and splitting the wording would make the second @@ -70,12 +77,12 @@ export type BookInclude = string | { tag: string }; */ const BOOK_TRANSLATIONS_RETIRED = 'Inline `translations` on a book (and on a book group) was removed in @objectstack/spec ' - + '17.0.0 (ADR-0049) — no resolver ever read it. The book tree endpoint and the ' - + 'docs portal render `label` / `description` verbatim in every locale, so a localized ' - + 'book shipped its authoring-locale strings to every reader. Delete the key. NOTE the ' - + 'near neighbour that DOES work: `doc.translations` is live and read on every doc render ' - + 'path — localize the docs themselves, and the portal picks the reader\'s locale up from ' - + 'there. ' + + '17.0.0 (ADR-0049) — no resolver ever read it, so a localized book shipped its ' + + 'authoring-locale strings to every reader. Delete the key and move each locale\'s strings ' + + 'into the translation bundle: `books..label` / `.description` and ' + + '`books..groups..label` (`os i18n extract` seeds them). The book list, ' + + 'the book tree and the docs sidebar resolve those per reader locale. A doc\'s own title ' + + 'and body still localize through `doc.translations`. ' + 'This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade.'; export const BookGroupSchema = lazySchema(() => @@ -84,7 +91,7 @@ export const BookGroupSchema = lazySchema(() => .string() .regex(/^[a-z][a-z0-9_]*$/, 'group key must be lowercase snake_case') .describe('Stable group key (used by overrides, deep links, explicit `doc.group`)'), - label: z.string().describe('Section title — first-class, i18n-homed'), + label: z.string().describe('Section title — first-class, i18n-homed: translated by `books..groups..label` in the translation bundle'), // TOMBSTONE, not a deletion: `BookGroupSchema` is a plain `z.object` with // no `.strict()`, so a bare delete would have zod silently STRIP the key — // replacing one silent no-op with another. `retiredKey` types it `never` @@ -150,7 +157,7 @@ export const BookSchema = lazySchema(() => .string() .regex(/^[a-z][a-z0-9_]*$/, 'name must be lowercase snake_case') .describe('Book name (namespace prefix recommended, like every metadata name)'), - label: z.string().optional().describe('Display title'), + label: z.string().optional().describe('Display title — translated by `books..label` in the translation bundle'), description: z.string().optional(), // `translations` removed in 17.0.0 (#4667) — see BOOK_TRANSLATIONS_RETIRED. slug: z.string().optional().describe('Portal URL segment; defaults to name sans prefix'), diff --git a/packages/spec/src/system/i18n-book.test.ts b/packages/spec/src/system/i18n-book.test.ts new file mode 100644 index 00000000000..114ff56ddc8 --- /dev/null +++ b/packages/spec/src/system/i18n-book.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `books..…` — a doc book's translation group, and the translator that +// overlays it on the served `book` document. +// +// Before this group existed a doc's body followed the reader's locale while +// the frame around it did not: the book title and every sidebar section title +// shipped in the authoring language, because the book's own inline map had +// been retired (no resolver ever read it) and the bundle had no key for it. + +import { describe, it, expect } from 'vitest'; +import { + TranslationDataSchema, + TranslationItemSchema, + PlatformTranslationDataSchema, + type TranslationBundle, +} from './translation.zod'; +import { translateMetadataDocument, translateBook, TRANSLATABLE_METADATA_TYPES } from './i18n-resolver'; +// The fixture is PARSED by the authoring schema rather than hand-shaped, so a +// translator reading a member the schema does not carry cannot pass here. +import { BookSchema, resolveBookTree } from './book.zod'; + +const book = () => BookSchema.parse({ + name: 'crm_manual', + label: 'User Manual', + description: 'Everything a sales rep needs', + groups: [ + { key: 'start', label: 'Getting started', include: 'crm_start_*' }, + { key: 'pipeline', label: 'Pipeline', include: 'crm_pipe_*' }, + ], +}) as any; + +const bundle: TranslationBundle = { + 'zh-CN': { + books: { + crm_manual: { + label: '使用手册', + description: '销售代表需要的一切', + groups: { start: { label: '快速上手' }, pipeline: { label: '销售管道' } }, + }, + }, + }, +}; + +describe('TranslationDataSchema — books', () => { + it('opens at all three doors — bundle entry, platform bundle and registered item', () => { + expect(TranslationDataSchema.safeParse(bundle['zh-CN']).success).toBe(true); + expect(PlatformTranslationDataSchema.safeParse(bundle['zh-CN']).success).toBe(true); + expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', ...bundle['zh-CN'] }).success).toBe(true); + }); + + it('closes the face at the book\'s `label` / `description` and each group\'s `label`', () => { + for (const entry of [{ crm_manual: { icon: 'book' } }, { crm_manual: { groups: { start: { order: 1 } } } }]) { + const result = TranslationDataSchema.safeParse({ books: entry }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + }); + + it('renames the singular and the book\'s own aliases onto the declared keys', () => { + expect(() => TranslationDataSchema.parse({ book: { b: { label: 'x' } } })).toThrow(/books/); + expect(() => TranslationDataSchema.parse({ books: { b: { title: 'x' } } })).toThrow(/label/); + expect(() => TranslationDataSchema.parse({ books: { b: { sections: {} } } })).toThrow(/groups/); + expect(() => TranslationDataSchema.parse({ books: { b: { groups: { g: { title: 'x' } } } } })).toThrow(/label/); + }); + + it('the inline map on the book stays retired, and its prescription names the bundle group', () => { + const result = BookSchema.safeParse({ ...book(), translations: { 'zh-CN': { label: '使用手册' } } }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error!.issues)).toContain('books..groups..label'); + }); +}); + +describe('translateBook', () => { + it('is registered in the dispatch table, so the REST boundary follows', () => { + // `TRANSLATABLE_METADATA_TYPES` is DERIVED from the dispatch table and + // `@objectstack/rest` reads it: `GET /meta/book` (list and item) is + // localized, and the tree route translates through the same table. + expect(TRANSLATABLE_METADATA_TYPES.has('book')).toBe(true); + }); + + it('a zh-CN reader gets the book title, description and every section title', () => { + const out = translateMetadataDocument('book', book(), bundle, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('使用手册'); + expect(out.description).toBe('销售代表需要的一切'); + expect(out.groups.map((g: any) => [g.key, g.label])).toEqual([['start', '快速上手'], ['pipeline', '销售管道']]); + // Identity and the membership rules ride through untouched. + expect(out.name).toBe('crm_manual'); + expect(out.groups.map((g: any) => g.include)).toEqual(['crm_start_*', 'crm_pipe_*']); + }); + + it('the translated book renders a translated spine through the tree resolver', () => { + const docs = [{ name: 'crm_start_login', label: '登录' }, { name: 'crm_pipe_stages', label: '阶段' }]; + const tree = resolveBookTree(translateBook(book(), bundle, { locale: 'zh-CN' }), docs); + expect(tree.label).toBe('使用手册'); + expect(tree.groups.map((g) => g.label)).toEqual(['快速上手', '销售管道']); + }); + + it('an en-US reader is unchanged — the same reference, the authored strings', () => { + const doc = book(); + expect(translateBook(doc, bundle, { locale: 'en-US' })).toBe(doc); + expect(translateBook(doc, undefined, { locale: 'zh-CN' })).toBe(doc); + }); + + it('a missing translation falls back to the authored string, per key', () => { + const partial: TranslationBundle = { 'zh-CN': { books: { crm_manual: { groups: { pipeline: { label: '销售管道' } } } } } }; + const out = translateBook(book(), partial, { locale: 'zh-CN' }) as any; + expect(out.label).toBe('User Manual'); + expect(out.description).toBe('Everything a sales rep needs'); + expect(out.groups.map((g: any) => g.label)).toEqual(['Getting started', '销售管道']); + }); + + it('reads only its own book\'s entry, and applies the BCP-47 ladder and the fallback chain', () => { + const other = BookSchema.parse({ name: 'crm_admin_manual', label: 'Admin Manual', groups: [{ key: 'start', label: 'Start' }] }) as any; + expect(translateBook(other, bundle, { locale: 'zh-CN' })).toBe(other); + expect((translateBook(book(), bundle, { locale: 'zh' }) as any).label).toBe('使用手册'); + expect((translateBook(book(), bundle, { locale: 'fr-FR', fallbackChain: ['zh-CN'] }) as any).label).toBe('使用手册'); + }); + + it('does not mutate the input', () => { + const doc = book(); + const before = JSON.parse(JSON.stringify(doc)); + translateBook(doc, bundle, { locale: 'zh-CN' }); + expect(doc).toEqual(before); + }); +}); diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 01a0ea1bd25..12c4841edec 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1518,6 +1518,10 @@ const METADATA_DOCUMENT_TRANSLATORS: Record< // The console's report index and report page both read `GET /meta/report`, // so registering here is the whole wiring. report: translateReport, + // A doc book's title and section titles: the console's docs portal builds + // its sidebar from `GET /meta/book`, and the tree route translates the book + // it resolves through this same table (`createMetaBookTreeAnswer`). + book: translateBook, }; /** @@ -2232,6 +2236,98 @@ export function translatePermissionSet( return translateSecurityCatalogItem('permissions', doc, bundle, opts); } +// ──────────────────────────────────────────────────────────────────────────── +// Doc-book resolver (label + description + group labels) +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal book-group shape consumed by {@link translateBook}. */ +export interface BookGroupLike { + key?: string; + label?: string; + [key: string]: any; +} + +/** Minimal doc-book shape consumed by {@link translateBook}. */ +export interface BookLike { + name: string; + label?: string; + description?: string; + groups?: BookGroupLike[]; + [key: string]: any; +} + +function lookupBookAttr( + bundle: TranslationBundle | undefined, + bookName: string, + attr: 'label' | 'description', + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.books?.[bookName]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +function lookupBookGroupLabel( + bundle: TranslationBundle | undefined, + bookName: string, + groupKey: string, + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.books?.[bookName]?.groups?.[groupKey]?.label; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * Apply the active locale to a doc-book metadata document — its `label` and + * `description` against `books..label` / `.description`, and each + * group's `label` against `books..groups..label`, addressed by the + * group's stable `key` as an app's navigation node is by its `id`. The input + * document is not mutated; it comes back as the same reference when the + * bundle carries none of its keys, and a key the bundle does not carry leaves + * the authored string. + * + * Registered in {@link METADATA_DOCUMENT_TRANSLATORS}, so the REST boundary + * localizes every served book (`GET /meta/book`, list and item — the list the + * console's docs portal builds its sidebar from), and the book tree route + * (`GET /meta/book/:name/tree`) translates the book it resolves through the + * same table before the spine is rendered. + */ +export function translateBook( + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!doc || typeof doc !== 'object') return doc; + const bookName = doc.name; + if (typeof bookName !== 'string' || bookName.length === 0 || !bundle) return doc; + const label = lookupBookAttr(bundle, bookName, 'label', opts); + const description = lookupBookAttr(bundle, bookName, 'description', opts); + let groupsMoved = false; + const groups = Array.isArray(doc.groups) + ? doc.groups.map((group) => { + if (!group || typeof group !== 'object' || typeof group.key !== 'string') return group; + const translated = lookupBookGroupLabel(bundle, bookName, group.key, opts); + if (translated === undefined) return group; + groupsMoved = true; + return { ...group, label: translated }; + }) + : doc.groups; + if (label === undefined && description === undefined && !groupsMoved) return doc; + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + ...(groupsMoved ? { groups } : {}), + }; +} + // ──────────────────────────────────────────────────────────────────────────── // Report metadata resolver (label + description) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 196f4801ff7..33196290c53 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -744,10 +744,10 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + 'application\'s own copy use the fourteen groups this bundle does declare, in the order it ' + + 'application\'s own copy use the fifteen groups this bundle does declare, in the order it ' + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " - + "'datasets', 'reports', 'pages', 'flows', 'positions', 'permissions', 'metadataForms', " - + "'settingsCommon'. " + + "'datasets', 'reports', 'pages', 'flows', 'positions', 'permissions', 'books', " + + "'metadataForms', 'settingsCommon'. " + "Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' + 'badges, under `settingsCommon.sourceLabels`) are NOT what is being refused here — only the ' @@ -789,7 +789,7 @@ const ITEM_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than overriding it from an application. For an ' - + 'application\'s own copy use the groups this item does declare — the same fourteen a per-app ' + + 'application\'s own copy use the groups this item does declare — the same fifteen a per-app ' + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + "refused here — only the per-namespace manifest copy under 'settings' is. " @@ -828,12 +828,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Fourteen groups — the fifteenth, `settings`, is platform-only and lives in + * schema. Fifteen groups — the sixteenth, `settings`, is platform-only and lives in * {@link platformSettingsShape}. * * Three schemas need exactly these keys: {@link TranslationDataSchema} (one - * entry of a per-app file-authored bundle — these fourteen and no more), - * {@link PlatformTranslationDataSchema} (these fourteen plus `settings`) and + * entry of a per-app file-authored bundle — these fifteen and no more), + * {@link PlatformTranslationDataSchema} (these fifteen plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — * the per-app face plus `locale`, its identity keys and the ADR-0010 * envelope; it carried the platform face's `settings` too until #19620). The item used to @@ -1773,6 +1773,41 @@ const appTranslationDataShape = () => ({ description: z.string().optional().describe("Translated permission-set description — overlays the set's own `description`"), })).optional().describe('Permission-set translations keyed by permission-set name'), + /** + * Doc-book translations keyed by book name (`Book.name`, + * `system/book.zod.ts`, the stack's `books[]`). + * + * books..label → the book's own `label` + * books..description → the book's own `description` + * books..groups..label → a group's `label`, by the group's `key` + * + * A book is the spine of a docs table of contents, and its strings frame + * every page of it: the book title and each section title in the docs + * sidebar. The pages themselves localize through `doc.translations`; the + * spine localizes here, the way an app's label and navigation labels do + * (`apps..navigation..label`, keyed by the stable node id as a + * group is by its stable `key`). Overlaid by `translateBook` + * (`i18n-resolver.ts`) on every served `book` document (`GET /meta/book`, + * the list the console's docs portal draws its sidebar from) and on the + * book `GET /meta/book/:name/tree` resolves. The inline map a book carried + * before 17.0.0 stays retired: one translation path, this one. + */ + books: z.record(z.string(), strictObject({ + surface: 'this book translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups' }, + }, { + label: z.string().optional().describe("Translated book title — overlays the book's own `label`"), + description: z.string().optional().describe("Translated book description — overlays the book's own `description`"), + groups: z.record(z.string(), strictObject({ + surface: 'this book group translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label' }, + }, { + label: z.string().describe("Translated section title — overlays the group's own `label`"), + })).optional().describe('Group (section) translations keyed by the group `key`'), + })).optional().describe('Doc-book translations keyed by book name'), + /** * Translations for **metadata-type configuration forms** — the forms * used by admins to author objects, fields, agents, flows, etc. in the @@ -1987,10 +2022,10 @@ const platformSettingsShape = () => ({ * One locale of a PER-APP translation bundle — `stack.translations`, and * everything {@link defineTranslationBundle} builds. * - * Fourteen groups: every group the platform bundle declares EXCEPT `settings`, + * Fifteen groups: every group the platform bundle declares EXCEPT `settings`, * which is platform-only and is refused here by name with * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See - * {@link PlatformTranslationDataSchema} for the fifteen-group face and for why + * {@link PlatformTranslationDataSchema} for the sixteen-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -2001,7 +2036,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ // `settings`, and an alias prescribing a key the shape rejects is a // suggestion the author cannot take (the `alias-integrity` audit judges // exactly that). Both spellings are answered by `guidance` above instead. - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', book: 'books', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -2011,7 +2046,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the fifteen groups, `settings` + * One locale of a PLATFORM translation bundle — the sixteen groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -2046,7 +2081,7 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the platform translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', book: 'books', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -2214,7 +2249,7 @@ export type TranslationConfig = z.input; * to whoever — or whatever — authored it. * * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): - * the item takes the PER-APP face, the same fourteen groups as + * the item takes the PER-APP face, the same fifteen groups as * {@link TranslationDataSchema}, and refuses `settings` (and the singular * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. * The file door and the item door are two authoring surfaces for one app @@ -2253,7 +2288,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // this door no longer declares `settings`, and an alias prescribing a key // the shape rejects is a suggestion the author cannot take. Both spellings // are answered by `guidance` above instead. - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', book: 'books', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index b68f688d516..061ad21d74c 100644 --- a/scripts/fixtures/i18n-walk-parity/every-group.stack.json +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -178,5 +178,13 @@ ], "permissions": [ { "name": "parity_auditor", "label": "Parity Auditor", "description": "Reads parity findings" } + ], + "books": [ + { + "name": "parity_handbook", + "label": "Parity Handbook", + "description": "How parity is measured", + "groups": [{ "key": "basics", "label": "Basics", "include": "parity_*" }] + } ] } diff --git a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx new file mode 100644 index 00000000000..a2c7fd1f2fe --- /dev/null +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -0,0 +1,142 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26690 — the docs sidebar under `/apps/:appName/docs` draws a doc + * book's title and section titles in the reader's locale. + * + * The portal builds its sidebar from `GET /meta/book`, which the server answers + * in the request locale (`Accept-Language`, sent by the console's client on + * every request) by overlaying the bundle's `books` group through + * `translateBook`. This test mounts the REAL route table, layout, reader and + * sidebar over an adapter that answers the book list the way the server does: + * the authored book run through the spec's own translator with a translation + * bundle, for the locale the "request" carries. So the chain pinned here is + * bundle → served book → sidebar, with nothing hand-translated in between. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +// Paid at collection, not inside the adapter's clocked `getItems` call. +import '@objectstack/spec/system'; +import type { TranslationBundle } from '@objectstack/spec/system'; + +const { ADAPTER, REQUEST } = vi.hoisted(() => { + const REQUEST = { locale: 'en-US' }; + const DOCS = [ + { name: 'crm_start_login', label: 'Signing in', _packageId: 'com.example.crm', order: 1 }, + { name: 'crm_pipe_stages', label: 'Stages', _packageId: 'com.example.crm', order: 2 }, + ]; + const BOOK = { + name: 'crm_manual', + label: 'User Manual', + slug: 'manual', + _packageId: 'com.example.crm', + groups: [ + { key: 'start', label: 'Getting started', include: 'crm_start_*' }, + { key: 'pipeline', label: 'Pipeline', include: 'crm_pipe_*' }, + ], + }; + const BUNDLE: TranslationBundle = { + 'zh-CN': { + books: { crm_manual: { label: '使用手册', groups: { start: { label: '快速上手' }, pipeline: { label: '销售管道' } } } }, + }, + }; + const ADAPTER = { + getClient: () => ({ + meta: { + getItems: async (type: string) => { + if (type === 'doc') return DOCS; + if (type !== 'book') return []; + // What `GET /meta/book` answers: every book through the translator + // table, for the request's locale. + const { translateMetadataDocument } = await import('@objectstack/spec/system'); + return [translateMetadataDocument('book', BOOK, BUNDLE, { locale: REQUEST.locale })]; + }, + getItem: async (_type: string, name: string) => ({ item: { name, content: `BODY ${name}` } }), + }, + }), + }; + return { ADAPTER, REQUEST }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, +})); + +vi.mock('@object-ui/plugin-markdown', async (importOriginal) => ({ + ...(await importOriginal>()), + MarkdownRenderer: ({ schema }: { schema: { content?: string } }) => ( +
{schema.content}
+ ), + extractToc: () => [], +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k }), +})); + +// Imported AFTER the mocks so the pages pick up the mocked modules. +import DocsLayout from './DocsLayout'; +import DocsSlug from './DocsSlug'; +import DocPage from './DocPage'; + +function Portal({ entry }: { entry: string }) { + return ( + + + }> + } /> + } /> + + + + ); +} + +/** + * The book sidebar, found by its accessible name — the book's title. It renders + * once wide and once in the narrow disclosure, so every copy is returned. + */ +async function sidebars(title: string): Promise { + await screen.findByTestId('doc-content'); + return screen.getAllByRole('navigation', { name: title }); +} + +afterEach(() => { + cleanup(); + REQUEST.locale = 'en-US'; +}); + +describe('objectstack#26690 — the docs sidebar serves the request locale', () => { + it('a zh-CN reader sees the book title and every section title in zh-CN', async () => { + REQUEST.locale = 'zh-CN'; + render(); + const navs = await sidebars('使用手册'); + expect(navs.length).toBeGreaterThanOrEqual(1); + expect(screen.queryAllByRole('navigation', { name: 'User Manual' })).toHaveLength(0); + for (const nav of navs) { + expect(within(nav).getByText('快速上手')).toBeInTheDocument(); + expect(within(nav).getByText('销售管道')).toBeInTheDocument(); + expect(within(nav).queryByText('Getting started')).toBeNull(); + } + }); + + it('an en-US reader is unchanged: the authored title and section titles', async () => { + render(); + const navs = await sidebars('User Manual'); + expect(navs.length).toBeGreaterThanOrEqual(1); + for (const nav of navs) { + expect(within(nav).getByText('Getting started')).toBeInTheDocument(); + expect(within(nav).getByText('Pipeline')).toBeInTheDocument(); + } + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index e3a19eb3467..c596a33eeb8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -949,6 +949,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.translationPreview.category.flows': 'Flows', 'engine.translationPreview.category.positions': 'Positions', 'engine.translationPreview.category.permissions': 'Permission Sets', + 'engine.translationPreview.category.books': 'Doc Books', 'engine.translationPreview.category.metadataForms': 'Metadata Forms', 'engine.translationPreview.category.settingsCommon': 'Settings UI', 'engine.translationPreview.coverage': 'Category coverage:', @@ -3031,6 +3032,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.translationPreview.category.flows': '流程', 'engine.translationPreview.category.positions': '岗位', 'engine.translationPreview.category.permissions': '权限集', + 'engine.translationPreview.category.books': '文档书', 'engine.translationPreview.category.metadataForms': '元数据表单', 'engine.translationPreview.category.settingsCommon': '设置界面', 'engine.translationPreview.coverage': '分类覆盖率:', diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx index 26ca72b5c35..bd0bf469550 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx @@ -69,6 +69,11 @@ const ONE_ENTRY: Record; key: string; ro key: 'contract_reviewer', row: () => keys(2), }, + books: { + data: { crm_manual: { label: 'Manuel', groups: { start: { label: 'Démarrage' } } } }, + key: 'crm_manual', + row: () => keys(2), + }, metadataForms: { data: { object: { label: 'Objet' } }, key: 'object', row: () => keys(1) }, // `settingsCommon` is one strict object, not a record of author-named nodes: // its members (`sourceLabels`) are what the card counts and samples. diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx index d9fe855240a..7d6f61332ba 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx @@ -33,6 +33,7 @@ import * as React from 'react'; import { AppWindow, BarChart3, + BookOpen, ClipboardList, Database, FileText, @@ -100,6 +101,7 @@ const CATEGORY_DEFS = { flows: { label: 'engine.translationPreview.category.flows', icon: Workflow, flat: false }, positions: { label: 'engine.translationPreview.category.positions', icon: Users, flat: false }, permissions: { label: 'engine.translationPreview.category.permissions', icon: ShieldCheck, flat: false }, + books: { label: 'engine.translationPreview.category.books', icon: BookOpen, flat: false }, metadataForms: { label: 'engine.translationPreview.category.metadataForms', icon: FileText, flat: false }, settingsCommon: { label: 'engine.translationPreview.category.settingsCommon', icon: Settings2, flat: false }, } satisfies Record>; diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx index 26a8c8eccd4..a3c4be75c71 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx @@ -464,6 +464,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.category.flows' }, { key: 'engine.translationPreview.category.positions' }, { key: 'engine.translationPreview.category.permissions' }, + { key: 'engine.translationPreview.category.books' }, { key: 'engine.translationPreview.category.metadataForms' }, { key: 'engine.translationPreview.category.settingsCommon' }, { key: 'engine.translationPreview.categoryEmpty' }, @@ -471,7 +472,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.keyCountOne', vars: { count: 1 } }, ]); // The bundle's own locale is author data, not the designer's language. - expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/14 (14%)']); + expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/15 (13%)']); }); } }); diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index 0f3638a4788..b4a6ab5e78e 100644 --- a/ui/packages/i18n/src/utils/spec-translations.ts +++ b/ui/packages/i18n/src/utils/spec-translations.ts @@ -107,6 +107,9 @@ const SPEC_TRANSLATION_GROUPS = [ // objectstack #25788 serves `reports` (a report's label and description are // translatable), copied through onto `app` the same way. 'reports', + // objectstack #26690 serves `books` (a doc book's title, description and + // section titles are translatable), copied through onto `app` the same way. + 'books', ] as const; function isPlainObject(value: unknown): value is Record {