418 lines
23 KiB
Markdown
418 lines
23 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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{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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|