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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/23855-settings-banner-translation-slot.md
Original file line number Diff line number Diff line change
@@ -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.<namespace>.banners.<banner_id>.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.
1 change: 1 addition & 0 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ Translation data for a single object
| **groups** | `Record<string, { title?: string; description?: string }>` | optional | Group translations keyed by group key |
| **keys** | `Record<string, { label?: string; help?: string; placeholder?: string; options?: Record<string, string> }>` | optional | Per-setting field translations keyed by setting key |
| **actions** | `Record<string, { label?: string; confirmText?: string; successMessage?: string }>` | optional | Action button translations keyed by action id |
| **banners** | `Record<string, { title?: string; text?: string }>` | optional | Info banner translations keyed by the info_banner specifier id |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 373 |
| `system/` | 374 |
2 changes: 2 additions & 0 deletions packages/spec/api-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,8 @@
"resolveSettingsActionConfirm (function)",
"resolveSettingsActionLabel (function)",
"resolveSettingsActionSuccess (function)",
"resolveSettingsBannerText (function)",
"resolveSettingsBannerTitle (function)",
"resolveSettingsDescription (function)",
"resolveSettingsFieldHelp (function)",
"resolveSettingsFieldLabel (function)",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/export-origins/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3676,6 +3676,40 @@ export function resolveSettingsActionSuccess(
);
}

/**
* Resolve an `info_banner` title under `settings.<namespace>.banners.<bannerId>.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.<namespace>.banners.<bannerId>.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.)
// ────────────────────────────────────────────────────────────────────────────
Expand Down
79 changes: 79 additions & 0 deletions packages/spec/src/system/settings-banner-translation.test.ts
Original file line number Diff line number Diff line change
@@ -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.<namespace>.banners.<banner_id>.{title,text}`, keyed by the
* specifier's `id`, beside `groups` / `keys` / `actions`.
*/
describe('settings.<namespace>.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');
});
});
6 changes: 4 additions & 2 deletions packages/spec/src/system/settings-manifest.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<namespace>.groups.<id>`, `.banners.<id>` and
* `.actions.<id>` 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)'),
Expand Down
22 changes: 21 additions & 1 deletion packages/spec/src/system/translation.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1626,11 +1626,20 @@ const platformSettingsShape = () => ({
* settings.<namespace>.actions.<action_id>.label
* settings.<namespace>.actions.<action_id>.confirmText
* settings.<namespace>.actions.<action_id>.successMessage
* settings.<namespace>.banners.<banner_id>.title
* settings.<namespace>.banners.<banner_id>.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'),
Expand Down Expand Up @@ -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'),
});

Expand Down
Loading