From 8a93fce32dc90186c52ccde5bd29a95a53d6ec85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:09:02 +0000 Subject: [PATCH 01/10] feat(spec,rest,cli): a doc book's label and group labels translate through the bundle's books group (wip) Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j Co-Authored-By: Claude --- packages/cli/src/utils/i18n-coverage.ts | 7 + packages/cli/src/utils/i18n-extract.ts | 34 +++++ packages/cli/test/i18n-book-walk.test.ts | 88 +++++++++++ packages/rest/src/meta-item-read-gate.ts | 15 +- packages/rest/src/meta-item-read-routes.ts | 2 + .../meta-list-projection-parity.test.ts | 21 +++ packages/runtime/src/domains/meta.ts | 5 +- packages/spec/liveness/translation.json | 26 ++++ packages/spec/src/system/book.zod.ts | 31 ++-- packages/spec/src/system/i18n-book.test.ts | 126 ++++++++++++++++ packages/spec/src/system/i18n-resolver.ts | 96 ++++++++++++ packages/spec/src/system/translation.zod.ts | 63 ++++++-- .../i18n-walk-parity/every-group.stack.json | 8 + .../docs-portal.bookLocale-26690.test.tsx | 137 ++++++++++++++++++ 14 files changed, 629 insertions(+), 30 deletions(-) create mode 100644 packages/cli/test/i18n-book-walk.test.ts create mode 100644 packages/spec/src/system/i18n-book.test.ts create mode 100644 ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx 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 ad6a953488e..dcf2943d08d 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -124,6 +124,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 @@ -230,6 +232,7 @@ export interface ExpectedEntry { | 'flow' | 'position' | 'permission' + | 'book' | 'metadataType' | 'metadataFormSection' | 'metadataFormField'; @@ -1631,6 +1634,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) { @@ -1946,6 +1952,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/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..d190bbf2ebd 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,18 @@ 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) { + const zh = served(await boot('holder').read('/meta/book/help_center/tree', {}, { 'accept-language': 'zh-CN' }), transport) as any; + expect([zh?.label, zh?.groups?.map((g: any) => g.label)], transport).toEqual(['帮助中心', ['开始']]); + // The entry beneath keeps its own `doc.translations` localization. + expect(zh?.groups?.[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, en?.groups?.map((g: any) => g.label)], transport).toEqual(['Help Centre', ['Start']]); + expect(en?.groups?.[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/liveness/translation.json b/packages/spec/liveness/translation.json index e8e745cbf50..5229f50eeec 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -266,6 +266,32 @@ } } }, + "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." + } + } + }, "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 4e31a0be543..e7a3293bb7f 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1508,6 +1508,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, }; /** @@ -2222,6 +2226,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 d8bd010ad95..9048527ccec 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -733,10 +733,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 ' @@ -778,7 +778,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. " @@ -817,12 +817,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 @@ -1761,6 +1761,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 @@ -1975,10 +2010,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({ @@ -1989,7 +2024,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. @@ -1999,7 +2034,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 @@ -2034,7 +2069,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(), @@ -2202,7 +2237,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 @@ -2241,7 +2276,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..a66844d7e83 --- /dev/null +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -0,0 +1,137 @@ +/** + * 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'; + +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 = { + '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 as any, { 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 (rendered once wide, once as a narrow disclosure) — every copy. */ +async function sidebars(): Promise { + await screen.findByTestId('doc-content'); + return screen.getAllByRole('navigation').filter((nav) => within(nav).queryAllByRole('link').length > 0); +} + +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); + for (const nav of navs) { + expect(nav).toHaveAttribute('aria-label', '使用手册'); + 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(); + expect(navs.length).toBeGreaterThanOrEqual(1); + for (const nav of navs) { + expect(nav).toHaveAttribute('aria-label', 'User Manual'); + expect(within(nav).getByText('Getting started')).toBeInTheDocument(); + expect(within(nav).getByText('Pipeline')).toBeInTheDocument(); + } + }); +}); From a7280202348f45e21ae1ecb6af76ff1ab44e196f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:18:17 +0000 Subject: [PATCH 02/10] chore(spec): regenerate artifacts for the books translation group; docs, changeset, showcase zh-CN book strings Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j Co-Authored-By: Claude --- .changeset/26690-translation-books.md | 29 +++++++++++++++ .../docs/protocol/kernel/i18n-standard.mdx | 6 ++-- content/docs/references/api/protocol.mdx | 1 + content/docs/references/system/book.mdx | 10 +++--- .../docs/references/system/translation.mdx | 36 +++++++++++++++++++ content/docs/ui/doc-pages.mdx | 29 +++++++++++++-- content/docs/ui/translations.mdx | 3 +- .../system.md | 2 +- .../src/system/translations/index.ts | 14 ++++++++ packages/spec/api-surface/system.json | 3 ++ packages/spec/authorable-surface/system.json | 4 +++ packages/spec/export-origins/system.json | 3 ++ .../spec/liveness/state-counts/translation.md | 2 +- packages/spec/liveness/translation.json | 10 +++++- 14 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 .changeset/26690-translation-books.md diff --git a/.changeset/26690-translation-books.md b/.changeset/26690-translation-books.md new file mode 100644 index 00000000000..dd14162b291 --- /dev/null +++ b/.changeset/26690-translation-books.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/cli': 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`. + +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 a28db115d72..c1b113c32b5 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -184,6 +184,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 | @@ -293,6 +294,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 | @@ -342,6 +351,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 | @@ -450,6 +460,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 | @@ -561,6 +579,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 | @@ -669,6 +688,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 | @@ -739,6 +766,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") | @@ -857,6 +885,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 40c9233391a..7e65c6f38f5 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/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 e2dcb63e47c..eeae699a648 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -902,6 +902,7 @@ "system/Plan:priceMonthly", "system/Plan:priceYearly", "system/PlatformTranslationData:apps", + "system/PlatformTranslationData:books", "system/PlatformTranslationData:dashboards", "system/PlatformTranslationData:datasets", "system/PlatformTranslationData:flows", @@ -1030,6 +1031,7 @@ "system/SecurityEventCorrelation:linkEncryptionToAudit", "system/SecurityEventCorrelation:linkMaskingToAudit", "system/ServedTranslationData:apps", + "system/ServedTranslationData:books", "system/ServedTranslationData:dashboards", "system/ServedTranslationData:datasets", "system/ServedTranslationData:flows", @@ -1332,6 +1334,7 @@ "system/TranslationCoverageResult:totalKeys", "system/TranslationCoverageResult:translatedKeys", "system/TranslationData:apps", + "system/TranslationData:books", "system/TranslationData:dashboards", "system/TranslationData:datasets", "system/TranslationData:flows", @@ -1360,6 +1363,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 5229f50eeec..d4dd272ab1a 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -288,7 +288,15 @@ "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." + "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`." + } + } } } }, From 3d2039980d9749b30ed688aec0e046c61916058c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:29:57 +0000 Subject: [PATCH 03/10] test(runtime): the tree locale pin reads the authored group, not the synthetic orphan group Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j Co-Authored-By: Claude --- .../src/domains/meta-list-projection-parity.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 d190bbf2ebd..d9b721d3667 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -1248,13 +1248,15 @@ describe('[#20408] GET /meta/book/:name/tree: the dispatcher serves the route Re 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, zh?.groups?.map((g: any) => g.label)], transport).toEqual(['帮助中心', ['开始']]); + expect([zh?.label, authored(zh)?.map((g: any) => g.label)], transport).toEqual(['帮助中心', ['开始']]); // The entry beneath keeps its own `doc.translations` localization. - expect(zh?.groups?.[0]?.entries?.map((e: any) => e.label), transport).toEqual(['入门']); + 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, en?.groups?.map((g: any) => g.label)], transport).toEqual(['Help Centre', ['Start']]); - expect(en?.groups?.[0]?.entries?.map((e: any) => e.label), transport).toEqual(['Getting started']); + 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']); } }); }); From b898d54b4ae879c617b94c1f8395b7b08e626218 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:09:08 +0000 Subject: [PATCH 04/10] test(console): pay the spec/system load at collection in the book locale test Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j Co-Authored-By: Claude --- ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 index a66844d7e83..6fe7be307ef 100644 --- a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -23,6 +23,8 @@ 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'; const { ADAPTER, REQUEST } = vi.hoisted(() => { const REQUEST = { locale: 'en-US' }; From ce846e3be76b1351d2e2550b98b855bde322a2ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:14:37 +0000 Subject: [PATCH 05/10] feat(app-shell): the translation preview lists the books group Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j Co-Authored-By: Claude --- ui/packages/app-shell/src/views/metadata-admin/i18n.ts | 2 ++ .../previews/TranslationPreview.categories-11765.test.tsx | 5 +++++ .../src/views/metadata-admin/previews/TranslationPreview.tsx | 2 ++ 3 files changed, 9 insertions(+) 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 0028125b62e..4adaf2d115f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1869,6 +1869,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:', @@ -5145,6 +5146,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>; From 118b2e77f0c3746038bb1428497a15c6fd59670b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:23:05 +0000 Subject: [PATCH 06/10] test(console): find the book sidebar by its accessible name The wide layout wraps the book sidebar in an unlabelled nav, so filtering navigations by link count picked the wrapper too. Query the navigation by its name (the served book title) and pin that the authored title is absent for a zh-CN reader. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j --- .../pages/docs-portal.bookLocale-26690.test.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 index 6fe7be307ef..e17589a26c8 100644 --- a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -101,10 +101,13 @@ function Portal({ entry }: { entry: string }) { ); } -/** The book sidebar (rendered once wide, once as a narrow disclosure) — every copy. */ -async function sidebars(): Promise { +/** + * 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').filter((nav) => within(nav).queryAllByRole('link').length > 0); + return screen.getAllByRole('navigation', { name: title }); } afterEach(() => { @@ -116,10 +119,10 @@ 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(); + const navs = await sidebars('使用手册'); expect(navs.length).toBeGreaterThanOrEqual(1); + expect(screen.queryAllByRole('navigation', { name: 'User Manual' })).toHaveLength(0); for (const nav of navs) { - expect(nav).toHaveAttribute('aria-label', '使用手册'); expect(within(nav).getByText('快速上手')).toBeInTheDocument(); expect(within(nav).getByText('销售管道')).toBeInTheDocument(); expect(within(nav).queryByText('Getting started')).toBeNull(); @@ -128,10 +131,9 @@ describe('objectstack#26690 — the docs sidebar serves the request locale', () it('an en-US reader is unchanged: the authored title and section titles', async () => { render(); - const navs = await sidebars(); + const navs = await sidebars('User Manual'); expect(navs.length).toBeGreaterThanOrEqual(1); for (const nav of navs) { - expect(nav).toHaveAttribute('aria-label', 'User Manual'); expect(within(nav).getByText('Getting started')).toBeInTheDocument(); expect(within(nav).getByText('Pipeline')).toBeInTheDocument(); } From 1f17d1834b91871decdcee2631dd145b9c979773 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:30:42 +0000 Subject: [PATCH 07/10] test(console): type the book-locale bundle instead of casting it Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j --- .../console/src/pages/docs-portal.bookLocale-26690.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index e17589a26c8..a2c7fd1f2fe 100644 --- a/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx +++ b/ui/apps/console/src/pages/docs-portal.bookLocale-26690.test.tsx @@ -25,6 +25,7 @@ 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' }; @@ -42,7 +43,7 @@ const { ADAPTER, REQUEST } = vi.hoisted(() => { { key: 'pipeline', label: 'Pipeline', include: 'crm_pipe_*' }, ], }; - const BUNDLE = { + const BUNDLE: TranslationBundle = { 'zh-CN': { books: { crm_manual: { label: '使用手册', groups: { start: { label: '快速上手' }, pipeline: { label: '销售管道' } } } }, }, @@ -56,7 +57,7 @@ const { ADAPTER, REQUEST } = vi.hoisted(() => { // 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 as any, { locale: REQUEST.locale })]; + return [translateMetadataDocument('book', BOOK, BUNDLE, { locale: REQUEST.locale })]; }, getItem: async (_type: string, name: string) => ({ item: { name, content: `BODY ${name}` } }), }, From 4b8fb9a1b366c289bfa57251875f25336a1c3971 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:06:24 +0000 Subject: [PATCH 08/10] fix(lint,i18n): unknown doc-book translation keys are reported; the console loader knows `books` translation-target-unknown gains a books arm: books.NAME resolves against the declared books (a sibling package's and a package id's implicit book included) and books.NAME.groups.KEY against that book's group keys. The console's language loader lists `books` among the spec translation groups, and the Studio translation-preview test counts the fifteenth category. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j --- .changeset/26690-translation-books.md | 4 + packages/lint/src/rule-explanations.ts | 4 +- .../validate-translation-references.test.ts | 62 ++++++++++++++- .../src/validate-translation-references.ts | 77 ++++++++++++++++++- .../adminPreviews.i18n-10862-s2.test.tsx | 3 +- .../i18n/src/utils/spec-translations.ts | 3 + 6 files changed, 149 insertions(+), 4 deletions(-) diff --git a/.changeset/26690-translation-books.md b/.changeset/26690-translation-books.md index dd14162b291..e21b90094cf 100644 --- a/.changeset/26690-translation-books.md +++ b/.changeset/26690-translation-books.md @@ -3,6 +3,8 @@ '@objectstack/rest': minor '@objectstack/runtime': minor '@objectstack/cli': minor +'@objectstack/lint': minor +'@object-ui/i18n': minor --- feat(spec,rest,cli): a doc book's title and section titles are translatable — `books.` in the translation bundle @@ -16,6 +18,8 @@ A package that ships its docs in two languages showed a half-translated manual: - **`@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): 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 686ce4300f2..a7a0f63e6a4 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'; @@ -4512,3 +4512,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 7d96c4db3e8..43ef85e2492 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/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 { From adfc306037a058f41613e3b7c171a5b267950762 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:26:47 +0000 Subject: [PATCH 09/10] chore(changeset): name @object-ui/app-shell for the translation-preview books row Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j --- .changeset/26690-translation-books.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/26690-translation-books.md b/.changeset/26690-translation-books.md index e21b90094cf..dc63c26821d 100644 --- a/.changeset/26690-translation-books.md +++ b/.changeset/26690-translation-books.md @@ -4,6 +4,7 @@ '@objectstack/runtime': minor '@objectstack/cli': minor '@objectstack/lint': minor +'@object-ui/app-shell': minor '@object-ui/i18n': minor --- From 525ae464723b8994fec778d41e303a0049573e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:22:46 +0000 Subject: [PATCH 10/10] chore(spec): regenerate the authorable surface and translation reference after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzf4dTFmjsttVz7Pm8j --- content/docs/references/system/translation.mdx | 7 ++++++- packages/spec/authorable-surface/system.json | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index c1b113c32b5..4224f0eaa11 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -128,6 +128,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -228,6 +229,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -394,6 +396,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -622,6 +625,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -770,7 +774,7 @@ One locale of translations — the `translation` metadata type | **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") | -| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | +| **name** | `string` | optional | Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale` | | **label** | `string` | optional | Human-readable label shown in metadata lists | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -819,6 +823,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index eeae699a648..17dfed4a5bc 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -294,6 +294,7 @@ "system/DeleteObjectOperation:objectName", "system/DeleteObjectOperation:type", "system/DemoStaff:email", + "system/DemoStaff:manager", "system/DemoStaff:name", "system/DemoStaff:permissionSets", "system/DemoStaff:position",