first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
# @suaveplan/i18n-icu-parser - ICU MessageFormat parser, {{var}} interpolation engine, and 9-language CLDR pluralization for the @suaveplan/i18n family. Universal leaf package — no formatter, backend, or core dependencies. Renders ICU placeholders through a FormatBridge DI seam so the package stays free of Intl wrappers.
## Summary
ICU MessageFormat parser, `{{var}}` interpolation engine, and
9-language CLDR pluralization for the `@suaveplan/i18n` family.
Universal leaf package — depends only on
`@suaveplan/i18n-shared`, `@suaveplan/error`, `@suaveplan/types`,
and the OTel API. No formatter, backend, detector, or core
dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this exists
The 14-package i18n modular split needs one home for the three
pure-text-processing concerns that share the `MessageNode` AST,
the `INTERPOLATION_ERROR` / `PLURAL_RULE_NOT_FOUND` /
`NESTING_DEPTH_EXCEEDED` error codes, and the same renderer
contract: ICU parsing, `{{var}}` interpolation, and CLDR
pluralization. Splitting them further would fragment the seam
without benefit; bundling them as `@suaveplan/i18n-icu-parser`
keeps the AST internal and exposes a clean public surface
(`MessageParser`, `InterpolationEngine`, `PluralizationEngine`,
`renderNodes`) for the slim core to compose.
## The FormatBridge seam
ICU format placeholders (`{value, number}`, `{amount, number,
currency=USD}`, `{date, date, short}`, `{ts, time, long}`,
`{delta, relativeTime}`) need locale-aware output. To stay free
of the per-formatter packages, the renderer accepts a
`FormatBridge` via `RenderOptions.formatBridge`. When the bridge
(or a specific method on it) is undefined, the renderer falls
back to `String(value)` and never throws — the parser remains
fully usable in isolation, with no `Intl.*` runtime cost.
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
bridge by importing the formatter packages and threads it through
`createI18n({ ... })` so consumers who use the high-level façade
get full ICU output with zero wiring.
```typescript
import {
MessageParser,
type FormatBridge,
} from "@suaveplan/i18n-icu-parser";
const bridge: FormatBridge = {
number: (v, locale) => new Intl.NumberFormat(locale).format(v),
currency: (v, currency, locale) =>
new Intl.NumberFormat(locale, { style: "currency", currency }).format(v),
};
const parser = new MessageParser();
parser.format(
"{amount, number, currency/USD} on {when, date, short}",
{ amount: 1234.5, when: new Date() },
{ locale: "en-US", formatBridge: bridge }
);
```
## Install
```bash
bun add @suaveplan/i18n-icu-parser
```
## Quick Start
```typescript
import {
InterpolationEngine,
MessageParser,
PluralizationEngine,
} from "@suaveplan/i18n-icu-parser";
const parser = new MessageParser();
parser.format("{count, plural, one {1 item} other {# items}}", { count: 3 });
// "3 items"
const engine = new InterpolationEngine({ escapeHtml: true });
engine.render("hello {{ name }}", { name: "Ada" });
// { text: "hello Ada", missingVariables: [] }
const plural = new PluralizationEngine();
plural.resolve(0, { zero: "none", one: "one", other: "many" }, { locale: "ar" });
// { category: "zero", message: "none", count: 0 }
```
## Modules
| Subpath | Description |
|---|---|
| `@suaveplan/i18n-icu-parser` | Root barrel — every public symbol |
| `@suaveplan/i18n-icu-parser/parser` | `MessageParser`, `renderNodes`, `extractVariables`, `FormatBridge`, `noopFormatBridge`, AST types |
| `@suaveplan/i18n-icu-parser/parser/format-bridge` | `FormatBridge` interface + `noopFormatBridge` ONLY (single-file import for slim-core wiring) |
| `@suaveplan/i18n-icu-parser/interpolation` | `InterpolationEngine`, `escapeHtmlChars`, `escapeRegex`, `InterpolationConfigSchema`, types |
| `@suaveplan/i18n-icu-parser/pluralization` | `PluralizationEngine`, the 9 plural-rule functions, `defaultPluralRule`, `normalizeLocale`, `getBaseLocale`, types |
## Modules Documentation
- [parser/format-bridge.md](src/parser/format-bridge.md) — DI seam between this package and the formatter packages
- [parser/message.md](src/parser/message.md) — `MessageParser` class
- [parser/message-render.md](src/parser/message-render.md) — `renderNodes` / `extractVariables` standalone helpers
- [interpolation/interpolation.md](src/interpolation/interpolation.md) — `InterpolationEngine`
- [interpolation/escape-html.md](src/interpolation/escape-html.md) — `escapeHtmlChars` standalone helper
- [interpolation/escape-regex.md](src/interpolation/escape-regex.md) — `escapeRegex` standalone helper
- [pluralization/pluralization.md](src/pluralization/pluralization.md) — `PluralizationEngine` + 9 plural-rule fns
- [observability/metrics.md](src/observability/metrics.md) — OTel histogram recorders
## Public Surface (snapshot)
| Export | Kind | Origin |
|---|---|---|
| `MessageParser` | class | `parser/message.ts` |
| `extractVariables` | function | `parser/message-render.ts` |
| `renderNodes` | function | `parser/message-render.ts` |
| `renderToChunks` | function | `parser/message-render.ts` |
| `preprocessIcuApostrophes` | function | `parser/message.ts` |
| `findUnknownArgTypes` | function | `parser/message.ts` |
| `FormatBridge` | type | `parser/format-bridge.ts` |
| `noopFormatBridge` | const | `parser/format-bridge.ts` |
| `MessageNode`, `ParsedMessage`, `ParserOptions`, `RenderOptions`, `RenderContext`, `ValidationIssue`, `ValidationResult` | types | `parser/*.types.ts` |
| `InterpolationEngine` | class | `interpolation/interpolation.ts` |
| `escapeHtmlChars` | function | `interpolation/escape-html.ts` |
| `escapeRegex` | function | `interpolation/escape-regex.ts` |
| `InterpolationConfigSchema` | const | `interpolation/interpolation.schemas.ts` |
| `PluralizationEngine` | class | `pluralization/pluralization.ts` |
| `arabicPlural` / `chinesePlural` / `englishPlural` / `frenchPlural` / `germanPlural` / `japanesePlural` / `polishPlural` / `russianPlural` / `welshPlural` / `spanishPlural` / `defaultPluralRule` | functions | `pluralization/pluralization.ts` |
| `recordParseDuration` / `recordInterpolationDuration` / `recordPluralDuration` | functions | `observability/metrics.ts` |
## Versioning
Part of the i18n modular-split rollout. Stays at `0.1.0` until
the slim core (Wave 6) cuts the family `1.0.0` release. Test
titles in `parser/message.test.ts`, `interpolation/interpolation.test.ts`,
and `pluralization/pluralization.test.ts` mirror the pre-split
monolith one-for-one so the core's conformance suite re-runs
unchanged against the bridged surface.
## Wave 2 Scope
Pure addition. `packages/i18n/i18n/src/` retains its copies of
the parser, interpolation engine, and pluralization sources; both
copies coexist during Waves 2–5 and the monolith remains green.
The slim core (Wave 6) deletes the monolith copies and re-exports
from this package.

