# Capability Spec Delta: i18n (i18n-completion) **Package**: `@suaveplan/i18n` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Ergonomic translate options bag `MessageCatalog.t(key, options?: TranslateOptions)` SHALL accept an optional second argument that bundles the most common translation concerns into a single call. `options` SHALL support `count: number`, `context: string`, `defaultValue: string`, `values: Record`, `escapeHtml?: boolean`, and `formatters?: Record`. When `count` is present, the catalog SHALL invoke `PluralizationEngine.resolve` and select the matching plural branch; when `context` is present, the catalog SHALL look up `${key}_${context}` first, falling back to `key`. The `count` value SHALL be auto-injected into `options.values` so plural strings can use `{count}` directly. #### Scenario: count selects plural branch - **GIVEN** catalog `{ en: { item: { one: '{count} item', other: '{count} items' } } }` - **WHEN** `t('item', { count: 3 })` is called - **THEN** the result equals `'3 items'` #### Scenario: context selector falls back to base key - **GIVEN** catalog `{ en: { greeting: 'Hi', greeting_male: 'Hello sir' } }` - **WHEN** `t('greeting', { context: 'female' })` is called - **THEN** the result equals `'Hi'` (greeting_female absent → falls back to base) #### Scenario: defaultValue used on miss - **GIVEN** catalog without key `'foo'` - **WHEN** `t('foo', { defaultValue: 'Bar' })` is called - **THEN** the result equals `'Bar'` #### Scenario: values interpolated alongside count - **GIVEN** catalog `{ en: { item: { one: '{name} has {count} item', other: '{name} has {count} items' } } }` - **WHEN** `t('item', { count: 2, values: { name: 'Alice' } })` is called - **THEN** the result equals `'Alice has 2 items'` **Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"count selects plural branch"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"context selector falls back to base key"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"defaultValue used on miss"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"values interpolated alongside count"` --- ### Requirement: Nested-key references in source strings When a source string contains `$t(other.key)` or `$t(ns:other.key)`, the catalog SHALL recursively resolve the referenced key and substitute its value in place. Cycles SHALL be detected via a maximum nesting depth of 4 (configurable via `nestingDepth` option). Exceeding the depth SHALL throw `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` listing the chain. #### Scenario: nested reference resolves - **GIVEN** catalog `{ en: { common: { cancel: 'Cancel' }, dialog: { close: 'Close — $t(common:cancel)' } } }` - **WHEN** `t('dialog:close')` is called - **THEN** the result equals `'Close — Cancel'` #### Scenario: cycle throws NESTING_DEPTH_EXCEEDED - **GIVEN** catalog `{ en: { a: '$t(b)', b: '$t(a)' } }` - **WHEN** `t('a')` is called - **THEN** it throws `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` with `context.path` containing `['a', 'b', 'a', 'b', 'a']` **Verified by:** `packages/i18n/i18n/src/core/catalog/translate.test.ts::"nested reference resolves"` AND `packages/i18n/i18n/src/core/catalog/translate.test.ts::"cycle throws NESTING_DEPTH_EXCEEDED"` --- ### Requirement: ICU selectordinal node `MessageParser` SHALL recognise `{var, selectordinal, one {…} two {…} few {…} other {…}}` as a node type distinct from `plural`. At render time, the renderer SHALL invoke the supplied `pluralSelect(count, 'ordinal')` callback (or `PluralizationEngine.getCategory(count, locale, true)` when wired through `MessageCatalog.t`). #### Scenario: selectordinal selects 'few' for English '3' - **GIVEN** message `'{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place'` - **WHEN** parsed and rendered with `{ place: 3 }` under `en` locale - **THEN** the result equals `'3rd place'` **Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"selectordinal selects 'few' for English '3'"` --- ### Requirement: ICU typed-argument support `MessageParser` SHALL recognise typed-argument forms `{name, type[, style]}` where `type` is one of `date`, `time`, `number`. At render time, the renderer SHALL route through the canonical `formatDate` / `formatTime` / `formatNumber` helpers from `@suaveplan/i18n/formatting/*`, threading the active locale. Style strings SHALL accept the standard ICU subset: - `date`: `short`, `medium`, `long`, `full` - `time`: `short`, `medium`, `long`, `full` - `number`: `decimal`, `percent`, `currency/`, `compact`, `scientific`, `engineering` #### Scenario: typed currency argument formats per locale - **GIVEN** message `'{amount, number, currency/USD}'` - **WHEN** parsed and rendered with `{ amount: 1234.5 }` under `en-US` locale - **THEN** the result equals `'$1,234.50'` #### Scenario: typed date argument formats per style - **GIVEN** message `'{at, date, medium}'` - **WHEN** parsed and rendered with `{ at: new Date(Date.UTC(2026, 4, 4)) }` under `en-GB` locale - **THEN** the result equals `'4 May 2026'` #### Scenario: unknown type rejects at parse time - **GIVEN** message `'{x, magic, foo}'` - **WHEN** parsed - **THEN** `MessageParser.validate(message)` returns an error with code `'UNKNOWN_ARG_TYPE'` and `field === 'magic'` **Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"typed currency argument formats per locale"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"typed date argument formats per style"` AND `packages/i18n/i18n/src/parser/message/message-validate.test.ts::"unknown type rejects at validate"` --- ### Requirement: ICU apostrophe quoting `MessageParser` SHALL implement the ICU MessageFormat apostrophe quoting rule: - A standalone apostrophe followed by a `{`, `}`, or another apostrophe begins a quoted run; the quoted run ends at the next single apostrophe. - Two consecutive apostrophes (`''`) render as a single literal apostrophe. - Apostrophes outside this rule render literally. #### Scenario: doubled apostrophe renders as single - **GIVEN** message `"it''s here"` - **WHEN** parsed and rendered with `{}` values - **THEN** the result equals `"it's here"` #### Scenario: quoted run preserves braces - **GIVEN** message `"'{literal}' is {actual}"` - **WHEN** parsed and rendered with `{ actual: 'X' }` - **THEN** the result equals `'{literal} is X'` #### Scenario: standalone apostrophe is literal - **GIVEN** message `"don't"` - **WHEN** parsed and rendered - **THEN** the result equals `"don't"` **Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"doubled apostrophe renders as single"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"quoted run preserves braces"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"standalone apostrophe is literal"` --- ### Requirement: Rich-text / tag interpolation `MessageParser` SHALL recognise XML-style open/close tag pairs (`...`) inside source messages and emit a `TagNode` with `name: string` and `children: MessageNode[]`. `MessageParser.format(message, values)` SHALL accept a value `name: (chunks: string) => string` to render the tag content inline. `MessageParser.formatChunks(message, values, chunkValues)` SHALL return `Array` for framework consumers, where `chunkValues[name]: (chunks: T[]) => T` (e.g. `{ bold: (chunks) => {chunks} }`). #### Scenario: tag formats inline as string - **GIVEN** message `'Hello world!'` - **WHEN** `format(message, { bold: (chunks) =>`**${chunks}**`})` - **THEN** the result equals `'Hello **world**!'` #### Scenario: nested tag formats correctly - **GIVEN** message `'read more here'` - **WHEN** `format(message, { a: (c) =>`[${c}]`, b: (c) => `*${c}*`})` - **THEN** the result equals `'[read *more* here]'` #### Scenario: formatChunks returns mixed string + framework-node array - **GIVEN** message `'Hello world!'` - **WHEN** `formatChunks(message, {}, { bold: (c) => ({ tag: 'b', children: c }) })` - **THEN** the result deep-equals `['Hello ', { tag: 'b', children: ['world'] }, '!']` **Verified by:** `packages/i18n/i18n/src/parser/message/message.test.ts::"tag formats inline as string"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"nested tag formats correctly"` AND `packages/i18n/i18n/src/parser/message/message.test.ts::"formatChunks returns mixed array"` --- ### Requirement: I18nInstance facade and plugin lifecycle The package SHALL expose `createI18n(options) → I18nInstance` that composes `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, and a plugin registry. `I18nInstance.use(plugin: I18nPlugin)` SHALL register the plugin (calling `install(i18n)` synchronously or awaiting it when async). `I18nInstance.dispose()` SHALL call `dispose()` on every registered plugin in reverse-registration order. #### Scenario: plugin install runs once on registration - **GIVEN** an `I18nInstance` and a plugin whose `install` increments a counter - **WHEN** `i18n.use(plugin)` is called once - **THEN** the counter is 1 (not 0, not 2) #### Scenario: dispose runs in reverse order - **GIVEN** plugins A, B, C registered in that order, each pushing their name into a shared array on `dispose` - **WHEN** `i18n.dispose()` is called - **THEN** the shared array equals `['C', 'B', 'A']` #### Scenario: duplicate plugin names rejected - **GIVEN** an `I18nInstance` with a plugin named `'http-backend'` already registered - **WHEN** another plugin with the same name is registered - **THEN** it throws `I18nError(I18N.PLUGIN_ALREADY_INSTALLED)` with `context.name === 'http-backend'` **Verified by:** `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"install runs once"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"dispose runs in reverse order"` AND `packages/i18n/i18n/src/core/plugin/plugin.test.ts::"duplicate plugin name rejected"` --- ### Requirement: Event bus topics `I18nInstance` SHALL emit five canonical event topics via an internal `@suaveplan/events` bus exposed as `i18n.events`: - `languageChanged` — `{ from: string; to: string }` — emitted by `i18n.changeLanguage(locale)` - `loaded` — `{ locale: string; namespace: string; backend?: string }` — emitted on every catalog `set` / `mergeMessages` / backend read - `missingKey` — `{ locale: string; namespace: string; key: string; defaultValue?: string }` — emitted on every miss before `MissingKeyHandler` is invoked - `keyAdded` — `{ locale: string; namespace: string; key: string; value: string }` — emitted on `saveMissing` / HMR / backend write-through - `pluralRuleAdded` — `{ locale: string }` — emitted by `PluralizationEngine.registerRule` #### Scenario: changeLanguage emits languageChanged - **WHEN** `i18n.changeLanguage('fr')` is called from an `en` locale - **THEN** the bus emits `languageChanged` with `{ from: 'en', to: 'fr' }` exactly once #### Scenario: missing key emits missingKey before handler - **WHEN** `t('absent.key')` is called and a `MissingKeyHandler` is registered - **THEN** the bus emits `missingKey` with the lookup payload AND the handler is invoked AFTER the emission **Verified by:** `packages/i18n/i18n/src/core/plugin/events.test.ts::"changeLanguage emits languageChanged"` AND `packages/i18n/i18n/src/core/plugin/events.test.ts::"missingKey emitted before handler"` --- ### Requirement: List, display-name, and number-helper formatters The package SHALL expose: - `formatList(values: string[], locale: string, options?: ListFormatOptions)` wrapping `Intl.ListFormat` with cached instance per `(locale, options-hash)` - `formatDisplayName(code: string, locale: string, options: DisplayNameOptions)` wrapping `Intl.DisplayNames` - `formatPercent(value: number, locale: string, options?)` — pre-bound `formatNumber({ style: 'percent' })` - `formatCompact(value: number, locale: string, options?)` — pre-bound `formatNumber({ notation: 'compact' })` - `formatUnit(value: number, unit: string, locale: string, options?)` — pre-bound `formatNumber({ style: 'unit', unit })` Each function SHALL emit a sample on the relevant `i18n.format..duration_ms` histogram. #### Scenario: list conjunction joins with locale-specific connector - **WHEN** `formatList(['apples', 'oranges', 'bananas'], 'en-US', { type: 'conjunction' })` is called - **THEN** the result equals `'apples, oranges, and bananas'` #### Scenario: display-name returns localised region name - **WHEN** `formatDisplayName('GB', 'en-US', { type: 'region' })` is called - **THEN** the result equals `'United Kingdom'` #### Scenario: compact notation for thousands - **WHEN** `formatCompact(1234, 'en-US')` is called - **THEN** the result equals `'1.2K'` #### Scenario: unit formatting - **WHEN** `formatUnit(12, 'kilometer-per-hour', 'en-US')` is called - **THEN** the result equals `'12 km/h'` **Verified by:** `packages/i18n/i18n/src/formatting/list/list.test.ts::"list conjunction joins"` AND `packages/i18n/i18n/src/formatting/display-name/display-name.test.ts::"region name localised"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"compact notation for thousands"` AND `packages/i18n/i18n/src/formatting/number/number-helpers.test.ts::"unit formatting"` --- ### Requirement: Pluralization engine prefers Intl.PluralRules `PluralizationEngine.getRule(locale)` SHALL select rules in this order: (1) explicitly registered override rule, (2) `Intl.PluralRules(locale)` when supported by the runtime, (3) hand-written rule registry, (4) `defaultPluralRule`. The hand-written rules SHALL remain registered and SHALL be used when `Intl.PluralRules` is unavailable. This decouples the package from CLDR drift. #### Scenario: registered override wins over Intl - **GIVEN** a custom rule registered for `en` that always returns `'few'` - **WHEN** `getRule('en')(5)` is called - **THEN** the result equals `'few'` (override wins, Intl skipped) #### Scenario: Intl.PluralRules used when no override - **GIVEN** no override registered for `pl` - **WHEN** `getCategory(2, 'pl')` is called on a runtime where `Intl.PluralRules` is supported - **THEN** the result equals `Intl.PluralRules('pl').select(2)` (i.e. `'few'`) **Verified by:** `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"override wins over Intl"` AND `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"` --- ### Requirement: Locale-aware first-day-of-week `getDayNames(locale, style?)` SHALL consult `new Intl.Locale(locale).weekInfo.firstDay` (1 = Monday, 7 = Sunday) to determine the array's starting weekday. When `weekInfo` is unavailable on the runtime (older Node, older browsers), the function SHALL fall back to Monday-start (`firstDay: 1`). #### Scenario: en-US starts on Sunday - **WHEN** `getDayNames('en-US', 'short')` is called on a runtime where `weekInfo` is available - **THEN** the first element equals `'Sun'` #### Scenario: en-GB starts on Monday - **WHEN** `getDayNames('en-GB', 'short')` is called - **THEN** the first element equals `'Mon'` **Verified by:** `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-US starts on Sunday"` AND `packages/i18n/i18n/src/formatting/date/date.test.ts::"en-GB starts on Monday"` --- ### Requirement: Accept-Language preserves script casing `parseAcceptLanguage(header)` SHALL preserve the BCP-47 case convention of script subtags: language → lowercase, script → title-case, region → uppercase. The current implementation lowercases the entire tag and is a bug. #### Scenario: zh-Hant-TW round-trips with original casing - **WHEN** `parseAcceptLanguage('zh-Hant-TW;q=0.9')` is called - **THEN** the first entry's `code` equals `'zh-Hant-TW'` (script title-case, region upper-case, language lower-case) **Verified by:** `packages/i18n/i18n/src/locale/negotiation/negotiation.test.ts::"zh-Hant-TW round-trips with original casing"` --- ### Requirement: Pseudo-localization post-processor The package SHALL expose `pseudoLocalize(text, options?)` and a corresponding `pseudoLocalizationPlugin(options?)` that registers as a post-processor. Three modes SHALL be supported via `mode: 'accent' | 'bracket' | 'expand'`: - `accent` (default): replaces ASCII letters with diacritic equivalents (`Welcome` → `Wéłçömé`) - `bracket`: surrounds the string with `[!! ... !!]` markers - `expand`: pads the string by ~30% with extra characters to test layout overflow #### Scenario: accent mode produces diacritic equivalents - **WHEN** `pseudoLocalize('Welcome', { mode: 'accent' })` is called - **THEN** the result is non-empty AND every original ASCII letter is replaced with a non-ASCII counterpart #### Scenario: bracket mode surrounds the string - **WHEN** `pseudoLocalize('Welcome', { mode: 'bracket' })` is called - **THEN** the result equals `'[!! Welcome !!]'` #### Scenario: post-processor plugin transforms t() output - **GIVEN** an `I18nInstance` with `pseudoLocalizationPlugin({ mode: 'bracket' })` registered - **WHEN** `t('greeting')` returns `'Welcome'` from the catalog - **THEN** the final return value equals `'[!! Welcome !!]'` **Verified by:** `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"accent mode produces diacritic equivalents"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"bracket mode surrounds string"` AND `packages/i18n/i18n/src/post-processors/pseudo-localize.test.ts::"plugin transforms t() output"` --- ### Requirement: Segmenter and collator wrappers The package SHALL expose `formatSegment(text, locale, granularity?)` returning an array of `{ segment: string; index: number; isWordLike?: boolean }` via `Intl.Segmenter`, and `compareLocale(a, b, locale, options?)` wrapping `Intl.Collator` with cached instance per `(locale, options-hash)`. #### Scenario: word segmentation in Japanese - **WHEN** `formatSegment('東京は晴れです', 'ja-JP', 'word')` is called on a runtime with `Intl.Segmenter` - **THEN** the result has at least one segment with `isWordLike: true` and `segment === '東京'` #### Scenario: collator sorts case-insensitively - **WHEN** `['B', 'a', 'C'].sort((a, b) => compareLocale(a, b, 'en-US', { sensitivity: 'base' }))` - **THEN** the result equals `['a', 'B', 'C']` **Verified by:** `packages/i18n/i18n/src/locale/segment/segment.test.ts::"word segmentation in Japanese"` AND `packages/i18n/i18n/src/locale/collator/collator.test.ts::"sorts case-insensitively"` --- ### Requirement: OTel meter instruments wired into production paths Every existing instrument declared in `src/observability/metrics.ts` (`i18n.translate.total`, `i18n.translate.duration_ms`, `i18n.locale.resolve.total`) SHALL be incremented / observed from the relevant production code paths. Two additional instruments SHALL be added: - Histogram `i18n.interpolation.duration_ms` — emitted from `InterpolationEngine.render` and `MessageParser.format` - Counter `i18n.missing_key.total` — labels: `locale`, `namespace`; incremented from `MessageCatalog.get` whenever the lookup falls through to the missing-key handler or returns `undefined` #### Scenario: get records translate.total{outcome=hit} - **WHEN** `MessageCatalog.get('present.key')` resolves a value with the in-memory `MeterProvider` registered - **THEN** exactly one sample is recorded under `i18n.translate.total` with attribute `outcome: 'hit'` AND one sample under `i18n.translate.duration_ms` #### Scenario: locale resolve records strategy - **WHEN** `LocaleResolver.resolve('fr-FR')` returns via `'language'` strategy - **THEN** `i18n.locale.resolve.total` records a sample with attribute `strategy: 'language'` #### Scenario: missing key increments counter - **WHEN** `MessageCatalog.get('absent.key')` returns `undefined` - **THEN** `i18n.missing_key.total` records a sample with `{ locale: 'en', namespace: '' }` **Verified by:** `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"translate.total records hit"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"locale.resolve.total records strategy"` AND `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts::"missing_key.total increments on miss"` --- ### Requirement: Missing-key reporter plugin The package SHALL expose `createMissingKeyReporter(options?)` returning an `I18nPlugin` that subscribes to the `missingKey` event. By default it logs at `warn` via `@suaveplan/logger`. When `options.onMissing` is supplied, the plugin SHALL invoke it with the event payload. When the catalog is configured with `saveMissing: true` and a backend that implements `save`, the reporter MAY (opt-in via `persistMissing: true`) call `catalog.saveMissing(locale, key, defaultValue)`. #### Scenario: missing key logs warn by default - **GIVEN** a logger spy and `i18n.use(createMissingKeyReporter({ logger }))` - **WHEN** `t('absent.key')` is called - **THEN** the logger received exactly one `warn` call mentioning `'absent.key'` #### Scenario: persistMissing routes to backend - **GIVEN** `saveMissing: true`, a `MemoryBackend`, and `createMissingKeyReporter({ persistMissing: true })` - **WHEN** `t('new.key', { defaultValue: 'New' })` is called for a missing key - **THEN** the backend's `save` was called once with the merged record containing `'new.key': 'New'` **Verified by:** `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"warn logged by default"` AND `packages/i18n/i18n/src/post-processors/missing-key-reporter.test.ts::"persistMissing routes to backend"` ## MODIFIED Requirements ### I18N-05 — Pluralization engine contract `PluralizationEngine` MUST: - Maintain a `PluralRuleRegistry` that callers can write to via `registerRule(locale, rule)`. Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, and `defaultPluralRule` SHALL remain registered as overrides. - `.getRule(locale)` selects in this order: (1) explicit override registered via `registerRule`, (2) `Intl.PluralRules(locale)` when the runtime supports it, (3) the hand-written rule registry, (4) `defaultPluralRule`. - `.resolve(count, locale, messages, opts?)` MUST call the resolved rule, fall back to the base-language rule (strip region), then fall back to `defaultPluralRule`; return `PluralResolveResult { category: PluralCategory; value: string }`. - `PluralCategory` MUST be `'zero' | 'one' | 'two' | 'few' | 'many' | 'other'`. - In strict mode, throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)` when no rule exists for the locale and its base language and `Intl.PluralRules` is unavailable. #### Scenario: Intl.PluralRules used when no override - **GIVEN** no override registered for `'pl'` - **WHEN** `getCategory(2, 'pl')` is called - **THEN** the result equals `Intl.PluralRules('pl').select(2)` (`'few'`) #### Scenario: hand-written rule used when Intl unavailable - **GIVEN** a stubbed runtime where `Intl.PluralRules` throws on construction - **WHEN** `getCategory(2, 'pl')` is called - **THEN** the result is computed by the registered `polishPlural` function **Verified by:** `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"Intl used when no override"` AND `packages/i18n/i18n/src/core/pluralization/pluralization.test.ts::"hand-written used when Intl unavailable"`