Files
suaveplan/openspec/changes/archive/2026-05-04-i18n-completion/specs/i18n/spec.md
2026-05-27 23:29:53 +01:00

23 KiB

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<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) 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/<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 } 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 (<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 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.<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 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

  • 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"