110 lines
10 KiB
Markdown
110 lines
10 KiB
Markdown
# `@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.
|