Files
2026-05-27 23:29:53 +01:00

10 KiB
Raw Permalink Blame History

@suaveplan/i18n-icu-parser — ICU parser, interpolation, pluralization

Package: @suaveplan/i18n-icu-parser (NEW) Status: PROPOSED Type: feature (new universal-tier package; verbatim port + tiny DI wedge) Date: 2026-05-05 Wave: 2 (depends on Wave 1 foundation; required by Wave 6 core slim) Design doc: openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md (especially §5b) Path: packages/i18n/i18n-icu-parser/

Why

The ICU MessageFormat parser, the {{var}} interpolation engine, and the 9-language plural-rule table currently live in three separate folders inside the @suaveplan/i18n monolith:

  • src/parser/message/ — AST + parser + renderer for ICU {var, number, integer}, {count, plural, one{..} other{..}}, {kind, select, foo{..} other{..}}
  • src/core/interpolation/ — {{varname}} substitution engine with escapeHtmlChars, escapeRegex, depth-limit guard
  • src/core/pluralization/ — arabicPlural, englishPlural, etc. (9 plural-rule functions matching CLDR categories)

These three modules have a single coherent purpose: turn a localized template + values into a final string. They share the MessageNode AST, the depth-limit invariant, the INTERPOLATION_ERROR / PLURAL_RULE_NOT_FOUND / NESTING_DEPTH_EXCEEDED error codes, and the same test fixtures. Splitting them into separate packages would fragment the seam without benefit. Bundling them as @suaveplan/i18n-icu-parser keeps the AST internal to one package and exposes a clean public surface (MessageParser, InterpolationEngine, PluralizationEngine, renderNodes) for the slim core to compose.

The one architectural wedge: today parser/message/message-render.ts imports formatNumber / formatCurrency / formatDate / formatTime directly. That would force the parser to depend on the formatter packages and invert Waves 2 and 3. The fix is a small DI seam: the parser declares a FormatBridge interface; the slim core wires up the bridge with the actual formatter packages in Wave 6. See §5b of the design doc.

What changes

