diff --git a/.changeset/25822-locale-tag-shape.md b/.changeset/25822-locale-tag-shape.md new file mode 100644 index 00000000000..47f45b57e8d --- /dev/null +++ b/.changeset/25822-locale-tag-shape.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a locale must be a BCP-47 tag — `zh_CN`, a padded value or a language name is refused + +`LocaleSchema` was a bare string, so a translator or AI author who wrote `zh_CN`, `'zh-CN '` or `not a locale!!` got a green save (`PUT /api/v1/meta/translation/:name` answered 200 with `_diagnostics.valid: true`). The strings were stored under a locale no session ever asks for. The resolvers fold case and nothing else, so those strings never rendered, with no diagnostic. + +`LocaleSchema` now accepts a tag-shaped value only: a 2–8 letter language subtag, then hyphen-separated 1–8 letter-or-digit subtags (`en`, `zh-CN`, `zh-Hans-CN`, `es-419`). It is the same shape a user's own `locale` column accepts. Case is not judged (`zh-cn` still parses, and resolves). The check applies everywhere the schema is used: + +- a `translation` item's `locale` (the metadata save door and `defineTranslation`); +- a translation bundle's locale keys (`defineTranslationBundle`, `stack.translations`, the platform bundle); +- `i18n.defaultLocale`, `i18n.fallbackLocale` and `i18n.supportedLocales`; +- a seed dataset's `locale` scope and the seed loader's `locale`. + +Stored rows and older built artifacts: the ADR-0087 D2 conversion `translation-locale-tag-canonicalized` rewrites the two spellings that have one tag they can have meant: `_` as the separator (`zh_CN` → `zh-CN`) and surrounding whitespace. A stored `translation` row is rewritten when it is rehydrated, so its strings start rendering for the locale they were written for. Any other value is left as stored and reported as a TODO by `os migrate meta --stored`. So is a bundle key that would land on a tag the entry already holds with different strings. The conversion is retired from the load path, so a source that still writes a non-tag locale is refused, not rewritten. The D3 entry `translation-locale-non-tag-refused` carries the judgment for `os migrate meta`. + +Clause-②: no (narrowing) — `LocaleSchema` refuses values it used to accept. Nothing is widened. + +Metadata-protocol: narrowed (a locale that is not a BCP-47 tag is refused on translation items, bundle keys, the i18n configuration and seed datasets) + + + +**BREAKING** for an author who wrote a non-tag locale. + +- **Before.** `defineTranslation({ name: 'zh_cn', locale: 'zh_CN', … })` parsed, and its strings never rendered. +- **Now.** The value is refused at `locale` (or at the bundle key) with a message naming the tag shape. +- **Fix.** Write the tag: `zh_CN` → `zh-CN`, drop surrounding whitespace, and replace a language name with its tag. `os migrate meta --from 17 --write` applies the first two to your sources. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index d7d17e35f80..ed03d92ead8 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -240,6 +240,13 @@ are retired and read by nothing: the `o.{object}` shape below, and the flat `.` shape recorded under [ObjectQL Integration](#objectql-integration). +Every locale code (a bundle key, an item's `locale`, `i18n.defaultLocale`, +`supportedLocales` and `fallbackLocale`) is a BCP-47 language tag with `-` +between subtags (`en`, `zh-CN`, `zh-Hans-CN`). `LocaleSchema` refuses anything +else at parse — `zh_CN`, a padded value, a language name — and the 18.x +conversion `translation-locale-tag-canonicalized` rewrites the `_` and padded +spellings in stored rows and older builds. Case is not judged: the resolvers fold it. + ```typescript // One locale of a TranslationBundle (e.g. zh-CN) const zh: TranslationData = { diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 370a57bc191..40c9233391a 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -18,7 +18,11 @@ labels either way. ## Your first bundle -A bundle is a **map of locale → translations**, registered on your stack: +A bundle is a **map of locale → translations**, registered on your stack. Every +locale (a bundle key, an item's `locale`, the `i18n` locales) must be a BCP-47 +language tag with `-` between subtags (`en`, `zh-CN`, `zh-Hans-CN`); `zh_CN`, a +padded value or a language name is refused at parse, and the 18.x conversion +rewrites the `_` and padded spellings in stored rows and older builds: ```typescript title="src/translations/crm.translation.ts" import { defineTranslationBundle } from '@objectstack/spec'; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 67a991df227..704d0b246dc 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -49,6 +49,7 @@ import { misplacedDefaultPageRefusal } from '../ui/default-record-page.js'; import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; import { CONNECTOR_CONFIG_LOOSE_KEYS, WAIT_EVENT_CONFIG_LOOSE_KEYS } from '../automation/flow-sibling-block-loose-keys.js'; import { FLOW_RESERVED_NODE_TYPES } from '../automation/flow-reserved-node-types.js'; +import { LocaleSchema } from '../system/translation.zod.js'; /** * Flow callout node type rename (protocol 11.0; retired from the load path at @@ -8204,6 +8205,188 @@ const elementFormRemoved: MetadataConversion = { }, }; +/** + * A locale written with `_` separators or surrounding whitespace becomes the + * BCP-47 tag it meant (protocol 18, #25822): `zh_CN` → `zh-CN`, `'en '` → `en`. + * + * `LocaleSchema` was a bare string, so a translation item, a bundle's locale + * key, the stack's `i18n` locales and a seed dataset's `locale` scope all + * saved any text. The resolvers fold case and nothing else, so a value + * spelled with `_` or padded with whitespace named a locale no session ever + * asks for: its strings were stored and never rendered. The schema now + * refuses anything that is not tag-shaped. + * + * **What it rewrites.** Only the two spellings that have exactly one tag they + * can have meant: `_` used as the subtag separator, and whitespace around the + * value. Case is kept as written, because every resolver already folds it. + * The result must pass `LocaleSchema`; a value that still does not (a + * language name, `not a locale!!`) is left as stored and reported as a TODO, + * because only its author knows which locale it was for. + * + * **A rewrite changes what renders, on purpose.** A `zh_CN` item's strings + * start resolving for `zh-CN` sessions, the strings their author wrote them + * for. On a bundle a rename that would land on a locale key the entry already + * holds is merged only when the two values are identical (`renameKey`); + * otherwise both are left as stored and reported, because picking a winner + * would silently discard one translator's strings. + * + * **Reach.** `translations[]`: a bare item's `locale` (the shape stored + * `translation` rows replay through), or a bundle entry's locale keys. A + * bundle key is judged only when it carries something other than letters, so + * a legacy item written before `locale` was required, whose top-level keys + * are group names (`globalActions`, `dashboards`), is never misread as a + * bundle with bad locale keys. Also `i18n.defaultLocale`, + * `i18n.fallbackLocale`, `i18n.supportedLocales[]` and `data[].locale[]`. + * + * Retired from the load path from day one: a live author meets the parse's + * refusal and its prescription instead of being rewritten. Stored rows + * (`applyConversionsToStoredItem`) and a built artifact inside its + * declared-floor window get the rewrite. Idempotent: a tag-shaped value is + * never touched again. + */ +const translationLocaleTagCanonicalized: MetadataConversion = { + id: 'translation-locale-tag-canonicalized', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.1.0', + surface: + 'translation.locale / stack.translations[]. / i18n.defaultLocale / i18n.fallbackLocale / ' + + 'i18n.supportedLocales[] / data[].locale[]', + summary: + "a locale written with '_' separators or surrounding whitespace → the BCP-47 tag it meant " + + "('zh_CN' → 'zh-CN'): no resolver matched it, so its strings never rendered; a value that is " + + 'still not tag-shaped is left as stored and reported', + apply(stack, emit, context) { + const isTag = (v: string): boolean => LocaleSchema.safeParse(v).success; + /** The one tag `raw` can have meant, or `undefined` when it is a tag already or names none. */ + const canonical = (raw: string): string | undefined => { + if (isTag(raw)) return undefined; + const tag = raw.trim().replace(/_/g, '-'); + return isTag(tag) ? tag : undefined; + }; + const todo = (path: string, from: string, reason: string): void => + context?.reportTodo?.({ path, from: JSON.stringify(from), reason }); + const NOT_A_TAG = + 'is not a BCP-47 language tag and has no single tag it can have meant. Left as stored, it is ' + + 'refused where it is parsed. Rewrite it by hand as the tag its strings were written for ' + + '(`en`, `zh-CN`, `zh-Hans-CN`).'; + /** A locale value: rewritten, reported, or (already a tag) returned as is. */ + const value = (raw: string, path: string, subject: string): string => { + if (isTag(raw)) return raw; + const tag = canonical(raw); + if (tag === undefined) { + todo(path, raw, `${subject} ${NOT_A_TAG}`); + return raw; + } + emit({ from: JSON.stringify(raw), to: JSON.stringify(tag), path }); + return tag; + }; + const list = (raw: unknown, path: string, subject: string): unknown => { + if (!Array.isArray(raw)) return raw; + let changed = false; + const next = raw.map((v, i) => { + if (typeof v !== 'string') return v; + const mapped = value(v, `${path}[${i}]`, subject); + if (mapped !== v) changed = true; + return mapped; + }); + return changed ? next : raw; + }; + + let next = mapCollection(stack, 'translations', (entry, path) => { + // A bare `translation` ITEM: its own `locale`. + if ('locale' in entry) { + if (typeof entry.locale !== 'string') return entry; + const locale = value(entry.locale, `${path}.locale`, 'The translation item locale'); + return locale === entry.locale ? entry : { ...entry, locale }; + } + // A bundle entry: locale code → data. + let bundle = entry; + for (const [key, data] of Object.entries(entry)) { + if (!isDict(data) || /^[A-Za-z]+$/.test(key) || isTag(key)) continue; + const tag = canonical(key); + if (tag === undefined) { + todo(`${path}.${key}`, key, `The translation bundle locale key ${NOT_A_TAG}`); + continue; + } + const renamed = renameKey(bundle, key, tag); + if (renamed === null) { + todo( + `${path}.${key}`, + key, + `The translation bundle locale key means \`${tag}\`, and the entry already holds different ` + + `strings under \`${tag}\`. Left as stored, it is refused where it is parsed. Merge the two ` + + 'by hand and keep one key.', + ); + continue; + } + emit({ from: JSON.stringify(key), to: JSON.stringify(tag), path: `${path}.${key}` }); + bundle = renamed; + } + return bundle; + }); + + const i18n = next.i18n; + if (isDict(i18n)) { + let config = i18n; + for (const key of ['defaultLocale', 'fallbackLocale'] as const) { + const raw = config[key]; + if (typeof raw !== 'string') continue; + const mapped = value(raw, `i18n.${key}`, `i18n.${key}`); + if (mapped !== raw) config = { ...config, [key]: mapped }; + } + const supported = list(config.supportedLocales, 'i18n.supportedLocales', 'A supported locale'); + if (supported !== config.supportedLocales) config = { ...config, supportedLocales: supported }; + if (config !== i18n) next = { ...next, i18n: config }; + } + + return mapCollection(next, 'data', (dataset, path) => { + const locale = list(dataset.locale, `${path}.locale`, 'A seed dataset locale'); + return locale === dataset.locale ? dataset : { ...dataset, locale }; + }); + }, + fixture: { + before: { + translations: [ + // A stored item under an underscore spelling. + { name: 'zh_cn', locale: 'zh_CN', messages: { commonSave: '保存' } }, + // Already a tag, in lower case: resolvers fold case, so it is left alone. + { name: 'pt_br', locale: 'pt-br', messages: { commonSave: 'Salvar' } }, + // Not a tag and no single tag it can have meant: a TODO, left as stored. + { name: 'qa_bad_locale', locale: 'not a locale!!', messages: { commonSave: 'x' } }, + // A bundle entry: one padded key, one underscore key, one tag. + { + 'en ': { messages: { commonSave: 'Save' } }, + ja_JP: { messages: { commonSave: '保存' } }, + 'zh-CN': { messages: { commonSave: '保存' } }, + }, + // A legacy item without `locale`: its group keys are not locale keys. + { name: 'fr', globalActions: { export_all: { label: 'Tout exporter' } } }, + ], + i18n: { defaultLocale: 'en_US', supportedLocales: ['en_US', 'zh-CN', ' ja-JP'], fallbackLocale: 'en' }, + data: [{ object: 'crm_lead', locale: ['zh_CN'], records: [] }], + }, + after: { + translations: [ + { name: 'zh_cn', locale: 'zh-CN', messages: { commonSave: '保存' } }, + { name: 'pt_br', locale: 'pt-br', messages: { commonSave: 'Salvar' } }, + { name: 'qa_bad_locale', locale: 'not a locale!!', messages: { commonSave: 'x' } }, + { + 'zh-CN': { messages: { commonSave: '保存' } }, + en: { messages: { commonSave: 'Save' } }, + 'ja-JP': { messages: { commonSave: '保存' } }, + }, + { name: 'fr', globalActions: { export_all: { label: 'Tout exporter' } } }, + ], + i18n: { defaultLocale: 'en-US', supportedLocales: ['en-US', 'zh-CN', 'ja-JP'], fallbackLocale: 'en' }, + data: [{ object: 'crm_lead', locale: ['zh-CN'], records: [] }], + }, + // One per rewritten value: the item, two bundle keys, the default locale, + // two supported locales and the seed scope. + expectedNotices: 7, + }, +}; + /** * `settings` on every APPLICATION-authored translation face — the platform-only * group leaving `stack.translations` with the type split (protocol 18, #15178, @@ -8560,8 +8743,9 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = { // Bare data/item shape: the groups sit at the entry's top level. let next = stripFromData(entry, path); // Bundle shape: locale code → data. Judged structurally (a dict whose - // `pages` is a dict) rather than by key spelling — `LocaleSchema` is an - // open string, so the locale keys cannot be enumerated. A strip-only + // `pages` is a dict) rather than by key spelling — `LocaleSchema` judges + // only a tag's shape, an open-ended set, so the locale keys cannot be + // enumerated. A strip-only // walk makes a false positive a no-op: it removes nothing unless the // exact `pages..components..submitLabel` path is present. for (const [locale, data] of Object.entries(next)) { @@ -14220,7 +14404,8 @@ const translationWidgetSubCaptionRemoved: MetadataConversion = { // Bare data/item shape: the groups sit at the entry's top level. let next = stripFromData(entry, path); // Bundle shape: locale code → data, judged structurally (a dict whose - // `dashboards` is a dict) because `LocaleSchema` is an open string. A + // `dashboards` is a dict) because `LocaleSchema` judges only a tag's + // shape, an open-ended set the locale keys cannot be enumerated from. A // strip-only walk makes a false positive a no-op: it removes nothing // unless the exact `dashboards..widgets..subCaption` path is // present. @@ -18215,6 +18400,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: reportJoinedChartRemoved, order: 38 }, { conversion: timeDefaultUtcSuffixDropped, order: 48 }, { conversion: translationComponentSubmitLabelRemoved, order: 12 }, + { conversion: translationLocaleTagCanonicalized, order: 76 }, { conversion: translationPerAppSettingsRemoved, order: 34 }, { conversion: translationWidgetSubCaptionRemoved, order: 55 }, { conversion: tursoConfigEncryptionKeyRemoved, order: 74 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts b/packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts new file mode 100644 index 00000000000..f3b01aa71ef --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The D3 entry of the `translation-locale-tag-canonicalized` family: the D2 +// conversion rewrites the two spellings that have one tag they can have meant +// (an `_` separator, surrounding whitespace) in stored rows and built +// artifacts; this entry is the judgment an author still owes on the rest. +export const entry: SemanticMigration = { + id: 'translation-locale-non-tag-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'a locale that is not a BCP-47 tag (LocaleSchema): a translation item locale, a translation ' + + 'bundle locale key, i18n.defaultLocale / fallbackLocale / supportedLocales, a seed dataset ' + + 'locale scope, the seed loader locale', + replacement: + 'the BCP-47 tag the value was written for: a 2-8 letter language subtag, then hyphen-separated ' + + '1-8 letter-or-digit subtags (`en`, `zh-CN`, `zh-Hans-CN`, `es-419`). Write `zh-CN` for ' + + '`zh_CN` and drop surrounding whitespace; the conversion does both for stored rows and built ' + + 'artifacts. A language name or any other text has no single tag it can have meant, so rewrite ' + + 'it by hand. Case is not judged: `zh-cn` is accepted, because the resolvers fold case', + reason: + 'LocaleSchema was a bare string, so a translator or AI author who wrote `zh_CN`, a padded ' + + 'value or a language name got a green save, and the strings were stored under a locale no ' + + 'session ever asks for. The resolvers fold case and nothing else, so those strings never ' + + 'rendered, with no diagnostic: indistinguishable from a translation nobody had written yet. ' + + 'The schema now refuses any value that is not tag-shaped, at the metadata save door, at ' + + 'defineTranslation / defineTranslationBundle and on the bundle map key. The shape is the one ' + + 'a user locale column already accepts. It is a syntax check only: membership has no registry ' + + 'to check against. Ships at once, no grace window and no dual spelling.', + acceptanceCriteria: + 'Every translation item locale, translation bundle key, i18n locale and seed dataset locale ' + + 'parses through LocaleSchema. A stored `translation` row under `zh_CN` or a padded locale ' + + 'loads with the tag rewritten and its strings start rendering for that locale. A stored row ' + + 'under any other non-tag value keeps loading as stored, is listed by `os migrate meta --stored` ' + + 'as a TODO, and fails the schema wherever it is parsed until it is rewritten. A bundle whose ' + + 'underscore key would land on a tag it already holds with different strings is reported and ' + + 'left for the author to merge.', + conversionIds: ['translation-locale-tag-canonicalized'], +}; diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 6ad96ffefdd..34c1e633854 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -94,6 +94,7 @@ import { PageComponentSchema, PageSchema } from '../ui/page.zod'; import { AddRecordConfigSchema, FormFieldSchema, FormSectionSchema, ListViewSchema, ObjectListViewSchema } from '../ui/view.zod'; import { FlowSchema } from '../automation/flow.zod'; import { JobSchema } from '../system/job.zod'; +import { TranslationItemSchema } from '../system/translation.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { @@ -260,6 +261,13 @@ const JOIN: Readonly> = { 'objects', ), ], + 'translation-locale-tag-canonicalized': [ + refusal( + "a translation item's locale written with '_'", + () => TranslationItemSchema.safeParse({ name: 'zh_cn', locale: 'zh_CN', messages: { commonSave: '保存' } }), + 'locale', + ), + ], 'manifest-permissions-string-list-removed': [ refusal('manifest.permissions as a list of strings', () => ManifestPermissionsSchema.safeParse(['system.user.read'])), ], diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index b2c55529b47..7de3fcebe38 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -4,6 +4,7 @@ import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY import { TEXT_SLOT_TEMPLATE_REFUSAL } from '../automation/flow-text-slot-template'; import { FlowSchema } from '../automation/flow.zod'; import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; +import { ALL_CONVERSIONS } from '../conversions/registry'; import { TranslationDataSchema, TranslationBundleSchema, @@ -45,6 +46,68 @@ describe('LocaleSchema', () => { expect(() => LocaleSchema.parse(locale)).not.toThrow(); }); }); + + it.each(['zh-Hans-CN', 'es-419', 'yue-HK', 'zh-cn'])('accepts the tag %j (case is not judged)', (locale) => { + expect(LocaleSchema.safeParse(locale).success).toBe(true); + }); + + // #25822: each of these saved green and was stored under a locale no session + // ever asks for, so its strings never rendered. + it.each(['zh_CN', 'zh-CN ', ' en', 'zh CN', 'Chinese (Simplified)', 'not a locale!!', '', '-CN', '1zh', 'zh-Hansumlaut'])( + 'refuses %j as a regex-format issue that names the release', + (locale) => { + const result = LocaleSchema.safeParse(locale); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.code).toBe('invalid_format'); + expect(issue.message).toMatch(/^must be a BCP-47 language tag/); + expect(issue.message).toContain('@objectstack/spec 18'); + }, + ); + + it('refuses a non-tag locale at both translation doors and on the bundle key', () => { + // The item door: `PUT /api/v1/meta/translation/:name` and `defineTranslation`. + const item = TranslationItemSchema.safeParse({ name: 'qa_bad_locale', locale: 'not a locale!!', messages: { x: 'y' } }); + expect(item.success).toBe(false); + expect(item.error!.issues.map((i) => i.path.join('.'))).toEqual(['locale']); + expect(() => defineTranslation({ name: 'zh_cn', locale: 'zh_CN', messages: { x: 'y' } })).toThrow(/BCP-47 language tag/); + + // The bundle door: the map key itself (`defineTranslationBundle`, `stack.translations`). + const bundle = TranslationBundleSchema.safeParse({ zh_CN: { messages: { x: 'y' } } }); + expect(bundle.success).toBe(false); + expect(bundle.error!.issues[0]!.code).toBe('invalid_key'); + expect(bundle.error!.issues[0]!.path).toEqual(['zh_CN']); + expect(PlatformTranslationBundleSchema.safeParse({ 'en ': { messages: { x: 'y' } } }).success).toBe(false); + expect(() => defineTranslationBundle({ zh_CN: { messages: { x: 'y' } } })).toThrow(); + + // The stack's i18n configuration. + const config = TranslationConfigSchema.safeParse({ defaultLocale: 'en', supportedLocales: ['en', 'zh_CN'] }); + expect(config.success).toBe(false); + expect(config.error!.issues.map((i) => i.path.join('.'))).toEqual(['supportedLocales.1']); + }); + + it('data at rest: the conversion reports what it cannot rewrite instead of guessing', () => { + const conversion = ALL_CONVERSIONS.find((c) => c.id === 'translation-locale-tag-canonicalized')!; + const todos: { path: string; from: string }[] = []; + const notices: { path: string }[] = []; + const stack = { + translations: [ + { name: 'qa_bad_locale', locale: 'not a locale!!', messages: { x: 'y' } }, + // `zh_CN` means `zh-CN`, which the entry already holds with other strings. + { zh_CN: { messages: { x: 'a' } }, 'zh-CN': { messages: { x: 'b' } } }, + // An identical twin is merged: nothing is lost. + { ja_JP: { messages: { x: 'c' } }, 'ja-JP': { messages: { x: 'c' } } }, + ], + }; + const out = conversion.apply(stack, (n) => notices.push(n), { reportTodo: (t) => todos.push(t) }); + expect(todos.map((t) => t.path)).toEqual(['translations[0].locale', 'translations[1].zh_CN']); + expect(notices.map((n) => n.path)).toEqual(['translations[2].ja_JP']); + expect(out.translations).toEqual([ + stack.translations[0], + stack.translations[1], + { 'ja-JP': { messages: { x: 'c' } } }, + ]); + }); }); describe('TranslationDataSchema', () => { diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 53eacbe2ea0..c14a149ddfd 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -12,7 +12,34 @@ import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { textSlotTemplateRefusal } from '../automation/flow-text-slot-template'; -export const LocaleSchema = lazySchema(() => z.string().describe('BCP-47 Language Tag (e.g. en-US, zh-CN)')); + +/** + * The shape a locale tag must have: a 2–8 letter language subtag, then any + * number of hyphen-separated 1–8 letter-or-digit subtags (`en`, `zh-CN`, + * `zh-Hans-CN`, `es-419`). The same pattern as `SYS_USER_LOCALE_TAG_PATTERN` + * (`@objectstack/platform-objects/identity`), the shape a user's own `locale` + * column accepts, so a translation is stored only under tags a session can + * ask for. + * + * Shape only, by decision: membership has no registry to check against + * (`Intl.getCanonicalLocales('xx-YY')` succeeds, which is why + * `shared/value-domain.zod.ts` dropped a `bcp47_locale` domain), and case is + * not judged because the resolvers fold it (`zh-cn` resolves to `zh-CN`). + * What it refuses is what no resolver ever matches: an underscore separator + * (`zh_CN`), whitespace, a language name. + */ +const LOCALE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +const LOCALE_TAG_REFUSAL = + 'must be a BCP-47 language tag such as `en`, `zh-CN` or `zh-Hans-CN`: a 2–8 letter language ' + + 'subtag, then hyphen-separated subtags. Refused since @objectstack/spec 18, because a translation ' + + 'stored under any other spelling (`zh_CN`, a trailing space, a language name) is under a locale no ' + + 'session ever asks for, so its strings never render. Separate subtags with `-`, not `_`.'; + +export const LocaleSchema = lazySchema(() => z + .string() + .regex(LOCALE_TAG_SHAPE, LOCALE_TAG_REFUSAL) + .describe('BCP-47 Language Tag (e.g. en-US, zh-CN)')); export type Locale = z.input; /**