first commit
This commit is contained in:
182
docs/localization/i18n/i18n-icu-parser/README.md
Normal file
182
docs/localization/i18n/i18n-icu-parser/README.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
`<`/`>` 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>");
|
||||
// "<b>bold</b>"
|
||||
```
|
||||
|
||||
## 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'm "the boss" & <admin>">…</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 |
|
||||
|---|---|
|
||||
| `&` | `&` |
|
||||
| `<` | `<` |
|
||||
| `>` | `>` |
|
||||
| `"` | `"` |
|
||||
| `'` | `'` |
|
||||
|
||||
**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 (`<`, `>`, `"`, `'`) 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 `'` rather
|
||||
than the named entity `'` 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 `'` per char).
|
||||
@@ -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.
|
||||
@@ -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: <script>alert('xss')</script>"
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal file
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal 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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user