23 KiB
Capability Spec Delta: i18n (i18n-completion)
Package: @suaveplan/i18n
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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<string, unknown>, escapeHtml?: boolean, and formatters?: Record<string, ValueFormatter>. 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)withcontext.pathcontaining['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 }underenlocale - 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,fulltime:short,medium,long,fullnumber:decimal,percent,currency/<ISO 4217 code>,compact,scientific,engineering
Scenario: typed currency argument formats per locale
- GIVEN message
'{amount, number, currency/USD}' - WHEN parsed and rendered with
{ amount: 1234.5 }underen-USlocale - 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)) }underen-GBlocale - 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'andfield === '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 (<tag>...</tag>) 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<T>(message, values, chunkValues) SHALL return Array<string | T> for framework consumers, where chunkValues[name]: (chunks: T[]) => T (e.g. { bold: (chunks) => <b>{chunks}</b> }).
Scenario: tag formats inline as string
- GIVEN message
'Hello <bold>world</bold>!' - WHEN
format(message, { bold: (chunks) =>${chunks}}) - THEN the result equals
'Hello **world**!'
Scenario: nested tag formats correctly
- GIVEN message
'<a>read <b>more</b> here</a>' - 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 <bold>world</bold>!' - 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
I18nInstanceand a plugin whoseinstallincrements 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
I18nInstancewith a plugin named'http-backend'already registered - WHEN another plugin with the same name is registered
- THEN it throws
I18nError(I18N.PLUGIN_ALREADY_INSTALLED)withcontext.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 byi18n.changeLanguage(locale)loaded—{ locale: string; namespace: string; backend?: string }— emitted on every catalogset/mergeMessages/ backend readmissingKey—{ locale: string; namespace: string; key: string; defaultValue?: string }— emitted on every miss beforeMissingKeyHandleris invokedkeyAdded—{ locale: string; namespace: string; key: string; value: string }— emitted onsaveMissing/ HMR / backend write-throughpluralRuleAdded—{ locale: string }— emitted byPluralizationEngine.registerRule
Scenario: changeLanguage emits languageChanged
- WHEN
i18n.changeLanguage('fr')is called from anenlocale - THEN the bus emits
languageChangedwith{ from: 'en', to: 'fr' }exactly once
Scenario: missing key emits missingKey before handler
- WHEN
t('absent.key')is called and aMissingKeyHandleris registered - THEN the bus emits
missingKeywith 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)wrappingIntl.ListFormatwith cached instance per(locale, options-hash)formatDisplayName(code: string, locale: string, options: DisplayNameOptions)wrappingIntl.DisplayNamesformatPercent(value: number, locale: string, options?)— pre-boundformatNumber({ style: 'percent' })formatCompact(value: number, locale: string, options?)— pre-boundformatNumber({ notation: 'compact' })formatUnit(value: number, unit: string, locale: string, options?)— pre-boundformatNumber({ style: 'unit', unit })
Each function SHALL emit a sample on the relevant i18n.format.<name>.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
enthat 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 whereIntl.PluralRulesis 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 whereweekInfois 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
codeequals'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[!! ... !!]markersexpand: 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
I18nInstancewithpseudoLocalizationPlugin({ 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 withIntl.Segmenter - THEN the result has at least one segment with
isWordLike: trueandsegment === '東京'
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 fromInterpolationEngine.renderandMessageParser.format - Counter
i18n.missing_key.total— labels:locale,namespace; incremented fromMessageCatalog.getwhenever the lookup falls through to the missing-key handler or returnsundefined
Scenario: get records translate.total
- WHEN
MessageCatalog.get('present.key')resolves a value with the in-memoryMeterProviderregistered - THEN exactly one sample is recorded under
i18n.translate.totalwith attributeoutcome: 'hit'AND one sample underi18n.translate.duration_ms
Scenario: locale resolve records strategy
- WHEN
LocaleResolver.resolve('fr-FR')returns via'language'strategy - THEN
i18n.locale.resolve.totalrecords a sample with attributestrategy: 'language'
Scenario: missing key increments counter
- WHEN
MessageCatalog.get('absent.key')returnsundefined - THEN
i18n.missing_key.totalrecords 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
warncall mentioning'absent.key'
Scenario: persistMissing routes to backend
- GIVEN
saveMissing: true, aMemoryBackend, andcreateMissingKeyReporter({ persistMissing: true }) - WHEN
t('new.key', { defaultValue: 'New' })is called for a missing key - THEN the backend's
savewas 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
PluralRuleRegistrythat callers can write to viaregisterRule(locale, rule). Built-in hand-written rules for Arabic, Chinese, English, French, German, Polish, Russian, Spanish, Welsh, anddefaultPluralRuleSHALL remain registered as overrides. .getRule(locale)selects in this order: (1) explicit override registered viaregisterRule, (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 todefaultPluralRule; returnPluralResolveResult { category: PluralCategory; value: string }.PluralCategoryMUST 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 andIntl.PluralRulesis 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.PluralRulesthrows on construction - WHEN
getCategory(2, 'pl')is called - THEN the result is computed by the registered
polishPluralfunction
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"