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

156 lines
16 KiB
Markdown

# 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.