View File

@@ -0,0 +1,96 @@
# escape-html
## Purpose
`escapeHtmlChars` converts the five HTML special characters
(`&`, `<`, `>`, `"`, `'`) to their numeric / named entity
equivalents so a string can be safely concatenated into an HTML
context without opening an XSS vector. The helper is the single
source of truth for HTML escaping inside this package — the
{@link InterpolationEngine} calls it when `escapeHtml: true` is
configured, and downstream consumers can call it directly when
they need to sanitise an already-rendered string before inserting
into `innerHTML`, an attribute value, or a `<script type="application/json">`
block.
The helper is extracted into its own file so consumers who only
need the escape function can tree-shake the rest of the
interpolation engine. This is particularly useful in
size-sensitive bundles (mobile web, edge workers) that ship a
prerendered translation catalog and never need the full
interpolation surface.
## Features
- Escapes the canonical OWASP-recommended five characters
- Order-stable replacement (`&` first, so subsequent
`&lt;`/`&gt;` entities don't get double-escaped)
- No HTML allow-list — escapes EVERY occurrence of every char
- Pure function: no I/O, no allocation beyond the output string,
no observable side effects
- Zero dependencies — safe to inline in edge / worker contexts
## Usage: Basic Example
```typescript
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
const safe = escapeHtmlChars("<b>bold</b>");
// "&lt;b&gt;bold&lt;/b&gt;"
```
## Usage: Advanced Example
```typescript
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
// Sanitise user input before inserting into a template literal
function tooltipHtml(label: string): string {
return `<span class="tooltip" title="${escapeHtmlChars(label)}">…</span>`;
}
console.log(tooltipHtml(`I'm "the boss" & <admin>`));
// '<span class="tooltip" title="I&#039;m &quot;the boss&quot; &amp; &lt;admin&gt;">…</span>'
// Combine with InterpolationEngine for catalog values
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine({ escapeHtml: true });
// Internally calls escapeHtmlChars on every interpolated value.
```
## API Reference
### `escapeHtmlChars(str: string): string`
Escape every occurrence of `&`, `<`, `>`, `"`, `'` in `str` to
its HTML entity equivalent.
| Input char | Output |
|---|---|
| `&` | `&amp;` |
| `<` | `&lt;` |
| `>` | `&gt;` |
| `"` | `&quot;` |
| `'` | `&#039;` |
**Returns:** the escaped string. Strings without any of the five
characters return unchanged.
## Implementation Notes
- The replacements run as five sequential `String.prototype.replace`
calls. This is faster than a single regex with a callback for
realistic translation-string sizes (10-200 characters), which
is the dominant case for i18n.
- `&` is escaped first so that the `&` in subsequent entity
outputs (`&lt;`, `&gt;`, `&quot;`, `&#039;`) is not re-escaped
on a second pass. The rule is invariant: every entity starts
with `&`, and that `&` was already produced by the helper, so
it is intentionally not re-escaped.
- The single-quote escape uses the numeric entity `&#039;` rather
than the named entity `&apos;` because the latter is not part
of the HTML 4 entity set and is unsafe in attribute contexts
served as `text/html` to legacy browsers.
- The function is `O(n)` in the input length and produces at
most `5n` output characters (worst case: a string of only `'`
characters expands 5x to `&#039;` per char).

View File

@@ -0,0 +1,85 @@
# escape-regex
## Purpose
`escapeRegex` escapes every regular-expression metacharacter in a
string so the string can be embedded literally inside a `RegExp`
constructor. The {@link InterpolationEngine} uses it to compile
placeholder patterns from caller-supplied delimiters (`{{`,
`%{`, `<%`, …) into a working RegExp without inadvertently
treating delimiter characters as regex syntax. Exposing it as a
standalone export lets downstream consumers build their own
delimiter-aware patterns or sanitise raw search queries.
The helper lives in its own file (alongside `escape-html`) so
tree-shaking can strip the rest of the interpolation engine when
a consumer needs only the escape primitive — for example, a
documentation tooling pipeline that compiles user-typed search
patterns into a forgiving regex.
## Features
- Escapes the canonical 12 ECMAScript regex metacharacters
(`. * + ? ^ $ { } ( ) | [ ] \`) plus the closing brace
- Pure function: no I/O, no state, deterministic per input
- Single-pass `String.prototype.replace` with a precompiled
regex literal — no allocation beyond the output string
- Zero dependencies — drops cleanly into edge / worker bundles
## Usage: Basic Example
```typescript
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
const safe = escapeRegex("a.b*c");
// "a\\.b\\*c"
const re = new RegExp(safe);
re.test("a.b*c"); // true (literal match)
```
## Usage: Advanced Example
```typescript
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
// Build a placeholder pattern from caller-supplied delimiters.
function buildPlaceholderPattern(open: string, close: string): RegExp {
const o = escapeRegex(open);
const c = escapeRegex(close);
return new RegExp(`${o}\\s*([^${c}]+)\\s*${c}`, "g");
}
const re = buildPlaceholderPattern("<%=", "%>");
const matches = "Hello <%= name %>".matchAll(re);
for (const m of matches) console.log(m[1]?.trim()); // "name"
```
## API Reference
### `escapeRegex(str: string): string`
Escape every regular-expression metacharacter in `str`. The
escaped output can be inserted unchanged into a `RegExp` source
string and will match the original character literally.
Escaped characters: `.`, `*`, `+`, `?`, `^`, `$`, `{`, `}`,
`(`, `)`, `|`, `[`, `]`, `\`.
**Returns:** the escaped string. Strings without metacharacters
return unchanged.
## Implementation Notes
- The replacement uses `\\$&` (backref to the whole match)
rather than enumerating every escape sequence, which keeps the
function bytes-small and easy to audit.
- The character class `[.*+?^${}()|[\\]\\\\]` is the canonical
V8 / SpiderMonkey escape set for the standard `RegExp`
grammar. Alternation operators inside character classes (`-`)
do NOT need escaping at the outer level and are intentionally
omitted.
- The function is `O(n)` in input length. Worst case is a string
of only metacharacters which expands 2x in the output.
- This is the canonical regex escape across Suaveplan
— every package that needs to embed user input in a RegExp
consumes this helper or its inlined twin in the parser file.

View File

@@ -0,0 +1,111 @@
# InterpolationEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InterpolationEngine` replaces placeholder tokens (default: `{{ name }}`) in message template strings with caller-supplied values. It is the rendering workhorse behind `MessageCatalog.t()` and standalone template usage. The engine is designed to be instantiated once and reused across many render calls, with all configuration captured in the constructor.
The engine handles the common i18n case of named variable substitution, and additionally supports pluggable per-slot value formatters (e.g., number, date, currency) via a named formatter registry. Values can be HTML-escaped on output to prevent XSS when templates are rendered into HTML.
## Features
- Named placeholder substitution: `{{ name }}`, `{{ count, number }}`, etc.
- Custom delimiter pairs (e.g., `%{` / `}` for Ruby-style templates)
- Per-slot `ValueFormatter` registry — applied when a placeholder contains a format name
- Optional `defaultFormatter` called for all slots without an explicit formatter
- HTML escaping of rendered values (`escapeHtml: true`) via `escapeHtmlChars`
- Recursive interpolation with configurable depth limit (`maxDepth`)
- `missingVariables` tracking in the result — non-throwing for production resilience
## Usage: Basic Example
```typescript
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine();
const { text } = engine.render(
"Hello, {{ name }}! You have {{ count }} messages.",
{ name: "Alice", count: 3 }
);
// text === "Hello, Alice! You have 3 messages."
```
## Usage: Advanced Example
```typescript
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
const engine = new InterpolationEngine({
escapeHtml: true,
formatters: {
upper: (v) => String(v).toUpperCase(),
number: (v, locale) =>
new Intl.NumberFormat(locale).format(Number(v)),
},
});
const { text, missingVariables } = engine.render(
"Welcome, {{ user, upper }}! Balance: {{ amount, number }}.",
{ user: "alice", amount: 1234567 },
"en-US"
);
// text === "Welcome, ALICE! Balance: 1,234,567."
// missingVariables === []
// HTML escaping
const { text: safe } = engine.render(
"Comment: {{ comment }}",
{ comment: "<script>alert('xss')</script>" }
);
// safe === "Comment: &lt;script&gt;alert(&#039;xss&#039;)&lt;/script&gt;"
```
## API Reference
### `new InterpolationEngine(options?: InterpolationOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `prefix` | `string` | `"{{"` | Placeholder open delimiter |
| `suffix` | `string` | `"}}"` | Placeholder close delimiter |
| `escapeHtml` | `boolean` | `false` | HTML-escape rendered values |
| `maxDepth` | `number` | `10` | Maximum recursion depth |
| `formatters` | `Record<string, ValueFormatter>?` | `{}` | Named formatter map |
| `defaultFormatter` | `ValueFormatter?` | — | Applied when no named formatter matches |
### `.render(template, values?, locale?): InterpolationResult`
Renders `template` replacing all placeholders. Returns `{ text: string; missingVariables: string[] }`.
### `.addFormatter(name, formatter): void`
Registers or replaces a named formatter at runtime.
### `.removeFormatter(name): boolean`
Removes a named formatter; returns `true` if it existed.
### `.hasPlaceholders(template): boolean`
Returns `true` if the template contains at least one placeholder.
### `.extractVariables(template): string[]`
Returns all unique variable names referenced in the template.
### `escapeHtmlChars(str: string): string`
Standalone export. Escapes `&`, `<`, `>`, `"`, `'`.
### `escapeRegex(str: string): string`
Standalone export. Escapes all regex metacharacters for safe use in `RegExp`.
## Implementation Notes
- The placeholder regex is rebuilt on each call to `replaceValues` rather than cached, because `options.prefix` / `suffix` could theoretically contain characters whose escaped forms would produce a different pattern — rebuilding is correct and the cost is negligible for the expected template sizes
- Recursive interpolation is capped at `maxDepth` (default 10) to prevent infinite loops when a value itself contains placeholder syntax
- HTML escaping is applied _after_ formatting so that formatter output (e.g., `"$1,234"`) is escaped as a unit, not before
- `missingVariables` accumulates in-place via the closure; the engine never throws on a missing variable — it leaves the placeholder in place and records the name

View File

@@ -0,0 +1,97 @@
# observability/metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single home for the three OTel histograms emitted
from the hot paths of the `@suaveplan/i18n-icu-parser` package:
`i18n.parse.duration_ms`, `i18n.interpolation.duration_ms`, and
`i18n.plural.duration_ms`. Centralising the metric construction here
keeps the production code paths free of meter boilerplate and makes
it trivial to audit (or relabel) every emission point in the
package.
The module wraps `@opentelemetry/api` so that consumers without an
SDK installed observe a no-op meter — there is no runtime cost when
no SDK is present, and no leaf package or test fixture has to opt
out of telemetry to avoid stray exports.
## Features
- Three OTel histograms with documented `description` and `unit`
fields so dashboards can render them without per-app config
- Bounded attribute cardinality: every recorder enforces a small
fixed set of attributes (`icu_syntax`, `escape_html`, `locale`,
`ordinal`) so metric pipelines do not blow up on unbounded
cardinality
- Single shared meter named `@suaveplan/i18n-icu-parser` so log
sinks can group every package's emissions without parsing
histogram names
- No-op fallback when no OTel SDK is wired (the upstream API
returns no-op meters) — production-safe with zero deps
## Usage: Basic Example
```typescript
import {
recordInterpolationDuration,
recordParseDuration,
recordPluralDuration,
} from "@suaveplan/i18n-icu-parser";
const t0 = performance.now();
parser.parse(template);
recordParseDuration(performance.now() - t0, true);
```
## Usage: Advanced Example
```typescript
import { metrics } from "@opentelemetry/api";
import { recordInterpolationDuration } from "@suaveplan/i18n-icu-parser";
// Wire any SDK (NodeSDK, BrowserSDK, etc.) — the histograms below
// flow through automatically because the package shares the
// global meter provider.
const provider = metrics.getMeterProvider();
// Inside a render loop:
for (const tpl of templates) {
const t0 = performance.now();
engine.render(tpl, values);
recordInterpolationDuration(performance.now() - t0, false);
}
```
## API Reference
### `recordParseDuration(durationMs: number, icuSyntax: boolean): void`
Record a sample on the `i18n.parse.duration_ms` histogram.
Attribute set: `{ "i18n.parse.icu_syntax": boolean }`.
### `recordInterpolationDuration(durationMs: number, escapeHtml: boolean): void`
Record a sample on the `i18n.interpolation.duration_ms` histogram.
Attribute set: `{ "i18n.interpolation.escape_html": boolean }`.
### `recordPluralDuration(durationMs: number, locale: string, ordinal: boolean): void`
Record a sample on the `i18n.plural.duration_ms` histogram.
Attribute set: `{ "i18n.plural.locale": string, "i18n.plural.ordinal": boolean }`.
## Implementation Notes
- The meter is created once at module-load and shared across every
recording path. OTel meters are designed to be long-lived; the
module never re-creates them.
- Attribute names use the `i18n.` prefix so consumers' dashboards
can group by package without inspecting the histogram name.
- The `locale` attribute is the **normalised** base locale (e.g.
`"en"` not `"en-GB"`) — call sites pre-normalise so cardinality
stays bounded by the CLDR set.
- No counters, gauges, or span events are emitted from this module
— only histograms. Span emission would require an active tracer;
the parser stays pure to keep that surface clean.

View File

@@ -0,0 +1,142 @@
# FormatBridge
## Purpose
`FormatBridge` is the dependency-injection seam that lets
`@suaveplan/i18n-icu-parser` render ICU format placeholders
(`{value, number}`, `{amount, number, currency=USD}`,
`{date, date, short}`, `{ts, time, long}`,
`{delta, relativeTime}`) without statically depending on any
formatter package. The renderer (`renderNodes`) consults whichever
methods are present on the bridge and falls back to
`String(value)` when a method (or the bridge itself) is undefined.
This keeps the parser package a true universal leaf with no
`Intl.*` runtime cost when the consumer doesn't need locale-aware
formatting.
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
bridge by importing the formatter packages
(`@suaveplan/i18n-formatter-number`, `-date`, `-currency`,
`-relative-time`) and threads it through every render call so
consumers who use `createI18n({ ... })` get full ICU output with
zero wiring. Consumers who construct a `MessageParser` directly
MAY pass their own bridge or accept the no-Intl fallback.
## Features
- Five method slots, every one OPTIONAL: `number`, `currency`,
`date`, `time`, `relativeTime`
- Renderer never throws on missing bridge or missing method —
always falls back to `String(value)` (or
`${currency} ${String(value)}` for currency)
- Style enums (`NumberFormatStyle`, `DateStyle`,
`RelativeTimeStyle`) declared in `format-bridge.types.ts` for
type-only imports without runtime cost
- `noopFormatBridge` exported as a frozen singleton consumers can
use directly or spread-and-extend
- Single-file subpath export (`@suaveplan/i18n-icu-parser/parser/format-bridge`)
so the slim core's wiring imports only this one module — keeps
bundle size minimal
## Usage: Basic Example
```typescript
import { renderNodes, type FormatBridge } from "@suaveplan/i18n-icu-parser/parser";
const bridge: FormatBridge = {
number: (value, locale) =>
new Intl.NumberFormat(locale).format(value),
};
const nodes = [
{ type: "text" as const, value: "Total: " },
{
type: "typed-argument" as const,
name: "amount",
argType: "number" as const,
style: "decimal",
},
];
const text = renderNodes(nodes, { amount: 1234.5 }, {
locale: "en-US",
formatBridge: bridge,
});
// "Total: 1,234.5"
```
## Usage: Advanced Example
```typescript
import {
MessageParser,
noopFormatBridge,
type FormatBridge,
} from "@suaveplan/i18n-icu-parser/parser";
// Spread-and-extend pattern: keep the no-op fallback for anything
// you don't care about, override just the methods you need.
const bridge: FormatBridge = {
...noopFormatBridge,
currency: (value, currency, locale) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(value),
date: (value, locale, style = "medium") => {
const d = value instanceof Date ? value : new Date(value);
const presets: Record<string, Intl.DateTimeFormatOptions> = {
short: { dateStyle: "short" },
medium: { dateStyle: "medium" },
long: { dateStyle: "long" },
full: { dateStyle: "full" },
};
return new Intl.DateTimeFormat(locale, presets[style]).format(d);
},
};
const parser = new MessageParser();
const out = parser.format(
"Total: {amount, number, currency/USD} on {when, date, long}",
{ amount: 1234.5, when: new Date("2026-05-05T00:00:00Z") },
{ locale: "en-US", formatBridge: bridge }
);
```
## API Reference
### `interface FormatBridge`
| Method | Signature | Notes |
|---|---|---|
| `number?` | `(value, locale, style?) => string` | Style is `NumberFormatStyle \| string \| undefined` so notation styles (`compact`, `scientific`) pass through as raw strings |
| `currency?` | `(value, currency, locale) => string` | `currency` is an ISO 4217 code |
| `date?` | `(value, locale, style?) => string` | Default style is `medium` |
| `time?` | `(value, locale, longForm?) => string` | `longForm: true` should include seconds |
| `relativeTime?` | `(value, locale, style?) => string` | Style is `"long" \| "short" \| "narrow"` |
### `noopFormatBridge: Readonly<FormatBridge>`
Frozen identity bridge. Every method coerces inputs to
`String(value)` (or `${currency} ${String(value)}` for
`currency`). Safe to share by reference; mutation throws TypeError.
## Implementation Notes
- The bridge is intentionally OPTIONAL across the entire surface
area. The renderer never asserts `formatBridge != null`; it
uses optional chaining (`bridge?.number?.(...)`) and falls
through to `String(value)` on undefined results. This means a
partial bridge (only `number` defined) still renders date and
currency placeholders — they just emit raw `String(value)` for
the missing methods.
- `noopFormatBridge` is `Object.freeze`d at module load. This
guards against accidental mutation in tests or downstream
packages corrupting the shared singleton.
- `currency` returns `${code} ${String(value)}` rather than
`String(value)` alone so the no-op fallback still surfaces the
ISO 4217 code in test output, making fallback hits visible
during development.
- The style enums are declared in `format-bridge.types.ts` to keep
this implementation file zero-runtime-cost when imported for
the bridge interface alone.

View File

@@ -0,0 +1,117 @@
# message-render
## Purpose
`message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects all unique variable names referenced in a parsed message AST, and `renderNodes`, which renders an AST to a string using caller-supplied values. These are the hot-path primitives consumed by `MessageParser.render` and exposed as public exports for callers that need to work with pre-parsed ASTs directly.
Separating these pure functions from the `MessageParser` class allows them to be imported without pulling in the full parser, which is useful for rendering pre-parsed messages in tight loops or edge environments.
## Features
- `extractVariables` — collects unique variable names from a `MessageNode[]`, recursing into plural and select choices
- `renderNodes` — renders a `MessageNode[]` to a string, supporting text, variable, plural, and select node types
- Plural `offset` support: `count - offset` is passed as `"#"` into nested renders
- Exact-match plural keys (`=0`, `=1`) take priority over category keys (`one`, `other`)
- Nullable/undefined values are rendered as empty strings
- Optional `pluralSelect` callback for language-specific plural category resolution
## Usage: Basic Example
```ts
import { extractVariables, renderNodes } from "@suaveplan/i18n-icu-parser/parser";
const nodes = [
{ type: "text", value: "Hello, " },
{ type: "variable", name: "name" },
{ type: "text", value: "!" },
];
const vars = extractVariables(nodes);
console.log(vars); // ["name"]
const text = renderNodes(nodes, { name: "Alice" });
console.log(text); // "Hello, Alice!"
```
## Usage: Advanced Example
```ts
import { renderNodes } from "@suaveplan/i18n-icu-parser/parser";
import type { MessageNode } from "@suaveplan/i18n-icu-parser/parser";
// Pre-parsed plural node (normally produced by MessageParser)
const nodes: MessageNode[] = [
{
type: "plural",
variable: "count",
offset: 0,
choices: {
one: [{ type: "variable", name: "#" }, { type: "text", value: " item" }],
other: [{ type: "variable", name: "#" }, { type: "text", value: " items" }],
},
},
];
// Custom plural selector for a language with more categories
const pluralSelect = (n: number) => (n === 1 ? "one" : "other");
console.log(renderNodes(nodes, { count: 1 }, pluralSelect)); // "1 item"
console.log(renderNodes(nodes, { count: 5 }, pluralSelect)); // "5 items"
```
## API Reference
### `extractVariables(nodes: MessageNode[]): string[]`
Extract all unique variable names referenced in `nodes`. Recurses into `plural` and `select` choice branches. Excludes the special `"#"` sentinel used inside plural branches.
**Returns:** Array of unique variable name strings in order of first appearance.
### `renderNodes(nodes: MessageNode[], values: Record<string, unknown>, options?: RenderOptions | RenderContext | pluralSelect): string`
Render `nodes` to a string.
| Node type | Behaviour |
|-----------|-----------|
| `text` | Appended verbatim |
| `variable` | Replaced with `String(values[name])`, or `""` if `null`/`undefined`. Throws `I18nError(I18N.INTERPOLATION_ERROR)` when the key is ABSENT. `"#"` resolves from `values["#"]` |
| `plural` | Resolves count, adjusts by offset, selects branch by exact key then category then `"other"`, recurses |
| `select` | Selects branch by string value of variable, falls back to `"other"`, recurses |
| `typed-argument` | Routes through `options.formatBridge` (`number` / `currency` / `date` / `time`); falls back to `String(value)` if bridge or method is undefined |
| `tag` | If `values[tagName]` is a function, calls it with the rendered inner string; otherwise renders inner content as-is |
The `pluralSelect(count, category)` callback is used to select plural categories for plural nodes. When absent, falls back to `count === 1 ? "one" : "other"`.
### `RenderOptions`
```typescript
interface RenderOptions extends RenderContext {
readonly formatBridge?: FormatBridge;
}
interface RenderContext {
readonly locale?: string;
readonly pluralSelect?: (count: number, category: string) => string;
readonly ordinalSelect?: (count: number) => string;
}
```
The `formatBridge` slot is the dependency-injection seam between
this parser package and the per-formatter packages
(see `format-bridge.md`). When supplied, the renderer dispatches
ICU format placeholders (`{value, number}`, `{amount, number,
currency=USD}`, `{date, date, short}`, `{ts, time, long}`) to the
bridge methods. When the bridge OR a specific method is undefined,
the renderer coerces the value to `String(value)` and never throws.
## Implementation Notes
`renderNodes` is recursive: plural and select nodes recursively call `renderNodes` on the matched choice's node array. For plural nodes, the adjusted count (`count - offset`) is injected as `values["#"]` into the recursive call so that `#` placeholders inside plural branches resolve correctly.
Exact-match keys (e.g. `=0`, `=2`) in plural choices are checked before category keys, matching the ICU specification for exact-value overrides.
The `formatBridge` slot keeps this package a true universal
leaf with no `Intl.*` dependency. The slim `@suaveplan/i18n` core
(Wave 6) constructs a default bridge over the formatter packages
and threads it through `createI18n({ ... })` so consumers get
locale-aware output automatically.

View File

@@ -0,0 +1,99 @@
# MessageParser
## Purpose
`MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to render that AST back to a string with caller-supplied values. It enables rich localised messages that include plural selection, gender/select branching, and nested variable substitution — without pulling in a full ICU implementation.
The parser is also available in a simpler non-ICU mode that only processes `{variable}` substitution with configurable delimiters, which is sufficient for many template-string use cases.
## Features
- Parses ICU-style plural: `{count, plural, one {# item} other {# items}}`
- Parses ICU-style select: `{gender, select, male {He} female {She} other {They}}`
- Nested ICU expressions and recursive choices
- Plural `offset:` support (`{count, plural, offset:1 ...}`)
- Exact-value plural matching: `=0 {no results}`, `=1 {one result}`
- `#` placeholder inside plural branches resolves to `count - offset`
- Non-ICU simple-variable mode with configurable open/close delimiters
- `extractVariables(nodes)` — extracts all referenced variable names from an AST
- `renderNodes(nodes, values, pluralSelect?)` — renders an AST to a string
## Usage: Basic Example
```typescript
import { MessageParser } from "@suaveplan/i18n-icu-parser/parser";
const parser = new MessageParser();
// Simple variable
const { nodes } = parser.parse("Hello, {name}!");
const text = parser.render({ nodes, variables: ["name"] }, { name: "World" });
// text === "Hello, World!"
// One-liner
const msg = parser.format(
"{count, plural, one {# apple} other {# apples}}",
{ count: 3 }
);
// msg === "3 apples"
```
## Usage: Advanced Example
```typescript
import { MessageParser, renderNodes, extractVariables } from "@suaveplan/i18n-icu-parser/parser";
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
const parser = new MessageParser();
const engine = new PluralizationEngine();
const template = "{gender, select, male {He has} female {She has} other {They have}} {count, plural, one {# item} other {# items}}.";
const parsed = parser.parse(template);
// Use PluralizationEngine for the plural select callback
const text = parser.render(parsed, { gender: "female", count: 5 }, (count) =>
engine.getCategory(count, "en")
);
// text === "She has 5 items."
// Extract variable names from a template without rendering
const vars = extractVariables(parsed.nodes);
// vars === ["gender", "count"]
```
## API Reference
### `new MessageParser(options?: ParserOptions)`
| Option | Default | Description |
|--------|---------|-------------|
| `variablePrefix` | `"{"` | Simple-mode open delimiter |
| `variableSuffix` | `"}"` | Simple-mode close delimiter |
| `icuSyntax` | `true` | Enable plural/select parsing |
### `.parse(message): ParsedMessage`
Returns `{ nodes: MessageNode[]; variables: string[] }`.
### `.render(parsed, values, pluralSelect?): string`
Renders a `ParsedMessage` to a string. `pluralSelect(count, category)` is used to select plural forms — defaults to `one`/`other` if omitted.
### `.format(message, values, pluralSelect?): string`
Convenience: `parse` + `render` in one call.
### `extractVariables(nodes: MessageNode[]): string[]`
Standalone export. Returns all unique variable names (excluding `"#"`) referenced in the node tree.
### `renderNodes(nodes, values, pluralSelect?): string`
Standalone export. Renders an array of `MessageNode` to a string.
## Implementation Notes
- The parser is a hand-written recursive descent parser; it does not use a grammar generator or regex for structural parsing (only for simple-mode delimiter matching)
- ICU `plural` and `select` nodes are parsed in a single forward pass; backtracking is never required
- The `#` sentinel in plural branches is emitted as a `VariableNode` with `name === "#"` and resolved during rendering by injecting `{ "#": adjusted }` into the values map
- `renderNodes` is exported to allow rendering pre-parsed ASTs without re-parsing, which is the expected hot-path in production (parse once, render many times)

View File

@@ -0,0 +1,98 @@
# PluralizationEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PluralizationEngine` selects the correct plural form of a message for a given count and locale, following the Unicode CLDR plural rules. Rather than requiring callers to know the intricacies of each language's plural grammar (Russian has four forms; Arabic six; Welsh six with unique triggers), the engine encapsulates that knowledge and exposes a simple `resolve(count, messages, options)` API.
The engine is pre-loaded with built-in rules for 14 languages and supports runtime extension via `registerRule`. The rule functions are exported as standalone named exports so they can be tree-shaken or used directly without the class.
## Features
- Pre-built rules for Arabic, Chinese, English, French, German, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Spanish, Welsh
- Cardinal and ordinal plural support (English ordinal: 1st, 2nd, 3rd)
- `PluralRuleRegistry` backed by a `Map` for O(1) locale lookup
- Locale normalisation: `"en-GB"` → `"en"`, `"ZH"` → `"zh"`
- `getCategories(locale)` — introspects which CLDR categories a locale uses
- All rule functions exported for standalone use
## Usage: Basic Example
```typescript
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
const engine = new PluralizationEngine();
const result = engine.resolve(3, { one: "1 item", other: "# items" }, { locale: "en" });
// result === { category: "other", message: "# items", count: 3 }
```
## Usage: Advanced Example
```typescript
import { PluralizationEngine, arabicPlural } from "@suaveplan/i18n-icu-parser/pluralization";
const engine = new PluralizationEngine({
// override Welsh with a custom rule
cy: (n) => (n === 0 ? "zero" : n === 1 ? "one" : "other"),
});
// Arabic: 0/1/2/few(3-10)/many(11-99)/other(100+)
const ar0 = engine.resolve(0, { zero: "لا عناصر", one: "عنصر", other: "عناصر" }, { locale: "ar" });
// { category: "zero", message: "لا عناصر", count: 0 }
// English ordinal
const { category } = engine.resolve(3, {}, { locale: "en", ordinal: true });
// category === "few" (3rd)
// Fallback to "other" when category absent
const r = engine.resolve(5, { one: "one thing" }, { locale: "en" });
// { category: "other", message: undefined, count: 5 }
```
## API Reference
### `new PluralizationEngine(customRules?: Record<string, PluralRuleFunction>)`
`customRules` is merged over the built-in rule set after registration.
### `.resolve(count, messages, options?): PluralResolveResult`
| Param | Type | Description |
|-------|------|-------------|
| `count` | `number` | Numeric quantity |
| `messages` | `PluralMessages` | Partial record of category → message string |
| `options.locale` | `string?` | BCP-47 locale (default `"en"`) |
| `options.ordinal` | `boolean?` | Use ordinal rules (default `false`) |
Returns `{ category: PluralCategory; message: string | undefined; count: number }`.
### `.getCategory(count, locale, ordinal?): PluralCategory`
Returns the CLDR plural category without resolving a message string.
### `.registerRule(locale, rule): void`
Registers or overrides a rule for a locale code. The locale is normalised before storage.
### `.hasRule(locale): boolean`
Returns `true` if a rule is registered for the locale or its base language.
### `.getRule(locale): PluralRuleFunction`
Returns the rule for the locale, its base language, or `defaultPluralRule`.
### `.getCategories(locale): PluralCategory[]`
Returns all CLDR categories reachable for a locale by probing values `[0,1,2,3,6,11,100]`.
## Implementation Notes
- `normalizeLocale` strips the region subtag: `"ru-RU"` → `"ru"`. Registering `"ru"` covers all regional variants
- Russian and Polish rules use modulo 10 and modulo 100 together; the order of checks matters — always test the teens exception (`mod100 >= 11 && mod100 <= 14`) before the single-digit rule
- Arabic plurals use `n % 100` for the few/many boundary, which correctly handles values ≥ 100
- Welsh plural rules are exact-value based (n === 0/1/2/3/6) rather than modulo based
- `chinesePlural` always returns `"other"` — Chinese, Japanese, and Korean use the same zero-variation plural system