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

16 KiB

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.