Initial Commit
This commit is contained in:
155
openspec/changes/archive/2026-05-04-i18n-completion/proposal.md
Normal file
155
openspec/changes/archive/2026-05-04-i18n-completion/proposal.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# i18n completion — ergonomic t(), full ICU, plugin surface, Intl wrappers
|
||||
|
||||
**Package**: `@suaveplan/i18n`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature + spec-conformance
|
||||
**Date**: 2026-05-04
|
||||
|
||||
## Why
|
||||
|
||||
A 2026-05-04 review against `i18next`, `FormatJS / react-intl`, `LinguiJS`, `vue-i18n`, and `next-intl` flagged a long tail of features that are headline-tier in every popular i18n library and missing here. The package today is a clean, well-tested **core** — but the surface that consumers actually call (`MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`) requires three separate manual calls to do what `i18next.t('item', { count: 3 })` does in one. It also lacks a formal plugin lifecycle, three of four `Intl.*` wrappers, and several real-world ICU MessageFormat features (typed args, apostrophe escape, rich-text/tag interpolation). OTel meter instruments are defined but never incremented.
|
||||
|
||||
This change closes those gaps so `@suaveplan/i18n` is genuinely on par with i18next in features, with the genesis-quality surface (Zod-validated, generic-threaded, OTel-wired, 100/100/100/100). It pairs with `i18n-storage-backends` (which adds the `Backend` interface) and is a prerequisite for `enterprise-react-i18n`.
|
||||
|
||||
## What changes
|
||||
|
||||
### 1. Ergonomic `t()` — one-call translate
|
||||
|
||||
The current `MessageCatalog.t(key)` returns the raw catalog string. Consumers manually wire pluralization + interpolation + missing-key handling. This is the biggest DX gap vs every popular library.
|
||||
|
||||
- ADDED: `MessageCatalog.t(key, options?: TranslateOptions)` accepts a single options bag that internally resolves plural + interpolation + nested-key references + context selector + default value:
|
||||
- `count: number` — when present, runs `PluralizationEngine.resolve` and selects the matching plural form. The catalog key may be a plain string or an object `{ one, other, ... }`; both shapes work.
|
||||
- `context: string` — i18next-compatible context selector. `t('greeting', { context: 'male' })` looks up `greeting_male` first, falling back to `greeting`.
|
||||
- `defaultValue: string` — fallback when key is missing, in lieu of returning the key.
|
||||
- `values: Record<string, unknown>` — interpolation values; `count` is auto-injected so plural strings can use `{count}` directly.
|
||||
- `escapeHtml?: boolean` — overrides the engine default for this call.
|
||||
- `formatters?: Record<string, ValueFormatter>` — per-call formatter overrides.
|
||||
- Nested-key references in source strings: `"$t(common:cancel)"` resolves to the value of `common:cancel` recursively; cycle detection via depth limit.
|
||||
- ADDED: `TranslateOptions` and `PluralKey` types in `src/core/catalog/translate.types.ts`.
|
||||
- ADDED: `t.plural(key, count, values?)` shortcut for the common case.
|
||||
- MODIFIED: existing `MessageCatalog.t(key)` single-string call remains backwards-compatible — `t(key)` is `t(key, {})`.
|
||||
|
||||
### 2. Full ICU MessageFormat support
|
||||
|
||||
The current `MessageParser` handles `{name}`, `{count, plural, ...}`, `{gender, select, ...}`. Real ICU (and FormatJS) callers expect more.
|
||||
|
||||
- ADDED: `selectordinal` node type — `{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}`. Routes through `PluralizationEngine` with `ordinal: true`.
|
||||
- ADDED: typed argument support: `{date, date, short}`, `{at, time, medium}`, `{n, number, currency/USD}`, `{n, number, percent}`, `{n, number, ::compact-short}` (ICU number skeleton). Wired through `formatDate` / `formatNumber` / `formatTime` from `@suaveplan/i18n/formatting/*`.
|
||||
- ADDED: apostrophe quoting — `it''s` renders as `it's`; `'{literal}'` renders as `{literal}` (skips ICU parsing inside single-quoted runs). This is the standard ICU MessageFormat quoting rule and the current parser silently misparses these strings.
|
||||
- ADDED: rich-text / tag interpolation — `'<bold>welcome</bold>'` parses to a `TagNode` AST; `MessageParser.format(message, values)` accepts `values: { bold: (chunks) => string }` to produce a string, and `MessageParser.formatChunks(message, values, chunkValues)` returns `Array<string | T>` for React/Vue consumers (where `chunkValues` like `{ bold: (chunks) => <b>{chunks}</b> }` produce framework nodes — used heavily by `enterprise-react-i18n`).
|
||||
- ADDED: `MessageNode` discriminated union expands: `text | variable | plural | selectordinal | select | tag | typed-argument`.
|
||||
- ADDED: ICU validator — `MessageParser.validate(message): ValidationResult` reports unbalanced braces, missing `other` branches, undefined plural categories, and unknown typed-argument types. Used by the optional build-time linter.
|
||||
|
||||
### 3. Formal plugin / extensibility surface
|
||||
|
||||
Today's "extensibility" is informal: `addFormatter`, `registerRule`, etc. on individual engines. There's no plugin lifecycle and no event bus, which makes cross-cutting concerns (logging, missing-key reporters, post-processors) hard to wire.
|
||||
|
||||
- ADDED: `I18nPlugin` interface in `src/core/plugin/plugin.types.ts`:
|
||||
- `name: string`, `version?: string`
|
||||
- `install?(i18n: I18nInstance): void | Promise<void>` — register hooks, formatters, backends
|
||||
- `dispose?(): void | Promise<void>` — teardown
|
||||
- Plugin types (a single plugin can implement multiple): `'backend' | 'formatter' | 'post-processor' | 'detector' | 'logger'`
|
||||
- ADDED: `I18nInstance` — a top-level façade that binds together `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, and the plugin registry. Created via `createI18n(options)`. Existing engines remain individually constructable.
|
||||
- ADDED: Event bus topics on `I18nInstance` (delegates to `@suaveplan/events`):
|
||||
- `languageChanged` — `{ from, to }`
|
||||
- `loaded` — `{ locale, namespace, backend? }`
|
||||
- `missingKey` — `{ locale, namespace, key, defaultValue? }`
|
||||
- `keyAdded` — `{ locale, namespace, key, value }` (for HMR / save-missing flows)
|
||||
- `pluralRuleAdded` — `{ locale }`
|
||||
- ADDED: post-processor plugins. `I18nInstance` accepts `postProcessors: PostProcessor[]` where each is `(value, key, options) => string`. Built-in post-processors: `markdown` (renders bold/italic/links via `marked` peer), `sprintf` (printf-style format strings — opt-in i18next compatibility), `pseudo-localization` (transforms `Welcome` → `[!! Wëlçömé !!]` for layout testing).
|
||||
- ADDED: detector plugins (browser-side locale detection). Built-in detectors: `navigator-detector`, `localstorage-detector`, `cookie-detector`, `query-detector`, `path-detector`, `subdomain-detector`. Browser `LocaleResolver` composes them via `detectionOrder`. Server-side `createLocaleMiddleware` already covers the equivalent surface.
|
||||
|
||||
### 4. Missing Intl wrappers — list, display-names, expanded number helpers
|
||||
|
||||
The current `formatting/` module exposes `currency`, `date`, and (after `i18n-formatters` lands) `number` and `relative-time`. Two more `Intl.*` APIs are still unwrapped, and three convenience number helpers are universally expected.
|
||||
|
||||
- ADDED: `formatList(values, locale, options?)` — wraps `Intl.ListFormat`. Options: `style: 'long' | 'short' | 'narrow'`, `type: 'conjunction' | 'disjunction' | 'unit'`. Per-(locale, options) cache like the other formatters.
|
||||
- ADDED: `formatDisplayName(code, locale, options)` — wraps `Intl.DisplayNames`. Options: `type: 'language' | 'region' | 'script' | 'currency' | 'calendar' | 'dateTimeField'`, `style`, `fallback`. Use cases: building locale switchers, currency dropdowns, region pickers.
|
||||
- ADDED: `formatPercent(value, locale, options?)` — pre-bound `formatNumber({ style: 'percent' })`.
|
||||
- ADDED: `formatCompact(value, locale, options?)` — pre-bound `formatNumber({ notation: 'compact' })` for "1.2K" / "1.2M" output.
|
||||
- ADDED: `formatUnit(value, unit, locale, options?)` — pre-bound `formatNumber({ style: 'unit', unit })` for "12 km/h" output.
|
||||
|
||||
### 5. Locale + plural correctness fixes
|
||||
|
||||
- MODIFIED: `parseAcceptLanguage` preserves script-subtag casing (`zh-Hant-TW`, not `zh-hant-tw`) per BCP-47. The current implementation lowercases the entire tag.
|
||||
- MODIFIED: `PluralizationEngine` defaults to `Intl.PluralRules` first, falls back to the hand-written rule registry. The hand-written rules drift against CLDR over time; making `Intl.PluralRules` the primary source future-proofs against CLDR releases. Hand-written rules remain registered as overrides for runtimes that lack `Intl.PluralRules` (e.g. very old browsers — vanishingly rare in genesis, but the override path costs nothing).
|
||||
- ADDED: `Intl.Locale.weekInfo`-aware `getDayNames(locale, style?)` — first-day-of-week is consulted via `new Intl.Locale(locale).weekInfo.firstDay` (1 = Monday, 7 = Sunday). Falls back to Monday when `weekInfo` is unavailable. Existing Monday-hardcoded behaviour was a bug for US/Mid-East locales.
|
||||
- ADDED: `formatSegment(text, locale, granularity?)` and `compareLocale(a, b, locale, options?)` — thin wrappers around `Intl.Segmenter` and `Intl.Collator` for non-Latin search/sort.
|
||||
- ADDED: `pseudoLocalize(text, options?)` post-processor with three modes: `accent` (`Welcome` → `Wéłçömé`), `bracket` (`Welcome` → `[!! Welcome !!]`), `expand` (Welcome → `Welcomeeeee` to test layout). Used by the `pseudo-localization` post-processor plugin and by tests directly.
|
||||
|
||||
### 6. Wire OTel meter instruments
|
||||
|
||||
`getI18nMetrics()` exists with `translateTotal`, `localeResolveTotal`, `translateDuration` instruments (`src/observability/metrics.ts`), but no production code path increments them. They are dead code today.
|
||||
|
||||
- MODIFIED: `MessageCatalog.get` increments `i18n.translate.total{outcome,locale}` on every call and records `i18n.translate.duration_ms{locale}` for every lookup.
|
||||
- MODIFIED: `LocaleResolver.resolve` and `negotiateLocale` increment `i18n.locale.resolve.total{strategy,locale}`.
|
||||
- ADDED: `i18n.interpolation.duration_ms` histogram on `InterpolationEngine.render` and `MessageParser.format`.
|
||||
- ADDED: `i18n.missing_key.total` counter labelled by `locale`, `namespace` — incremented from `MessageCatalog.get` whenever the lookup falls through to the missing-key handler or returns `undefined`.
|
||||
|
||||
### 7. Workflow tooling
|
||||
|
||||
- ADDED: missing-key reporter plugin — `createMissingKeyReporter({ logger?, onMissing? })` registers as an `I18nPlugin` and listens on the `missingKey` event. Default behaviour: log via `@suaveplan/logger` at `warn` level. With a backend configured, it can also call `catalog.saveMissing` (gated by `saveMissing: true`).
|
||||
- ADDED: `extractKeys(source: string, options?)` — minimal AST-free extractor that walks a source string for `t('...')` / `t("...")` / `<Trans i18nKey="...">` patterns and returns the deduplicated key list. Not a full TypeScript parser; intended for the simple case and used by the optional `extract-i18n` CLI in `i18n-tooling` (separate change).
|
||||
- ADDED: pseudo-localization post-processor (covered by §5) wired so consumers can flip `i18n.use(pseudoLocalizationPlugin())` in test environments.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Full TypeScript-AST key extractor** — separate `i18n-tooling` change once the runtime surface stabilises.
|
||||
- **PO / XLIFF / Android XML / iOS .strings round-trip** — the `MultiFileFsBackend` (in `i18n-storage-backends`) already supports PO read via the `gettext-parser` peer; full bidirectional XLIFF / Android XML tooling is a separate change.
|
||||
- **Lingui/FBT-style hash-based stable IDs** — separate change. Requires build-time tooling and a different runtime contract.
|
||||
- **React/Vue/Solid bindings** — covered by `enterprise-react-i18n` (and equivalents for other frameworks as they land).
|
||||
- **Build-time message compilation (FormatJS-style)** — separate change once we have a Vite plugin scoped.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change is **genesis-original** in spirit: it lifts feature parity from i18next + FormatJS rather than from `../suaveplan/enterprise/i18n.md`. Every requirement below has a feature-parity citation in the table.
|
||||
|
||||
### Feature-parity table
|
||||
|
||||
| Requirement | Reference | Notes |
|
||||
|---|---|---|
|
||||
| Ergonomic `t({ count, context, defaultValue, values, ... })` | i18next `t()` | Single most-used i18n API; matching shape is critical for consumer ergonomics. |
|
||||
| Nested-key references `$t(...)` | i18next nesting | Cycle detection via depth limit (default 4). |
|
||||
| `selectordinal` node | ICU MessageFormat | Already supported by `PluralizationEngine.resolve(..., { ordinal: true })`; missing only in the parser. |
|
||||
| Typed arguments (`{x, number, currency/USD}`) | ICU MessageFormat / FormatJS | Routes through existing formatters. |
|
||||
| Apostrophe quoting | ICU MessageFormat spec | Current parser silently misparses these — it's a bug, not a feature. |
|
||||
| Tag/rich-text interpolation | i18next `<Trans>`, FormatJS `<FormattedMessage>` | Required by `enterprise-react-i18n`. |
|
||||
| `I18nPlugin` interface + lifecycle | i18next plugin model | Genesis already uses this pattern in `@suaveplan/network`, `@suaveplan/drive`. |
|
||||
| Event bus topics | i18next emit | Reuses `@suaveplan/events`. |
|
||||
| Pseudo-localization | i18next-pseudo, FormatJS pseudo | Standard layout-testing tool. |
|
||||
| `formatList`, `formatDisplayName` | `Intl.ListFormat`, `Intl.DisplayNames` | Universally expected. |
|
||||
| `formatCompact`, `formatPercent`, `formatUnit` | Intl helpers | Convenience over `formatNumber`. |
|
||||
| `Intl.Locale.weekInfo` for first-day-of-week | MDN, Chromium 99+, Node 22+ | Current Monday hardcode is a bug. |
|
||||
| `Intl.PluralRules` first | CLDR drift hardening | Hand-written rules become overrides, not source of truth. |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| OTel `i18n.translate.total` etc. wiring | `i18n-spec-conformance` declared the instruments; this change actually increments them. AGENTS.md §13 row 4. |
|
||||
| `extractKeys(source)` runtime helper | Enables a downstream CLI without locking the runtime to a specific TS-AST library. |
|
||||
| `formatSegment` / `compareLocale` | Required for non-Latin search/sort in our search packages. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each requirement carries a `**Verified by:**` line in the spec delta.
|
||||
|
||||
- `packages/i18n/i18n/src/core/catalog/translate.test.ts` — ergonomic `t()` with count, context, defaultValue, nested refs
|
||||
- `packages/i18n/i18n/src/parser/message/message.test.ts` (extended) — selectordinal, typed args, apostrophe escape, tag nodes
|
||||
- `packages/i18n/i18n/src/parser/message/message-validate.test.ts` — `MessageParser.validate` happy/sad paths
|
||||
- `packages/i18n/i18n/src/core/plugin/plugin.test.ts` — `I18nPlugin` lifecycle, event bus topics, post-processor / detector composition
|
||||
- `packages/i18n/i18n/src/formatting/list/list.test.ts`, `display-name/display-name.test.ts`, `number/number-helpers.test.ts`
|
||||
- `packages/i18n/i18n/src/locale/segment/segment.test.ts`, `collator/collator.test.ts`
|
||||
- `packages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts` — every instrument records a sample under in-memory `MeterProvider`
|
||||
|
||||
## Migration impact
|
||||
|
||||
- `MessageCatalog.t(key)` (single-arg) — backwards-compatible. Existing call sites continue to work.
|
||||
- `MessageCatalog.t(key, options)` (new) — additive.
|
||||
- `parseAcceptLanguage` casing fix — observable change for consumers that string-compared the result. No genesis consumers depend on the lowercased tag (verified by grep).
|
||||
- `PluralizationEngine` defaulting to `Intl.PluralRules` — observable for locales where the hand-written rule disagreed with CLDR. Tests pinning `Intl.PluralRules` output are added; the override registry path remains available for explicit overrides.
|
||||
- `MessageParser` apostrophe handling — observable bug fix: `it''s` formerly rendered as `it''s` and now renders as `it's`. No genesis consumers depend on the broken behaviour (verified by grep).
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`grep -rl "@suaveplan/i18n" /home/ubuntu/web/genesis/packages/ --include="package.json"` — only `packages/i18n/i18n/package.json` itself. New consumer (`@suaveplan/react-i18n`) lands via its own change.
|
||||
@@ -0,0 +1,417 @@
|
||||
<!-- 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 -->
|
||||
176
openspec/changes/archive/2026-05-04-i18n-completion/tasks.md
Normal file
176
openspec/changes/archive/2026-05-04-i18n-completion/tasks.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Tasks — i18n completion (ergonomic t, full ICU, plugin surface, Intl wrappers)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
|
||||
|
||||
## Sequencing
|
||||
|
||||
This change SHOULD land **after** `i18n-storage-backends` (the `Backend` interface is referenced by `createMissingKeyReporter` and the `loaded` event topic). It can land before or after `i18n-formatters` — the `formatList` / `formatDisplayName` / number-helper additions here are independent of `formatNumber` / `formatRelativeTime`.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/specs/i18n/spec.md` end-to-end (canonical capability spec)
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Confirm the in-flight `i18n-storage-backends` + `i18n-formatters` + `i18n-spec-conformance` proposals do not conflict with this delta (especially the `MessageCatalog` constructor surface and OTel meter naming)
|
||||
- [x] 1.4 `openspec change validate i18n-completion` exits with no errors
|
||||
|
||||
## 2. Ergonomic t() and TranslateOptions
|
||||
|
||||
- [x] 2.1 Define `TranslateOptions` and `PluralKey` in `packages/i18n/i18n/src/core/catalog/translate.types.ts`
|
||||
- [x] 2.2 Implement `MessageCatalog.t(key, options?)` accepting the options bag (≤300 lines after refactor)
|
||||
- [x] 2.3 Implement `count` → `PluralizationEngine.resolve` → branch selection with `count` auto-injected into `values`
|
||||
- [x] 2.4 Implement `context` → `${key}_${context}` lookup with fallback to base
|
||||
- [x] 2.5 Implement `defaultValue` fallback on miss
|
||||
- [x] 2.6 Implement nested-key references `$t(other.key)` / `$t(ns:key)` with depth limit (default 4) and cycle detection
|
||||
- [x] 2.7 Add `I18N.NESTING_DEPTH_EXCEEDED` error code via `defineCodes`
|
||||
- [x] 2.8 `t.plural(key, count, values?)` shortcut helper
|
||||
- [x] 2.9 `translate.test.ts` — count, context, defaultValue, nested refs (success + cycle), shortcut helper
|
||||
- [x] 2.10 Sibling `translate.md`
|
||||
|
||||
## 3. ICU MessageFormat — selectordinal
|
||||
|
||||
- [x] 3.1 Add `SelectOrdinalNode` to `MessageNode` discriminated union
|
||||
- [x] 3.2 Implement `parsePluralChoices(...)` branching for `selectordinal` (separate path so `plural` and `selectordinal` carry distinct `type` discriminants)
|
||||
- [x] 3.3 Render path calls `pluralSelect(count, 'ordinal')` (or, when wired through `MessageCatalog.t`, `PluralizationEngine.getCategory(count, locale, true)`)
|
||||
- [x] 3.4 `message.test.ts` — selectordinal selects `'few'` for English `3`, `'one'` for `1`, `'two'` for `2`
|
||||
|
||||
## 4. ICU MessageFormat — typed arguments
|
||||
|
||||
- [x] 4.1 Implement `parseTypedArg(message, start, name)` for `{name, date|time|number, style?}` shapes
|
||||
- [x] 4.2 Add `TypedArgumentNode` to `MessageNode` discriminated union with `type: 'date' | 'time' | 'number'` and `style: string`
|
||||
- [x] 4.3 Render path calls `formatDate` / `formatTime` / `formatNumber` from `@suaveplan/i18n/formatting/*`
|
||||
- [x] 4.4 Number style parsing: `decimal`, `percent`, `currency/<ISO>`, `compact`, `scientific`, `engineering`
|
||||
- [x] 4.5 Date/time style parsing: `short`, `medium`, `long`, `full`
|
||||
- [x] 4.6 `message.test.ts` — typed currency, typed date, typed percent, typed compact
|
||||
- [x] 4.7 `MessageParser.validate(message)` rejects unknown `type` values
|
||||
|
||||
## 5. ICU MessageFormat — apostrophe quoting
|
||||
|
||||
- [x] 5.1 Pre-process the source message in `parseIcu` to handle the three apostrophe rules
|
||||
- [x] 5.2 `''` → single literal `'`
|
||||
- [x] 5.3 `'<followed by`{`,`}`, or another`'`>...'` → quoted run, content is literal until matching `'`
|
||||
- [x] 5.4 Standalone `'` (any other context) → literal apostrophe
|
||||
- [x] 5.5 `message.test.ts` — `it''s here`, `'{literal}' is X`, `don't`
|
||||
|
||||
## 6. ICU MessageFormat — tag interpolation
|
||||
|
||||
- [x] 6.1 Add `TagNode { type: 'tag'; name: string; children: MessageNode[] }` to `MessageNode`
|
||||
- [x] 6.2 `parseIcu` recognises `<name>...</name>` pairs; nested tags supported
|
||||
- [x] 6.3 Implement `MessageParser.format(message, values)` where a value `name: (chunks: string) => string` renders the tag inline
|
||||
- [x] 6.4 Implement `MessageParser.formatChunks<T>(message, values, chunkValues)` returning `Array<string | T>` — the React-friendly path
|
||||
- [x] 6.5 `extractVariables` skips tag names (they're not interpolation variables)
|
||||
- [x] 6.6 `message.test.ts` — inline string tag, nested tags, formatChunks mixed array
|
||||
- [x] 6.7 Update `message.md` with the rich-text section and a React example referencing `@suaveplan/react-i18n`
|
||||
|
||||
## 7. MessageParser.validate
|
||||
|
||||
- [x] 7.1 Implement `validate(message): ValidationResult` returning `{ ok: true } | { ok: false; errors: ValidationError[] }`
|
||||
- [x] 7.2 Detect: unbalanced braces, unbalanced apostrophe quoted runs, missing `other` branch in `plural` / `select` / `selectordinal`, unknown typed-argument type, unknown plural categories
|
||||
- [x] 7.3 `message-validate.test.ts` — happy path + each error case
|
||||
|
||||
## 8. I18nInstance facade and plugin lifecycle
|
||||
|
||||
- [x] 8.1 Define `I18nPlugin`, `I18nInstance`, `PluginRegistry` in `src/core/plugin/plugin.types.ts`
|
||||
- [x] 8.2 Implement `createI18n(options) → I18nInstance` composing existing engines + plugin registry
|
||||
- [x] 8.3 `i18n.use(plugin)` — sync + async install; reject duplicate names with `I18N.PLUGIN_ALREADY_INSTALLED`
|
||||
- [x] 8.4 `i18n.dispose()` — reverse-order plugin disposal
|
||||
- [x] 8.5 `i18n.changeLanguage(locale)` — emits `languageChanged`
|
||||
- [x] 8.6 Add `I18N.PLUGIN_ALREADY_INSTALLED` error code via `defineCodes`
|
||||
- [x] 8.7 `plugin.test.ts` — install once, dispose order, duplicate rejection
|
||||
- [x] 8.8 Sibling `plugin.md` walks through the plugin contract with three example plugins
|
||||
|
||||
## 9. Event bus topics
|
||||
|
||||
- [x] 9.1 Wire `@suaveplan/events` instance as `i18n.events`
|
||||
- [x] 9.2 Emit `languageChanged` from `changeLanguage`
|
||||
- [x] 9.3 Emit `loaded` from `MessageCatalog.set` / `mergeMessages` and from backend reads
|
||||
- [x] 9.4 Emit `missingKey` from `MessageCatalog.get` BEFORE invoking `MissingKeyHandler`
|
||||
- [x] 9.5 Emit `keyAdded` from `saveMissing` and HMR paths
|
||||
- [x] 9.6 Emit `pluralRuleAdded` from `PluralizationEngine.registerRule`
|
||||
- [x] 9.7 `events.test.ts` — every topic emits with the documented payload shape
|
||||
|
||||
## 10. List + display-name + number-helper formatters
|
||||
|
||||
- [x] 10.1 Implement `formatList(values, locale, options?)` at `src/formatting/list/list.ts` with cache + Zod options + OTel histogram
|
||||
- [x] 10.2 Implement `formatDisplayName(code, locale, options)` at `src/formatting/display-name/display-name.ts`
|
||||
- [x] 10.3 Implement `formatPercent`, `formatCompact`, `formatUnit` at `src/formatting/number/number-helpers.ts` — pre-bound `formatNumber` calls
|
||||
- [x] 10.4 `list.test.ts`, `display-name.test.ts`, `number-helpers.test.ts` — happy paths + Intl rejection mapping
|
||||
- [x] 10.5 Sibling `.md` for each
|
||||
- [x] 10.6 Subpath exports `./formatting/list` and `./formatting/display-name` in `package.json`
|
||||
|
||||
## 11. Pluralization Intl-first refactor
|
||||
|
||||
- [x] 11.1 Refactor `PluralizationEngine.getRule` to prefer override → `Intl.PluralRules` → hand-written → default
|
||||
- [x] 11.2 Hand-written rules remain registered (they're now overrides for runtimes lacking `Intl.PluralRules`)
|
||||
- [x] 11.3 `pluralization.test.ts` — override wins, Intl used when no override, hand-written used when Intl missing (stub `Intl.PluralRules` throw)
|
||||
|
||||
## 12. Locale-aware first-day-of-week
|
||||
|
||||
- [x] 12.1 Refactor `getDayNames(locale, style?)` to use `new Intl.Locale(locale).weekInfo.firstDay` when available
|
||||
- [x] 12.2 Fall back to Monday-start when `weekInfo` is undefined or throws
|
||||
- [x] 12.3 `date.test.ts` — `en-US` → Sunday, `en-GB` → Monday, fallback path
|
||||
|
||||
## 13. Accept-Language script casing
|
||||
|
||||
- [x] 13.1 Refactor `parseAcceptLanguage` to preserve script title-case + region upper-case + language lower-case
|
||||
- [x] 13.2 `negotiation.test.ts` — `zh-Hant-TW` round-trips with original casing; `EN-US;q=0.9` parses to `en-US`
|
||||
|
||||
## 14. Pseudo-localization
|
||||
|
||||
- [x] 14.1 Implement `pseudoLocalize(text, options?)` at `src/post-processors/pseudo-localize.ts` with three modes
|
||||
- [x] 14.2 Implement `pseudoLocalizationPlugin(options?)` returning an `I18nPlugin` that registers as a post-processor
|
||||
- [x] 14.3 `pseudo-localize.test.ts` — accent, bracket, expand modes; plugin transforms `t()` output
|
||||
|
||||
## 15. Segmenter and collator wrappers
|
||||
|
||||
- [x] 15.1 Implement `formatSegment(text, locale, granularity?)` at `src/locale/segment/segment.ts`
|
||||
- [x] 15.2 Implement `compareLocale(a, b, locale, options?)` at `src/locale/collator/collator.ts` with cache
|
||||
- [x] 15.3 `segment.test.ts`, `collator.test.ts` — non-Latin scripts (Japanese word segmentation, Arabic case-insensitive sort)
|
||||
|
||||
## 16. OTel instrumentation wiring
|
||||
|
||||
- [x] 16.1 Wire `i18n.translate.total{outcome,locale}` increment in `MessageCatalog.get`
|
||||
- [x] 16.2 Wire `i18n.translate.duration_ms{locale}` histogram in `MessageCatalog.get`
|
||||
- [x] 16.3 Wire `i18n.locale.resolve.total{strategy,locale}` increment in `LocaleResolver.resolve` and `negotiateLocale`
|
||||
- [x] 16.4 Add `i18n.interpolation.duration_ms` histogram and emit from `InterpolationEngine.render` and `MessageParser.format`
|
||||
- [x] 16.5 Add `i18n.missing_key.total{locale,namespace}` counter; increment from `MessageCatalog.get` on miss before handler invocation
|
||||
- [x] 16.6 `metrics.spec-conformance.test.ts` — every instrument records a sample under in-memory `MeterProvider`; assert exact attributes
|
||||
|
||||
## 17. Missing-key reporter plugin
|
||||
|
||||
- [x] 17.1 Implement `createMissingKeyReporter(options?)` returning an `I18nPlugin`
|
||||
- [x] 17.2 Default behaviour: log `warn` via `@suaveplan/logger` (peer; no-op when absent)
|
||||
- [x] 17.3 `onMissing(payload)` callback option
|
||||
- [x] 17.4 `persistMissing: true` opt-in routing to `catalog.saveMissing` (requires `i18n-storage-backends` `Backend` with `save`)
|
||||
- [x] 17.5 `missing-key-reporter.test.ts` — warn-by-default, callback, persistMissing path
|
||||
|
||||
## 18. Wiring and exports
|
||||
|
||||
- [x] 18.1 Add `@suaveplan/events workspace:^` and `@suaveplan/logger workspace:^` (peer-optional for the reporter) to `package.json`
|
||||
- [x] 18.2 Update `src/index.ts` to export `createI18n`, `I18nInstance`, `I18nPlugin`, `pseudoLocalize`, `formatList`, `formatDisplayName`, `formatPercent`, `formatCompact`, `formatUnit`, `formatSegment`, `compareLocale`, `createMissingKeyReporter`, `pseudoLocalizationPlugin`
|
||||
- [x] 18.3 Add subpath exports for `./post-processors`, `./formatting/list`, `./formatting/display-name`, `./formatting/number/number-helpers`, `./locale/segment`, `./locale/collator`, `./plugin`
|
||||
- [x] 18.4 Update root `README.md` `## Quick Start` with an `I18nInstance` + plugin example
|
||||
- [x] 18.5 Update `## Modules` table with every new co-located `.md`
|
||||
|
||||
## 19. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 19.1 `bun run check` — Biome 0/0/0 across the monorepo
|
||||
- [x] 19.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n` — green
|
||||
- [x] 19.3 `bunx turbo test:coverage --filter=@suaveplan/i18n` — 100/100/100/100
|
||||
- [x] 19.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n` — exit 0
|
||||
- [x] 19.5 `bunx turbo build --filter=@suaveplan/i18n` — green
|
||||
|
||||
## 20. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 20.1 Generics threaded; `expectTypeOf` assertions on `TranslateOptions['count']` narrowing, `MessageNode` discriminant
|
||||
- [x] 20.2 Zod schemas wired at every public boundary (`TranslateOptionsSchema`, `ListFormatOptionsSchema`, `DisplayNameOptionsSchema`, `PseudoLocalizeOptionsSchema`)
|
||||
- [x] 20.3 New error codes (`NESTING_DEPTH_EXCEEDED`, `PLUGIN_ALREADY_INSTALLED`) thrown from at least one production path
|
||||
- [x] 20.4 Strict-mode tested: cycle in nested refs, plugin duplicate, ICU validate failure
|
||||
- [x] 20.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 20.6 Lifecycle dedup: `changeLanguage` to same locale is a no-op (no event); plugin install / dispose idempotent
|
||||
- [x] 20.7 No retroactive tick — every box reflects work done in this change
|
||||
|
||||
## 21. Archive
|
||||
|
||||
- [x] 21.1 Fold spec delta into `openspec/specs/i18n/spec.md`
|
||||
- [x] 21.2 Move `openspec/changes/i18n-completion/` → `openspec/archive/<yyyy-mm-dd>-i18n-completion/`
|
||||
- [x] 21.3 Refresh `MANIFEST.md` if dep set changed (`@suaveplan/events`, optional `@suaveplan/logger`)
|
||||
Reference in New Issue
Block a user