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
33 changes: 33 additions & 0 deletions .changeset/i18n-inline-map-retired-spellings-by-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/spec": minor
---

fix(spec): reject the retired `key`/`defaultValue` spellings in inline locale maps BY NAME, in any combination — and stop claiming the retired form "resolves to nothing" (#10492)

Two legs, both on `InlineLocaleMapSchema` in `packages/spec/src/ui/i18n.zod.ts`:

1. **Message accuracy.** The `INLINE_LOCALE_KEY` rejection message said the
retired key-reference form (#5055) "resolves to nothing". Measured false:
both resolvers — `resolveI18nLabel` here and objectui's `pickLocalized`,
parity-pinned — fall through to their last resort (first string value, in
key insertion order) and return the raw dotted key, which renders as the
visible label. The message now states the measured behaviour.

2. **Enforcement hole closed.** `key` is three letters — syntactically a valid
BCP-47 primary subtag — so `{ key: 'common.save' }` alone parsed as a
"language `key` inline locale map" and painted `common.save` on screen; the
pair form was rejected only because `defaultValue` fails the tag grammar.
The key pattern now refuses the two retired spellings by name, in any
combination, matching the emitted type's `{ key?: never; defaultValue?:
never }` narrowing (#9925, maintainer ruling 2026-08-19, option B). This is
an enforcement gap of the #5055 retirement, not a new contract: nothing else
is denied — real 2–3 letter subtags (`deu`, `fra`, `yue`) still parse.

FROM → TO: a label authored as `{ key: '<i18n.key>' }` (or any inline map
carrying a `key`/`defaultValue` entry) is now refused at parse time with the
named message; write the inline locale map form `{ en: '…', 'zh-CN': '…' }`,
or a plain string resolved through a translation bundle. This is the same
prescription the #5055 retirement and the #9925 type narrowing already carry —
the runtime now enforces what the type already refused.

<!-- adr-0087: not-required (already-registered ui-widget-i18n-family-retired) the key-reference dialect's retirement record already carries this prescription; this change closes its runtime enforcement gap, no new migration -->
62 changes: 62 additions & 0 deletions packages/spec/src/ui/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,68 @@ describe('I18nLabelSchema', () => {
})).toThrow();
});

// ── #10492: the retired spellings are rejected BY NAME, in any combination ─
//
// Before this, the pair above was rejected only because `defaultValue` fails
// the tag grammar — `key` is three letters, syntactically a valid BCP-47
// primary subtag, so `{ key: 'common.save' }` ALONE parsed as a "language
// `key` locale map" and the resolvers' last resort (first string value) then
// painted the raw dotted key on screen. The emitted type had already made
// that spelling a compile error (#9925 `key?: never`); these pins hold the
// runtime to the same line. Each rejection pin asserts the named error
// content (issue code + message), not just parse failure.

it('rejects a lone `key` — the enforcement hole #10492 closes', () => {
const r = I18nLabelSchema.safeParse({ key: 'common.save' });
expect(r.success).toBe(false);
const issues = JSON.stringify(r.error?.issues);
expect(issues).toContain('invalid_key');
expect(issues).toContain('never by `key`/`defaultValue`');
expect(issues).toContain('#5055');
});

it('rejects a lone `defaultValue` with the same named error', () => {
const r = I18nLabelSchema.safeParse({ defaultValue: 'Save' });
expect(r.success).toBe(false);
const issues = JSON.stringify(r.error?.issues);
expect(issues).toContain('invalid_key');
expect(issues).toContain('never by `key`/`defaultValue`');
});

it('rejects the retired spellings even when mixed with valid locale keys', () => {
for (const value of [
{ key: 'common.save', en: 'Save' },
{ en: 'Save', defaultValue: 'Save' },
]) {
const r = I18nLabelSchema.safeParse(value);
expect(r.success, `expected ${JSON.stringify(value)} to be REJECTED`).toBe(false);
expect(JSON.stringify(r.error?.issues)).toContain('invalid_key');
}
});

it('the rejection message states the MEASURED behaviour, not "resolves to nothing"', () => {
// The message's old claim was measured false (#10492): both resolvers —
// `resolveI18nLabel` here and objectui's `pickLocalized`, parity-pinned —
// fall through to the first string value and return the raw key, which is
// worse than nothing: the machine key renders as the visible label.
const issues = JSON.stringify(I18nLabelSchema.safeParse({ key: 'common.save' }).error?.issues);
expect(issues).toContain('first string value');
expect(issues).toContain('raw key is rendered');
expect(issues).not.toContain('resolves to nothing');
});

it('does NOT deny-list real 2–3 letter subtags — only the two retired spellings', () => {
// The narrowing is exactly `key`/`defaultValue`, never a claim about which
// English-looking words are languages: real ISO-639 subtags still parse.
for (const tag of ['deu', 'fra', 'yue', 'EN']) {
expect(I18nLabelSchema.safeParse({ [tag]: 'v' }).success, `${tag} must stay accepted`).toBe(true);
}
// And the issue's rejected probes stay rejected (grammar, not deny-list).
for (const bad of ['notALocale', 'x-private', 'e']) {
expect(I18nLabelSchema.safeParse({ [bad]: 'v' }).success, `${bad} must stay rejected`).toBe(false);
}
});

it('should reject non-string, non-map values', () => {
expect(() => I18nLabelSchema.parse(123)).toThrow();
expect(() => I18nLabelSchema.parse(true)).toThrow();
Expand Down
27 changes: 17 additions & 10 deletions packages/spec/src/ui/i18n.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,20 @@ import { strictObject } from '../shared/strict-object';
* pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no
* real authoring surface.
*
* What it rejects is the retired SHAPE, not a list of banned words: the
* key-reference form always carried `defaultValue` (required on the old
* `I18nObjectSchema`), which cannot be a language tag. A hypothetical map whose
* only key is a bare three-letter `key` still parses, because nothing
* distinguishes it from a language subtag without an ISO-639 registry — and a
* hand-curated deny-list of English words that "look like" tags would be a
* claim about languages this schema has no business making.
* The two retired spellings are rejected BY NAME, in any combination
* (#10492). An earlier revision of this comment argued the opposite — that the
* key-reference form "always carried `defaultValue`" and a lone three-letter
* `key` was indistinguishable from a language subtag — and that reasoning left
* an enforcement hole in the #5055 retirement: `{ key: 'common.save' }` alone
* parsed as a "language `key` locale map" and then rendered the raw dotted key
* on screen (the resolvers' last resort is the first string value — see the
* message below), while the emitted type had already made the same spelling a
* compile error (#9925's `key?: never` limb). Runtime and type axis now refuse
* the same two names. This is not a deny-list of English words that "look
* like" tags — it is exactly the two spellings #5055 retired, nothing else:
* `deu`, `fra`, or any other real three-letter subtag still parses.
*/
const INLINE_LOCALE_KEY = /^(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;
const INLINE_LOCALE_KEY = /^(?!(?:key|defaultValue)$)(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;

/**
* The emitted type of an inline locale map — hand-tied, because the key regex
Expand Down Expand Up @@ -137,7 +142,8 @@ const INLINE_LOCALE_KEY = /^(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;
* ruling offered both and asked for a measured pick): a template-literal key
* cannot express "2–3 letters", so its letter-union approximation both ADMITS
* the lone `key` (three lowercase letters parse as a language subtag pattern —
* the same boundary the runtime doc below records) and explodes tsc (the
* the boundary the runtime refinement also had until #10492 closed it by name)
* and explodes tsc (the
* 26-letter probe did not finish; a 12-letter scale took 16s where this shape
* takes 2s), while a branded key breaks every existing object literal. The
* narrowing is deliberately exactly the measured harm class, not BCP-47
Expand Down Expand Up @@ -189,7 +195,8 @@ export const InlineLocaleMapSchema: z.ZodType<
z.string().regex(
INLINE_LOCALE_KEY,
'an inline label map is keyed by BCP-47 locale tags (`en`, `zh-CN`, …) or `default` — '
+ 'not by `key`/`defaultValue`, which was the retired key-reference form (#5055) and resolves to nothing',
+ 'never by `key`/`defaultValue`, the retired key-reference form (#5055): nothing looks the key up, '
+ 'so both resolvers fall through to the first string value and the raw key is rendered on screen',
),
z.string(),
).describe('Inline locale map: BCP-47 tag → translated string'));
Expand Down
Loading