diff --git a/.changeset/23855-settings-banner-translation-slot.md b/.changeset/23855-settings-banner-translation-slot.md new file mode 100644 index 00000000000..7fec24d3909 --- /dev/null +++ b/.changeset/23855-settings-banner-translation-slot.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +Clause-②: yes (widening) + +The platform translation bundle (`PlatformTranslationDataSchema`) gains a slot for a settings +`info_banner` row: `settings..banners..title` and `.text`, keyed by the +specifier's `id`, beside `groups`, `keys` and `actions`. `title` translates the specifier's `label` +(the heading spelling `groups` already uses) and `text` translates its `bannerText` (markdown). +Before this, a translated banner was refused with `unrecognized_keys`, so a banner reached every +locale in its manifest English. The slot is strict: a borrowed `label`, `bannerText` or +`description` leaf, or a singular `banner` group, is refused with the spelling it meant. + +Two resolvers read it, `resolveSettingsBannerTitle` and `resolveSettingsBannerText`, from the +`resolveSettings*` family: they walk the requested locale and the declared fallback chain, then +return the manifest literal. The `info_banner` specifier's own accept set is unchanged. Give the +banner an `id` to make it addressable; a banner without one keeps rendering its literals. diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index c84e2ac62e2..d77f7654442 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -287,6 +287,7 @@ Translation data for a single object | **groups** | `Record` | optional | Group translations keyed by group key | | **keys** | `Record }>` | optional | Per-setting field translations keyed by setting key | | **actions** | `Record` | optional | Action button translations keyed by action id | +| **banners** | `Record` | optional | Info banner translations keyed by the info_banner specifier id | --- 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 5449560ed02..6aaedaaf246 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/` | 373 | +| `system/` | 374 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index fc0407b6735..2d42ed594a4 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -855,6 +855,8 @@ "resolveSettingsActionConfirm (function)", "resolveSettingsActionLabel (function)", "resolveSettingsActionSuccess (function)", + "resolveSettingsBannerText (function)", + "resolveSettingsBannerTitle (function)", "resolveSettingsDescription (function)", "resolveSettingsFieldHelp (function)", "resolveSettingsFieldLabel (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 63ea593a0ac..bb2cc677f2b 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -816,6 +816,8 @@ "resolveSettingsActionConfirm": "src/system/i18n-resolver.ts#resolveSettingsActionConfirm (function)", "resolveSettingsActionLabel": "src/system/i18n-resolver.ts#resolveSettingsActionLabel (function)", "resolveSettingsActionSuccess": "src/system/i18n-resolver.ts#resolveSettingsActionSuccess (function)", + "resolveSettingsBannerText": "src/system/i18n-resolver.ts#resolveSettingsBannerText (function)", + "resolveSettingsBannerTitle": "src/system/i18n-resolver.ts#resolveSettingsBannerTitle (function)", "resolveSettingsDescription": "src/system/i18n-resolver.ts#resolveSettingsDescription (function)", "resolveSettingsFieldHelp": "src/system/i18n-resolver.ts#resolveSettingsFieldHelp (function)", "resolveSettingsFieldLabel": "src/system/i18n-resolver.ts#resolveSettingsFieldLabel (function)", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a77972afcca..998bc298868 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3676,6 +3676,40 @@ export function resolveSettingsActionSuccess( ); } +/** + * Resolve an `info_banner` title under `settings..banners..title`, + * keyed by the specifier's `id`; falls back to the specifier's `label`. + */ +export function resolveSettingsBannerTitle( + bundle: PlatformTranslationBundle | undefined, + namespace: string, + bannerId: string, + fallback: string, + opts?: ResolveOptions, +): string { + return ( + resolveOptionalString(bundle, namespace, (e) => e.banners?.[bannerId]?.title, opts) + ?? fallback + ); +} + +/** + * Resolve an `info_banner` body under `settings..banners..text`, + * keyed by the specifier's `id`; falls back to the specifier's `bannerText`. + */ +export function resolveSettingsBannerText( + bundle: PlatformTranslationBundle | undefined, + namespace: string, + bannerId: string, + fallback: string | undefined, + opts?: ResolveOptions, +): string | undefined { + return ( + resolveOptionalString(bundle, namespace, (e) => e.banners?.[bannerId]?.text, opts) + ?? fallback + ); +} + // ──────────────────────────────────────────────────────────────────────────── // SettingsCommon — cross-namespace UI strings (source badges, etc.) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/settings-banner-translation.test.ts b/packages/spec/src/system/settings-banner-translation.test.ts new file mode 100644 index 00000000000..ed1ef95a470 --- /dev/null +++ b/packages/spec/src/system/settings-banner-translation.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { PlatformTranslationDataSchema, type PlatformTranslationBundle } from './translation.zod'; +import { resolveSettingsBannerText, resolveSettingsBannerTitle } from './i18n-resolver'; + +/** + * The `info_banner` slot of the platform settings translation group: + * `settings..banners..{title,text}`, keyed by the + * specifier's `id`, beside `groups` / `keys` / `actions`. + */ +describe('settings..banners — the info_banner translation slot', () => { + const banners = { + beta_notice: { title: '注意', text: 'Beta 功能可能随时变更。' }, + }; + + it('accepts a banner title and text on the platform face', () => { + const result = PlatformTranslationDataSchema.safeParse({ settings: { feature_flags: { banners } } }); + expect(result.success).toBe(true); + }); + + it.each([ + ['the specifier spelling `bannerText`', { bannerText: 'x' }, 'text'], + ['the specifier spelling `label`', { label: 'x' }, 'title'], + ['the group body spelling `description`', { description: 'x' }, 'text'], + ])('refuses %s on a banner and names the leaf that holds it', (_what, leaf, expected) => { + const result = PlatformTranslationDataSchema.safeParse({ + settings: { feature_flags: { banners: { beta_notice: leaf } } }, + }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['settings', 'feature_flags', 'banners', 'beta_notice']); + expect(issue?.message).toContain(`→ \`${expected}\``); + }); + + it('refuses the singular `banner` group and names `banners`', () => { + const result = PlatformTranslationDataSchema.safeParse({ + settings: { feature_flags: { banner: banners } }, + }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual(['settings', 'feature_flags']); + expect(issue?.message).toContain('→ `banners`'); + }); +}); + +describe('resolveSettingsBannerTitle / resolveSettingsBannerText', () => { + const bundle: PlatformTranslationBundle = { + 'zh-CN': { + settings: { + feature_flags: { banners: { beta_notice: { title: '注意', text: 'Beta 功能可能随时变更。' } } }, + }, + }, + }; + + it('resolves the requested locale', () => { + const opts = { locale: 'zh-CN' }; + expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('注意'); + expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts)) + .toBe('Beta 功能可能随时变更。'); + }); + + it('walks the declared fallback chain when the requested locale has no entry', () => { + const opts = { locale: 'ja-JP', fallbackChain: ['zh-CN'] }; + expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('注意'); + expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts)) + .toBe('Beta 功能可能随时变更。'); + }); + + it('falls back to the manifest literal when no locale in the chain has the banner', () => { + const opts = { locale: 'ja-JP' }; + expect(resolveSettingsBannerTitle(bundle, 'feature_flags', 'beta_notice', 'Heads up', opts)).toBe('Heads up'); + expect(resolveSettingsBannerText(bundle, 'feature_flags', 'beta_notice', 'Beta features…', opts)) + .toBe('Beta features…'); + expect(resolveSettingsBannerText(bundle, 'feature_flags', 'other_notice', undefined, { locale: 'zh-CN' })) + .toBeUndefined(); + expect(resolveSettingsBannerTitle(undefined, 'feature_flags', 'beta_notice', 'Heads up')).toBe('Heads up'); + }); +}); diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index 19b75002b4b..4190bb2059b 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -393,8 +393,10 @@ export const SpecifierSchema = lazySchema(() => z.object({ /** * Stable identifier (snake_case) used for i18n translation lookup, * action routing, and test selectors. Orthogonal to `key` (which is - * the storage path). Recommended for `group` and `action_button` - * specifiers so their labels can be translated and so action + * the storage path). Recommended for `group`, `info_banner` and + * `action_button` specifiers so their text can be translated + * (`settings..groups.`, `.banners.` and + * `.actions.` in the platform translation bundle) and so action * handlers have stable hook keys. Must be unique within a manifest. */ id: SnakeCaseIdentifierSchema.optional().describe('Stable identifier (snake_case)'), diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index a11143b16c5..9cf3e7ef5f5 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1626,11 +1626,20 @@ const platformSettingsShape = () => ({ * settings..actions..label * settings..actions..confirmText * settings..actions..successMessage + * settings..banners..title + * settings..banners..text + * + * `banners` is keyed by an `info_banner` specifier's `id`, as `groups` is + * keyed by a `group` specifier's. Its leaves follow the nearest sibling: + * the specifier's `label` translates as `title` (the heading spelling + * `groups` already uses for the same field), and its `bannerText` as + * `text`. A banner without an `id` has no address here and renders its + * manifest literals. */ settings: z.record(z.string(), strictObject({ surface: 'this settings manifest translation', history: TRANSLATION_HISTORY, - aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys' }, + aliases: { label: 'title', name: 'title', sections: 'groups', fields: 'keys', settings: 'keys', banner: 'banners', notices: 'banners', alerts: 'banners' }, }, { title: z.string().optional().describe('Translated settings manifest title'), description: z.string().optional().describe('Translated settings manifest description'), @@ -1669,6 +1678,17 @@ const platformSettingsShape = () => ({ confirmText: z.string().optional().describe('Translated confirmation prompt'), successMessage: z.string().optional().describe('Translated success toast/message'), })).optional().describe('Action button translations keyed by action id'), + banners: z.record(z.string(), strictObject({ + surface: 'this settings banner translation', + history: TRANSLATION_HISTORY, + // The specifier's own spellings (`label`, `bannerText`) and the group + // body's (`description`) are the likely borrowings; each names the leaf + // that holds the same string here. + aliases: { label: 'title', name: 'title', heading: 'title', bannerText: 'text', body: 'text', description: 'text', message: 'text' }, + }, { + title: z.string().optional().describe('Translated banner title (the info_banner specifier label)'), + text: z.string().optional().describe('Translated banner body, markdown (the info_banner specifier bannerText)'), + })).optional().describe('Info banner translations keyed by the info_banner specifier id'), })).optional().describe('Settings manifest translations keyed by namespace'), });