# 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` — interpolation values; `count` is auto-injected so plural strings can use `{count}` directly. - `escapeHtml?: boolean` — overrides the engine default for this call. - `formatters?: Record` — 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 — `'welcome'` 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` for React/Vue consumers (where `chunkValues` like `{ bold: (chunks) => {chunks} }` 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` — register hooks, formatters, backends - `dispose?(): void | Promise` — 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("...")` / `` 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 ``, FormatJS `` | 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.