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

110 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@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.