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
28 changes: 28 additions & 0 deletions .changeset/25822-locale-tag-shape.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered translation-locale-non-tag-refused, translation-locale-tag-canonicalized -->

**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.
7 changes: 7 additions & 0 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ are retired and read by nothing: the `o.{object}` shape below, and the flat
`<namespace>.<prop>` 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 = {
Expand Down
6 changes: 5 additions & 1 deletion content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
192 changes: 189 additions & 3 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[].<locale> / 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,
Expand Down Expand Up @@ -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.<name>.components.<id>.submitLabel` path is present.
for (const [locale, data] of Object.entries(next)) {
Expand Down Expand Up @@ -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.<name>.widgets.<id>.subCaption` path is
// present.
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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'],
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -260,6 +261,13 @@ const JOIN: Readonly<Record<string, readonly Renderer[]>> = {
'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'])),
],
Expand Down
Loading
Loading