New package: @suaveplan/i18n-icu-parser

  • ADDED: packages/i18n/i18n-icu-parser/ package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
  • ADDED: src/parser/message.ts — MessageParser class, extractVariables, MessageNode AST. Source: packages/i18n/i18n/src/parser/message/message.ts verbatim.
  • ADDED: src/parser/message-render.ts — renderNodes function. Source: packages/i18n/i18n/src/parser/message/message-render.ts. Modified: replaces direct formatNumber / formatCurrency / formatDate / formatTime imports with calls to a FormatBridge parameter on RenderOptions; provides a no-Intl fallback when bridge is undefined.
  • ADDED: src/parser/format-bridge.ts — declares the FormatBridge interface (see design doc §5b) plus a noopFormatBridge: FormatBridge that coerces values to string (used by the parser's own tests; consumers in production wire a real bridge).
  • ADDED: src/parser/message.types.ts — MessageNode, PlainNode, VarNode, FormatNode, PluralNode, SelectNode, RenderOptions, etc.
  • ADDED: src/parser/index.ts — explicit named re-exports.
  • ADDED: src/interpolation/interpolation.ts — InterpolationEngine class. Source: packages/i18n/i18n/src/core/interpolation/interpolation.ts verbatim minus the formatter imports (which were unused at the engine level).
  • ADDED: src/interpolation/interpolation.types.ts — InterpolationConfig, InterpolationOptions, InterpolationResult, InterpolationValues, ValueFormatter.
  • ADDED: src/interpolation/escape-html.ts — escapeHtmlChars standalone helper.
  • ADDED: src/interpolation/escape-regex.ts — escapeRegex standalone helper.
  • ADDED: src/interpolation/index.ts — explicit named re-exports.
  • ADDED: src/pluralization/pluralization.ts — PluralizationEngine class + 9 plural-rule functions: arabicPlural, englishPlural, welshPlural, russianPlural, polishPlural, chinesePlural, japanesePlural, frenchPlural, germanPlural. Source: packages/i18n/i18n/src/core/pluralization/pluralization.ts verbatim.
  • ADDED: src/pluralization/pluralization.types.ts — PluralCategory ("zero" | "one" | "two" | "few" | "many" | "other"), PluralRuleFunction, PluralizationConfig.
  • ADDED: src/pluralization/index.ts — explicit named re-exports.
  • ADDED: src/index.ts — explicit named re-exports of the three sub-namespaces.

Subpath exports

  • . — root barrel
  • ./parser — parser + render + format-bridge + types
  • ./parser/format-bridge — FormatBridge interface + noopFormatBridge only (single-import for slim-core wiring)
  • ./interpolation — interpolation engine + helpers + types
  • ./pluralization — PluralizationEngine + every plural-rule function + types

Dependencies

  • @suaveplan/i18n-shared workspace:^ — for I18N_CODES, I18nError, DEFAULT_INTERPOLATION
  • @suaveplan/types workspace:^ — for shared catalog types if referenced
  • @suaveplan/error workspace:^ — for defineCodes consumer-side helpers (if any)
  • @opentelemetry/api catalog:observability — for histogram emission in MessageParser.parse and InterpolationEngine.interpolate
  • tslib catalog:build
  • zod catalog:forms — for InterpolationConfigSchema boundary validation
  • No dep on any formatter package, on @suaveplan/i18n core, or on @suaveplan/i18n-detector.

Out of scope

  • Removing parser / interpolation / pluralization from packages/i18n/i18n/src/ — happens in Wave 6 (i18n-core-slim). For Waves 2–5, both copies coexist; the monolith remains green.
  • Backwards-compatible bridge implementation in the slim core — that lives in Wave 6's change folder.
  • CLDR data import for additional plural rules beyond the existing 9 — out of scope; new rules can be added in a follow-up.
  • Switching from hand-rolled MessageFormat parser to intl-messageformat — explicit non-goal; the existing parser passes ICU conformance tests and avoids the 39 KiB external dep.
  • Async interpolation — current engine is synchronous; async (e.g. for Promise-returning value resolvers) is a follow-up.

Source mapping

Requirement Source file Source lines Notes
MessageParser, extractVariables, MessageNode AST packages/i18n/i18n/src/parser/message/message.ts full file Verbatim port
renderNodes packages/i18n/i18n/src/parser/message/message-render.ts full file Port; replace direct formatter imports with FormatBridge parameter
MessageNode, PlainNode, VarNode, FormatNode, PluralNode, SelectNode, RenderOptions types packages/i18n/i18n/src/parser/message/message.types.ts full file Verbatim port
InterpolationEngine packages/i18n/i18n/src/core/interpolation/interpolation.ts full file minus formatter imports Verbatim port
escapeHtmlChars, escapeRegex packages/i18n/i18n/src/core/interpolation/interpolation.ts matching exports Split into per-helper files for tree-shaking
InterpolationConfig, InterpolationOptions, InterpolationResult, InterpolationValues, ValueFormatter types packages/i18n/i18n/src/core/interpolation/interpolation.types.ts full file Verbatim port
PluralizationEngine + 9 plural-rule functions packages/i18n/i18n/src/core/pluralization/pluralization.ts full file Verbatim port
PluralCategory, PluralRuleFunction, PluralizationConfig types packages/i18n/i18n/src/core/pluralization/pluralization.types.ts full file Verbatim port

Requirements with no source line

Requirement Justification
FormatBridge interface New DI seam — see design doc §5b. Required so the parser package does not depend on formatter packages
noopFormatBridge: FormatBridge Tests need a no-op bridge; consumers without Intl wrappers also benefit
Subpath export ./parser/format-bridge Slim-core wiring imports only this one file when constructing the default bridge

Verification approach

Each requirement carries a **Verified by:** line in the spec delta. Test files:

  • packages/i18n/i18n-icu-parser/src/parser/message.test.ts — every ICU node type round-trips through parse + render; conformance with the existing monolith's parser test suite (verbatim test port)
  • packages/i18n/i18n-icu-parser/src/parser/message-render.test.ts — renderNodes calls formatBridge.number/currency/date/time/relativeTime when supplied; falls back to String(value) when bridge is undefined; throws INTERPOLATION_ERROR on missing variable
  • packages/i18n/i18n-icu-parser/src/parser/format-bridge.test.ts — noopFormatBridge returns string-coerced values for every method
  • packages/i18n/i18n-icu-parser/src/interpolation/interpolation.test.ts — basic substitution, custom delimiters, depth-limit enforcement (NESTING_DEPTH_EXCEEDED), HTML escape on/off
  • packages/i18n/i18n-icu-parser/src/interpolation/escape-html.test.ts — <, >, &, ", ' all escaped
  • packages/i18n/i18n-icu-parser/src/interpolation/escape-regex.test.ts — every regex meta char escaped
  • packages/i18n/i18n-icu-parser/src/pluralization/pluralization.test.ts — every one of the 9 rule functions returns the correct CLDR category for boundary values; PLURAL_RULE_NOT_FOUND thrown for unknown locale; Welsh's six-form rule covered
  • packages/i18n/i18n-icu-parser/src/index.test.ts — every documented export resolves through the root barrel
  • Conformance test pass rate on the existing packages/i18n/i18n/src/conformance.test.ts — must remain 100% after Wave 6 wires the bridge through

Impact

  • New universal-tier package — joins @suaveplan/i18n-shared under packages/i18n/.
  • @suaveplan/i18n slim core (Wave 6) consumes this package and wires FormatBridge against the formatter packages.
  • No downstream consumer changes needed — the slim core re-export bridge surfaces every public symbol from this package under the @suaveplan/i18n root.
  • MANIFEST.md and CLAUDE.md updated.