10 KiB
@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 withescapeHtmlChars,escapeRegex, depth-limit guardsrc/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—MessageParserclass,extractVariables,MessageNodeAST. Source:packages/i18n/i18n/src/parser/message/message.tsverbatim. - ADDED:
src/parser/message-render.ts—renderNodesfunction. Source:packages/i18n/i18n/src/parser/message/message-render.ts. Modified: replaces directformatNumber/formatCurrency/formatDate/formatTimeimports with calls to aFormatBridgeparameter onRenderOptions; provides a no-Intl fallback when bridge is undefined. - ADDED:
src/parser/format-bridge.ts— declares theFormatBridgeinterface (see design doc §5b) plus anoopFormatBridge: FormatBridgethat 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—InterpolationEngineclass. Source:packages/i18n/i18n/src/core/interpolation/interpolation.tsverbatim 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—escapeHtmlCharsstandalone helper. - ADDED:
src/interpolation/escape-regex.ts—escapeRegexstandalone helper. - ADDED:
src/interpolation/index.ts— explicit named re-exports. - ADDED:
src/pluralization/pluralization.ts—PluralizationEngineclass + 9 plural-rule functions:arabicPlural,englishPlural,welshPlural,russianPlural,polishPlural,chinesePlural,japanesePlural,frenchPlural,germanPlural. Source:packages/i18n/i18n/src/core/pluralization/pluralization.tsverbatim. - 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—FormatBridgeinterface +noopFormatBridgeonly (single-import for slim-core wiring)./interpolation— interpolation engine + helpers + types./pluralization—PluralizationEngine+ every plural-rule function + types
Dependencies
@suaveplan/i18n-shared workspace:^— forI18N_CODES,I18nError,DEFAULT_INTERPOLATION@suaveplan/types workspace:^— for shared catalog types if referenced@suaveplan/error workspace:^— fordefineCodesconsumer-side helpers (if any)@opentelemetry/api catalog:observability— for histogram emission inMessageParser.parseandInterpolationEngine.interpolatetslib catalog:buildzod catalog:forms— forInterpolationConfigSchemaboundary validation- No dep on any formatter package, on
@suaveplan/i18ncore, 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—renderNodescallsformatBridge.number/currency/date/time/relativeTimewhen supplied; falls back toString(value)when bridge is undefined; throwsINTERPOLATION_ERRORon missing variablepackages/i18n/i18n-icu-parser/src/parser/format-bridge.test.ts—noopFormatBridgereturns string-coerced values for every methodpackages/i18n/i18n-icu-parser/src/interpolation/interpolation.test.ts— basic substitution, custom delimiters, depth-limit enforcement (NESTING_DEPTH_EXCEEDED), HTML escape on/offpackages/i18n/i18n-icu-parser/src/interpolation/escape-html.test.ts—<,>,&,",'all escapedpackages/i18n/i18n-icu-parser/src/interpolation/escape-regex.test.ts— every regex meta char escapedpackages/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_FOUNDthrown for unknown locale; Welsh's six-form rule coveredpackages/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-sharedunderpackages/i18n/. @suaveplan/i18nslim core (Wave 6) consumes this package and wiresFormatBridgeagainst the formatter packages.- No downstream consumer changes needed — the slim core re-export bridge surfaces every public symbol from this package under the
@suaveplan/i18nroot. MANIFEST.mdandCLAUDE.mdupdated.