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, runsPluralizationEngine.resolveand 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 upgreeting_malefirst, falling back togreeting.defaultValue: string— fallback when key is missing, in lieu of returning the key.values: Record<string, unknown>— interpolation values;countis 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 ofcommon:cancelrecursively; cycle detection via depth limit.
- ADDED:
TranslateOptionsandPluralKeytypes insrc/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)ist(key, {}).
2. Full ICU MessageFormat support
The current MessageParser handles {name}, {count, plural, ...}, {gender, select, ...}. Real ICU (and FormatJS) callers expect more.
- ADDED:
selectordinalnode type —{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}. Routes throughPluralizationEnginewithordinal: 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 throughformatDate/formatNumber/formatTimefrom@suaveplan/i18n/formatting/*. - ADDED: apostrophe quoting —
it''srenders asit'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 aTagNodeAST;MessageParser.format(message, values)acceptsvalues: { bold: (chunks) => string }to produce a string, andMessageParser.formatChunks(message, values, chunkValues)returnsArray<string | T>for React/Vue consumers (wherechunkValueslike{ bold: (chunks) => <b>{chunks}</b> }produce framework nodes — used heavily byenterprise-react-i18n). - ADDED:
MessageNodediscriminated union expands:text | variable | plural | selectordinal | select | tag | typed-argument. - ADDED: ICU validator —
MessageParser.validate(message): ValidationResultreports unbalanced braces, missingotherbranches, 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:
I18nPlugininterface insrc/core/plugin/plugin.types.ts:name: string,version?: stringinstall?(i18n: I18nInstance): void | Promise<void>— register hooks, formatters, backendsdispose?(): 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 togetherMessageCatalog,InterpolationEngine,PluralizationEngine,MessageParser,LocaleResolver, and the plugin registry. Created viacreateI18n(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.
I18nInstanceacceptspostProcessors: PostProcessor[]where each is(value, key, options) => string. Built-in post-processors:markdown(renders bold/italic/links viamarkedpeer),sprintf(printf-style format strings — opt-in i18next compatibility),pseudo-localization(transformsWelcome→[!! 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. BrowserLocaleResolvercomposes them viadetectionOrder. Server-sidecreateLocaleMiddlewarealready 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?)— wrapsIntl.ListFormat. Options:style: 'long' | 'short' | 'narrow',type: 'conjunction' | 'disjunction' | 'unit'. Per-(locale, options) cache like the other formatters. - ADDED:
formatDisplayName(code, locale, options)— wrapsIntl.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-boundformatNumber({ style: 'percent' }). - ADDED:
formatCompact(value, locale, options?)— pre-boundformatNumber({ notation: 'compact' })for "1.2K" / "1.2M" output. - ADDED:
formatUnit(value, unit, locale, options?)— pre-boundformatNumber({ style: 'unit', unit })for "12 km/h" output.
5. Locale + plural correctness fixes
- MODIFIED:
parseAcceptLanguagepreserves script-subtag casing (zh-Hant-TW, notzh-hant-tw) per BCP-47. The current implementation lowercases the entire tag. - MODIFIED:
PluralizationEnginedefaults toIntl.PluralRulesfirst, falls back to the hand-written rule registry. The hand-written rules drift against CLDR over time; makingIntl.PluralRulesthe primary source future-proofs against CLDR releases. Hand-written rules remain registered as overrides for runtimes that lackIntl.PluralRules(e.g. very old browsers — vanishingly rare in genesis, but the override path costs nothing). - ADDED:
Intl.Locale.weekInfo-awaregetDayNames(locale, style?)— first-day-of-week is consulted vianew Intl.Locale(locale).weekInfo.firstDay(1 = Monday, 7 = Sunday). Falls back to Monday whenweekInfois unavailable. Existing Monday-hardcoded behaviour was a bug for US/Mid-East locales. - ADDED:
formatSegment(text, locale, granularity?)andcompareLocale(a, b, locale, options?)— thin wrappers aroundIntl.SegmenterandIntl.Collatorfor non-Latin search/sort. - ADDED:
pseudoLocalize(text, options?)post-processor with three modes:accent(Welcome→Wéłçömé),bracket(Welcome→[!! Welcome !!]),expand(Welcome →Welcomeeeeeto test layout). Used by thepseudo-localizationpost-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.getincrementsi18n.translate.total{outcome,locale}on every call and recordsi18n.translate.duration_ms{locale}for every lookup. - MODIFIED:
LocaleResolver.resolveandnegotiateLocaleincrementi18n.locale.resolve.total{strategy,locale}. - ADDED:
i18n.interpolation.duration_mshistogram onInterpolationEngine.renderandMessageParser.format. - ADDED:
i18n.missing_key.totalcounter labelled bylocale,namespace— incremented fromMessageCatalog.getwhenever the lookup falls through to the missing-key handler or returnsundefined.
7. Workflow tooling
- ADDED: missing-key reporter plugin —
createMissingKeyReporter({ logger?, onMissing? })registers as anI18nPluginand listens on themissingKeyevent. Default behaviour: log via@suaveplan/loggeratwarnlevel. With a backend configured, it can also callcatalog.saveMissing(gated bysaveMissing: true). - ADDED:
extractKeys(source: string, options?)— minimal AST-free extractor that walks a source string fort('...')/t("...")/<Trans i18nKey="...">patterns and returns the deduplicated key list. Not a full TypeScript parser; intended for the simple case and used by the optionalextract-i18nCLI ini18n-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-toolingchange once the runtime surface stabilises. - PO / XLIFF / Android XML / iOS .strings round-trip — the
MultiFileFsBackend(ini18n-storage-backends) already supports PO read via thegettext-parserpeer; 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— ergonomict()with count, context, defaultValue, nested refspackages/i18n/i18n/src/parser/message/message.test.ts(extended) — selectordinal, typed args, apostrophe escape, tag nodespackages/i18n/i18n/src/parser/message/message-validate.test.ts—MessageParser.validatehappy/sad pathspackages/i18n/i18n/src/core/plugin/plugin.test.ts—I18nPluginlifecycle, event bus topics, post-processor / detector compositionpackages/i18n/i18n/src/formatting/list/list.test.ts,display-name/display-name.test.ts,number/number-helpers.test.tspackages/i18n/i18n/src/locale/segment/segment.test.ts,collator/collator.test.tspackages/i18n/i18n/src/observability/metrics.spec-conformance.test.ts— every instrument records a sample under in-memoryMeterProvider
Migration impact
MessageCatalog.t(key)(single-arg) — backwards-compatible. Existing call sites continue to work.MessageCatalog.t(key, options)(new) — additive.parseAcceptLanguagecasing fix — observable change for consumers that string-compared the result. No genesis consumers depend on the lowercased tag (verified by grep).PluralizationEnginedefaulting toIntl.PluralRules— observable for locales where the hand-written rule disagreed with CLDR. Tests pinningIntl.PluralRulesoutput are added; the override registry path remains available for explicit overrides.MessageParserapostrophe handling — observable bug fix:it''sformerly rendered asit''sand now renders asit'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.