Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,88 @@
{
"name": "@suaveplan/i18n-icu-parser",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./parser": {
"import": {
"types": "./dist/parser/index.d.ts",
"default": "./dist/parser/index.js"
}
},
"./parser/format-bridge": {
"import": {
"types": "./dist/parser/format-bridge.d.ts",
"default": "./dist/parser/format-bridge.js"
}
},
"./interpolation": {
"import": {
"types": "./dist/interpolation/index.d.ts",
"default": "./dist/interpolation/index.js"
}
},
"./pluralization": {
"import": {
"types": "./dist/pluralization/index.d.ts",
"default": "./dist/pluralization/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/i18n-shared": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as root from "./index.js";
describe("@suaveplan/i18n-icu-parser root barrel", () => {
it("re-exports MessageParser", { timeout: 5000 }, () => {
expect(root.MessageParser).toBeDefined();
const p = new root.MessageParser();
expect(p.format("hi {x}", { x: "y" })).toBe("hi y");
});
it("re-exports InterpolationEngine", { timeout: 5000 }, () => {
expect(root.InterpolationEngine).toBeDefined();
const e = new root.InterpolationEngine();
expect(e.render("{{ a }}", { a: "b" }).text).toBe("b");
});
it("re-exports PluralizationEngine", { timeout: 5000 }, () => {
expect(root.PluralizationEngine).toBeDefined();
const pe = new root.PluralizationEngine();
expect(pe.getCategory(1, "en")).toBe("one");
});
it("re-exports noopFormatBridge", { timeout: 5000 }, () => {
expect(root.noopFormatBridge).toBeDefined();
expect(root.noopFormatBridge.number?.(42, "en")).toBe("42");
});
it("re-exports the 9 plural rule functions", { timeout: 5000 }, () => {
expect(root.arabicPlural(0)).toBe("zero");
expect(root.chinesePlural()).toBe("other");
expect(root.englishPlural(1)).toBe("one");
expect(root.frenchPlural(0)).toBe("one");
expect(root.germanPlural(1)).toBe("one");
expect(root.japanesePlural()).toBe("other");
expect(root.polishPlural(1)).toBe("one");
expect(root.russianPlural(1)).toBe("one");
expect(root.welshPlural(0)).toBe("zero");
});
it("re-exports escape helpers", { timeout: 5000 }, () => {
expect(root.escapeHtmlChars("<")).toBe("&lt;");
expect(root.escapeRegex(".")).toBe("\\.");
});
it("re-exports namespace bundles", { timeout: 5000 }, () => {
expect(root.parser.MessageParser).toBe(root.MessageParser);
expect(root.interpolation.InterpolationEngine).toBe(
root.InterpolationEngine
);
expect(root.pluralization.PluralizationEngine).toBe(
root.PluralizationEngine
);
});
it("re-exports the OTel histogram recorders", { timeout: 5000 }, () => {
expect(typeof root.recordParseDuration).toBe("function");
expect(typeof root.recordInterpolationDuration).toBe("function");
expect(typeof root.recordPluralDuration).toBe("function");
});
});
describe("@suaveplan/i18n-icu-parser subpath imports", () => {
it("./parser subpath resolves", { timeout: 5000 }, async () => {
const mod = await import("./parser/index.js");
expect(mod.MessageParser).toBeDefined();
expect(mod.noopFormatBridge).toBeDefined();
});
it("./parser/format-bridge subpath resolves", {
timeout: 5000,
}, async () => {
const mod = await import("./parser/format-bridge.js");
expect(mod.noopFormatBridge).toBeDefined();
});
it("./interpolation subpath resolves", { timeout: 5000 }, async () => {
const mod = await import("./interpolation/index.js");
expect(mod.InterpolationEngine).toBeDefined();
expect(mod.escapeHtmlChars).toBeDefined();
expect(mod.escapeRegex).toBeDefined();
});
it("./pluralization subpath resolves", { timeout: 5000 }, async () => {
const mod = await import("./pluralization/index.js");
expect(mod.PluralizationEngine).toBeDefined();
expect(mod.englishPlural).toBeDefined();
});
it("every documented subpath export resolves", { timeout: 5000 }, () => {
const expectedSurface = [
"MessageParser",
"InterpolationEngine",
"PluralizationEngine",
"noopFormatBridge",
"extractVariables",
"renderNodes",
"renderToChunks",
"escapeHtmlChars",
"escapeRegex",
"arabicPlural",
"chinesePlural",
"englishPlural",
"frenchPlural",
"germanPlural",
"japanesePlural",
"polishPlural",
"russianPlural",
"spanishPlural",
"welshPlural",
"defaultPluralRule",
"normalizeLocale",
"getBaseLocale",
"InterpolationConfigSchema",
"preprocessIcuApostrophes",
"findUnknownArgTypes",
"recordParseDuration",
"recordInterpolationDuration",
"recordPluralDuration",
];
const exported = Object.keys(root);
for (const name of expectedSurface) {
expect(exported).toContain(name);
}
});
});

View File

@@ -0,0 +1,85 @@
// Root barrel for @suaveplan/i18n-icu-parser. Explicit named
// re-exports across three sub-namespaces (parser / interpolation /
// pluralization) plus the observability hooks. Per CLAUDE.md §10
// no `export *` in non-root barrels; package roots may use
// namespace re-exports, which is what we do here.
export type {
InterpolationConfig,
InterpolationConfigInput,
InterpolationOptions,
InterpolationResult,
InterpolationValues,
ValueFormatter,
} from "./interpolation/index.js";
export * as interpolation from "./interpolation/index.js";
export {
escapeHtmlChars,
escapeRegex,
InterpolationConfigSchema,
InterpolationEngine,
} from "./interpolation/index.js";
export {
recordInterpolationDuration,
recordParseDuration,
recordPluralDuration,
} from "./observability/metrics.js";
export type {
DateStyle,
MessageNode,
NumberFormatStyle,
ParsedMessage,
ParserOptions,
PluralNode,
RelativeTimeStyle,
RenderContext,
RenderOptions,
SelectNode,
SelectOrdinalNode,
TagNode,
TextNode,
TypedArgumentNode,
ValidationIssue,
ValidationResult,
VariableNode,
} from "./parser/index.js";
export * as parser from "./parser/index.js";
// Direct named re-exports for the highest-traffic public symbols
// so single-import call sites stay short.
export {
extractVariables,
type FormatBridge,
findUnknownArgTypes,
MessageParser,
noopFormatBridge,
preprocessIcuApostrophes,
renderNodes,
renderToChunks,
} from "./parser/index.js";
export type {
PluralCategory,
PluralizationConfig,
PluralizationEngineOptions,
PluralMessages,
PluralResolveOptions,
PluralResolveResult,
PluralRuleFunction,
PluralRuleRegistry,
} from "./pluralization/index.js";
export * as pluralization from "./pluralization/index.js";
export {
arabicPlural,
chinesePlural,
defaultPluralRule,
englishPlural,
frenchPlural,
germanPlural,
getBaseLocale,
japanesePlural,
normalizeLocale,
PluralizationEngine,
polishPlural,
russianPlural,
spanishPlural,
welshPlural,
} from "./pluralization/index.js";

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,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { escapeHtmlChars } from "./escape-html.js";
describe("escapeHtmlChars", () => {
it("escapes all five HTML chars", { timeout: 5000 }, () => {
expect(escapeHtmlChars("<>&\"'")).toBe("&lt;&gt;&amp;&quot;&#039;");
});
it("escapes < to &lt;", { timeout: 5000 }, () => {
expect(escapeHtmlChars("<")).toBe("&lt;");
});
it("escapes > to &gt;", { timeout: 5000 }, () => {
expect(escapeHtmlChars(">")).toBe("&gt;");
});
it("escapes & to &amp;", { timeout: 5000 }, () => {
expect(escapeHtmlChars("&")).toBe("&amp;");
});
it('escapes " to &quot;', { timeout: 5000 }, () => {
expect(escapeHtmlChars('"')).toBe("&quot;");
});
it("escapes ' to &#039;", { timeout: 5000 }, () => {
expect(escapeHtmlChars("'")).toBe("&#039;");
});
it("leaves safe strings unchanged", { timeout: 5000 }, () => {
expect(escapeHtmlChars("hello world")).toBe("hello world");
});
it("returns empty string for empty input", { timeout: 5000 }, () => {
expect(escapeHtmlChars("")).toBe("");
});
it("escapes & before subsequent entity-producing chars", {
timeout: 5000,
}, () => {
// & must be escaped first so the & in &lt; is not re-escaped.
expect(escapeHtmlChars("&<")).toBe("&amp;&lt;");
});
it("handles a tricky XSS payload", { timeout: 5000 }, () => {
const out = escapeHtmlChars(`<script>alert('x')</script>`);
expect(out).toBe("&lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt;");
});
});

View File

@@ -0,0 +1,16 @@
/**
* Escape HTML special characters to prevent XSS in rendered templates.
* Converts: & < > " '
*
* Extracted from the interpolation engine into its own file so consumers
* who want only the helper (e.g. for sanitising tooltip strings before
* insertion into innerHTML) can tree-shake the rest of the engine.
*/
export function escapeHtmlChars(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

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,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { escapeRegex } from "./escape-regex.js";
describe("escapeRegex", () => {
it("escapes all regex meta chars", { timeout: 5000 }, () => {
expect(escapeRegex(".*+?^${}()|[]\\")).toBe(
"\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\"
);
});
it("escapes the open and close delimiters", { timeout: 5000 }, () => {
expect(escapeRegex("{{")).toBe("\\{\\{");
expect(escapeRegex("}}")).toBe("\\}\\}");
});
it("escapes . to \\.", { timeout: 5000 }, () => {
expect(escapeRegex(".")).toBe("\\.");
});
it("escapes * to \\*", { timeout: 5000 }, () => {
expect(escapeRegex("*")).toBe("\\*");
});
it("escapes + to \\+", { timeout: 5000 }, () => {
expect(escapeRegex("+")).toBe("\\+");
});
it("escapes ? to \\?", { timeout: 5000 }, () => {
expect(escapeRegex("?")).toBe("\\?");
});
it("escapes ^ to \\^", { timeout: 5000 }, () => {
expect(escapeRegex("^")).toBe("\\^");
});
it("escapes $ to \\$", { timeout: 5000 }, () => {
expect(escapeRegex("$")).toBe("\\$");
});
it("escapes ( to \\(", { timeout: 5000 }, () => {
expect(escapeRegex("(")).toBe("\\(");
});
it("escapes ) to \\)", { timeout: 5000 }, () => {
expect(escapeRegex(")")).toBe("\\)");
});
it("escapes | to \\|", { timeout: 5000 }, () => {
expect(escapeRegex("|")).toBe("\\|");
});
it("escapes [ to \\[", { timeout: 5000 }, () => {
expect(escapeRegex("[")).toBe("\\[");
});
it("escapes ] to \\]", { timeout: 5000 }, () => {
expect(escapeRegex("]")).toBe("\\]");
});
it("escapes backslash to \\\\", { timeout: 5000 }, () => {
expect(escapeRegex("\\")).toBe("\\\\");
});
it("leaves alphanumeric unchanged", { timeout: 5000 }, () => {
expect(escapeRegex("abc")).toBe("abc");
expect(escapeRegex("Abc123")).toBe("Abc123");
});
it("returns empty string for empty input", { timeout: 5000 }, () => {
expect(escapeRegex("")).toBe("");
});
it("escaped output round-trips through RegExp constructor", {
timeout: 5000,
}, () => {
const meta = "a.b*c+d";
const re = new RegExp(escapeRegex(meta));
expect(re.test(meta)).toBe(true);
expect(re.test("a-b-c-d")).toBe(false);
});
});

View File

@@ -0,0 +1,10 @@
/**
* Escape all regex metacharacters in a string so it can be used literally
* inside a RegExp constructor. Used internally by the interpolation
* engine to compile placeholder patterns from caller-supplied delimiters,
* and exported as a standalone helper for consumers building their own
* delimiter-aware patterns.
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,14 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export { escapeHtmlChars } from "./escape-html.js";
export { escapeRegex } from "./escape-regex.js";
export { InterpolationEngine } from "./interpolation.js";
export type { InterpolationConfigInput } from "./interpolation.schemas.js";
export { InterpolationConfigSchema } from "./interpolation.schemas.js";
export type {
InterpolationConfig,
InterpolationOptions,
InterpolationResult,
InterpolationValues,
ValueFormatter,
} from "./interpolation.types.js";

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,32 @@
import z from "zod";
/**
* Zod boundary schema for {@link InterpolationOptions}.
*
* Canonical fields: `open`, `close`, `escape`, `maxIterations`.
* Transitional aliases `prefix` (→ `open`), `suffix` (→ `close`),
* `escapeHtml` (→ `escape`), and `maxDepth` (→ `maxIterations`)
* are accepted; the canonical fields win when both forms are
* supplied. Function-valued slots (`formatters` / `defaultFormatter`)
* are accepted structurally because Zod cannot meaningfully
* validate function shape.
*/
export const InterpolationConfigSchema = z
.object({
open: z.string().min(1).optional(),
close: z.string().min(1).optional(),
escape: z.boolean().optional(),
maxIterations: z.number().int().positive().max(100).optional(),
// Transitional aliases (kept until external consumers migrate)
prefix: z.string().min(1).default("{{"),
suffix: z.string().min(1).default("}}"),
escapeHtml: z.boolean().default(false),
maxDepth: z.number().int().positive().max(100).default(10),
formatters: z.record(z.string(), z.unknown()).optional(),
defaultFormatter: z.unknown().optional(),
})
.passthrough();
export type InterpolationConfigInput = z.input<
typeof InterpolationConfigSchema
>;

View File

@@ -0,0 +1,291 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
escapeHtmlChars,
escapeRegex,
InterpolationEngine,
} from "./interpolation.js";
describe("escapeHtmlChars", () => {
it("escapes all HTML special chars", { timeout: 5000 }, () => {
expect(escapeHtmlChars("<>&\"'")).toBe("&lt;&gt;&amp;&quot;&#039;");
});
it("leaves safe strings unchanged", { timeout: 5000 }, () => {
expect(escapeHtmlChars("hello world")).toBe("hello world");
});
});
describe("escapeRegex", () => {
it("escapes regex metacharacters", { timeout: 5000 }, () => {
const escaped = escapeRegex("{{");
expect(escaped).toBe("\\{\\{");
});
it("leaves alphanumeric unchanged", { timeout: 5000 }, () => {
expect(escapeRegex("abc")).toBe("abc");
});
});
describe("InterpolationEngine", () => {
it("interpolates basic variables", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text } = engine.render("Hello, {{ name }}!", { name: "World" });
expect(text).toBe("Hello, World!");
});
it("tracks missing variables", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text, missingVariables } = engine.render(
"{{ a }} and {{ b }}",
{ a: "1" }
);
expect(text).toContain("1");
expect(missingVariables).toContain("b");
});
it("returns template unchanged when no values", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text, missingVariables } = engine.render("{{ x }}");
expect(text).toBe("{{ x }}");
expect(missingVariables).toContain("x");
});
it("HTML-escapes values when escapeHtml is true", { timeout: 5000 }, () => {
const engine = new InterpolationEngine({ escapeHtml: true });
const { text } = engine.render("{{ val }}", { val: "<b>bold</b>" });
expect(text).toContain("&lt;b&gt;");
expect(text).not.toContain("<b>");
});
it("uses custom formatter by name", { timeout: 5000 }, () => {
const engine = new InterpolationEngine({
formatters: { upper: (v) => String(v).toUpperCase() },
});
const { text } = engine.render("{{ name, upper }}", { name: "alice" });
expect(text).toBe("ALICE");
});
it("uses defaultFormatter when no named formatter matches", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine({
defaultFormatter: (v) => `*${v}*`,
});
const { text } = engine.render("{{ val }}", { val: "test" });
expect(text).toBe("*test*");
});
it("handles null and undefined values as empty string", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine();
const { text } = engine.render("{{ a }}{{ b }}", {
a: null,
b: undefined,
});
expect(text).toBe("");
});
it("formats Date values to ISO string", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const d = new Date("2024-01-15T00:00:00.000Z");
const { text } = engine.render("{{ d }}", { d });
expect(text).toContain("2024-01-15");
});
it("uses custom delimiters", { timeout: 5000 }, () => {
const engine = new InterpolationEngine({ prefix: "%{", suffix: "}" });
const { text } = engine.render("Hello, %{name}!", { name: "World" });
expect(text).toBe("Hello, World!");
});
it("addFormatter and removeFormatter", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
engine.addFormatter("reverse", (v) =>
String(v).split("").reverse().join("")
);
expect(engine.render("{{ x, reverse }}", { x: "abc" }).text).toBe(
"cba"
);
expect(engine.removeFormatter("reverse")).toBe(true);
expect(engine.removeFormatter("reverse")).toBe(false);
});
it("hasPlaceholders returns correct bool", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
expect(engine.hasPlaceholders("{{ a }}")).toBe(true);
expect(engine.hasPlaceholders("no placeholders")).toBe(false);
});
it("extractVariables returns unique names", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const vars = engine.extractVariables("{{ a }} {{ b }} {{ a }}");
expect(vars).toEqual(["a", "b"]);
});
it("enforces maxDepth", { timeout: 5000 }, () => {
const engine = new InterpolationEngine({ maxDepth: 1 });
// Value itself contains a placeholder — recursion past
// maxDepth surfaces as I18N.NESTING_DEPTH_EXCEEDED instead
// of silently leaving the inner placeholder behind.
try {
engine.render("{{ a }}", { a: "{{ b }}" });
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.NESTING_DEPTH_EXCEEDED"
);
}
});
it("escapes HTML when configured", { timeout: 5000 }, () => {
const engine = new InterpolationEngine({ escapeHtml: true });
const { text } = engine.render("safe: {{ html }}", {
html: "<script>",
});
expect(text).toBe("safe: &lt;script&gt;");
});
it("substitutes single variable", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text } = engine.render("hello {{ name }}", { name: "Ada" });
expect(text).toBe("hello Ada");
});
it("handles boolean values", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text } = engine.render("{{ flag }}", { flag: true });
expect(text).toBe("true");
});
it("handles number values", { timeout: 5000 }, () => {
const engine = new InterpolationEngine();
const { text } = engine.render("{{ n }}", { n: 42 });
expect(text).toBe("42");
});
it("unknown formatter name falls through to string conversion", {
timeout: 5000,
}, () => {
// No formatter named "missing", no defaultFormatter → falls back to String()
const engine = new InterpolationEngine();
const { text } = engine.render("{{ v, missing }}", { v: "hello" });
expect(text).toBe("hello");
});
it("throws INVALID_OPTIONS when canonical + legacy mixed", {
timeout: 5000,
}, () => {
try {
new InterpolationEngine({
open: "[[",
prefix: "{{",
});
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INVALID_OPTIONS"
);
}
});
it("throws INVALID_OPTIONS when open === close", { timeout: 5000 }, () => {
try {
new InterpolationEngine({
open: "@",
close: "@",
});
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INVALID_OPTIONS"
);
}
});
it("throws INTERPOLATION_ERROR on Zod-invalid options", {
timeout: 5000,
}, () => {
// maxDepth -1 violates the schema (.positive()).
try {
new InterpolationEngine({
maxDepth: -1,
} as unknown as never);
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INTERPOLATION_ERROR"
);
}
});
it("throws INTERPOLATION_ERROR on unclosed delimiter", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine();
try {
engine.render("hello {{ name", { name: "x" });
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INTERPOLATION_ERROR"
);
}
});
it("wraps formatter throw in INTERPOLATION_ERROR", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine({
formatters: {
bomb: () => {
throw new Error("boom");
},
},
});
try {
engine.render("{{ v, bomb }}", { v: "x" });
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INTERPOLATION_ERROR"
);
}
});
it("wraps defaultFormatter throw in INTERPOLATION_ERROR", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine({
defaultFormatter: () => {
throw new Error("boom");
},
});
try {
engine.render("{{ v }}", { v: "x" });
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INTERPOLATION_ERROR"
);
}
});
it("constructor accepts canonical open/close delimiters", {
timeout: 5000,
}, () => {
const engine = new InterpolationEngine({ open: "[[", close: "]]" });
// Test both directions of the canonical-only path.
expect(engine.hasPlaceholders("[[ x ]]")).toBe(true);
});
it("renders a placeholder-free template unchanged", {
timeout: 5000,
}, () => {
// No `{{` in template — the openIdx === -1 branch in render().
const engine = new InterpolationEngine();
const { text, missingVariables } = engine.render("plain string", {
anything: "x",
});
expect(text).toBe("plain string");
expect(missingVariables).toEqual([]);
});
});

View File

@@ -0,0 +1,251 @@
import {
DEFAULT_INTERPOLATION,
I18N_CODES,
I18nError,
} from "@suaveplan/i18n-shared";
import { recordInterpolationDuration } from "../observability/metrics.js";
import { escapeHtmlChars } from "./escape-html.js";
import { escapeRegex } from "./escape-regex.js";
import { InterpolationConfigSchema } from "./interpolation.schemas.js";
import type {
InterpolationOptions,
InterpolationResult,
InterpolationValues,
ValueFormatter,
} from "./interpolation.types.js";
// Re-export the helpers so the legacy import path
// `import { escapeHtmlChars, escapeRegex } from "./interpolation.js"`
// keeps working — every monolith caller, including the ported test
// suite below, expects to find them here.
export { escapeHtmlChars } from "./escape-html.js";
export { escapeRegex } from "./escape-regex.js";
/**
* Template interpolation engine.
*
* Replaces placeholder tokens (default: {{ name }}) in message templates
* with caller-supplied values. Supports pluggable per-slot formatters, an
* optional default formatter, HTML escaping of rendered values, and
* recursive interpolation up to a configurable depth limit.
*/
export class InterpolationEngine {
protected readonly prefix: string;
protected readonly suffix: string;
protected readonly escapeHtml: boolean;
protected readonly maxDepth: number;
protected readonly formatters: Map<string, ValueFormatter>;
protected readonly defaultFormatter: ValueFormatter | undefined;
constructor(options: InterpolationOptions = {}) {
const parsed = InterpolationConfigSchema.safeParse(options);
if (!parsed.success) {
throw new I18nError(
I18N_CODES.INTERPOLATION_ERROR,
`Invalid interpolation options: ${parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`,
{ context: { zodIssues: parsed.error.issues } }
);
}
const hasCanonical =
options.open !== undefined || options.close !== undefined;
const hasLegacy =
options.prefix !== undefined || options.suffix !== undefined;
if (hasCanonical && hasLegacy) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"Cannot mix canonical (open/close) and legacy (prefix/suffix) delimiter options"
);
}
const open =
options.open ?? options.prefix ?? DEFAULT_INTERPOLATION.prefix;
const close =
options.close ?? options.suffix ?? DEFAULT_INTERPOLATION.suffix;
if (open === close) {
throw new I18nError(
I18N_CODES.INVALID_OPTIONS,
"open and close delimiters must be different"
);
}
this.prefix = open;
this.suffix = close;
this.escapeHtml = options.escapeHtml ?? false;
this.maxDepth = options.maxDepth ?? DEFAULT_INTERPOLATION.maxDepth;
this.formatters = new Map(Object.entries(options.formatters ?? {}));
this.defaultFormatter = options.defaultFormatter;
}
/**
* Render a template with the supplied values.
* Returns `{ text, missingVariables }` where missingVariables contains
* any names referenced in the template but absent from values.
*/
render(
template: string,
values?: InterpolationValues,
locale?: string
): InterpolationResult {
const t0 = performance.now();
const openIdx = template.indexOf(this.prefix);
if (openIdx !== -1) {
const closeIdx = template.indexOf(
this.suffix,
openIdx + this.prefix.length
);
if (closeIdx === -1) {
throw new I18nError(
I18N_CODES.INTERPOLATION_ERROR,
`Unclosed delimiter '${this.prefix}' in template`
);
}
}
if (!values) {
const missingVariables = this.extractVariables(template);
recordInterpolationDuration(
performance.now() - t0,
this.escapeHtml
);
return { text: template, missingVariables };
}
const missingVariables: string[] = [];
const text = this.replaceValues(
template,
values,
locale ?? "en",
missingVariables,
0
);
recordInterpolationDuration(performance.now() - t0, this.escapeHtml);
return { text, missingVariables };
}
/** Register a named formatter */
addFormatter(name: string, formatter: ValueFormatter): void {
this.formatters.set(name, formatter);
}
/** Deregister a named formatter; returns true if it existed */
removeFormatter(name: string): boolean {
return this.formatters.delete(name);
}
/** Returns true if the template contains at least one placeholder */
hasPlaceholders(template: string): boolean {
return this.buildPattern().test(template);
}
/** Return all variable names referenced in a template */
extractVariables(template: string): string[] {
const pattern = this.buildPatternGlobal();
const variables: string[] = [];
let match: RegExpExecArray | null;
match = pattern.exec(template);
while (match !== null) {
const name = match[1]?.trim().split(",")[0]?.trim();
if (name && !variables.includes(name)) variables.push(name);
match = pattern.exec(template);
}
return variables;
}
protected replaceValues(
template: string,
values: InterpolationValues,
locale: string,
missingVars: string[],
depth: number
): string {
if (depth >= this.maxDepth) {
// The new package surface promotes silent stop into a hard
// failure so consumers cannot ship a self-referential
// template that quietly leaves placeholders behind. The
// pre-split monolith returned `template` here.
throw new I18nError(
I18N_CODES.NESTING_DEPTH_EXCEEDED,
`Interpolation depth ${depth} reached configured maxDepth ${this.maxDepth}`,
{ context: { depth, maxDepth: this.maxDepth } }
);
}
const p = escapeRegex(this.prefix);
const s = escapeRegex(this.suffix);
const regex = new RegExp(`${p}\\s*([^}]+?)\\s*${s}`, "g");
const result = template.replace(regex, (_, expr: string) => {
const parts = expr.split(",").map((x: string) => x.trim());
const varName = parts[0] as string;
const formatName = parts[1];
if (!(varName in values)) {
if (!missingVars.includes(varName)) missingVars.push(varName);
return `${this.prefix} ${varName} ${this.suffix}`;
}
const rawValue = values[varName];
let formatted = this.formatValue(rawValue, locale, formatName);
if (this.escapeHtml) formatted = escapeHtmlChars(formatted);
return formatted;
});
if (result !== template && this.hasPlaceholders(result)) {
return this.replaceValues(
result,
values,
locale,
missingVars,
depth + 1
);
}
return result;
}
protected formatValue(
value: unknown,
locale: string,
formatName?: string
): string {
if (formatName) {
const formatter = this.formatters.get(formatName);
if (formatter) {
try {
return formatter(value, locale, formatName);
} catch (cause) {
throw new I18nError(
I18N_CODES.INTERPOLATION_ERROR,
`Formatter '${formatName}' threw an error`,
{ cause }
);
}
}
}
if (this.defaultFormatter) {
try {
return this.defaultFormatter(value, locale, formatName);
} catch (cause) {
throw new I18nError(
I18N_CODES.INTERPOLATION_ERROR,
"Default formatter threw an error",
{ cause }
);
}
}
if (value === null || value === undefined) return "";
if (value instanceof Date) return value.toISOString();
return String(value);
}
private buildPattern(): RegExp {
const p = escapeRegex(this.prefix);
const s = escapeRegex(this.suffix);
return new RegExp(`${p}\\s*[^}]+?\\s*${s}`);
}
private buildPatternGlobal(): RegExp {
const p = escapeRegex(this.prefix);
const s = escapeRegex(this.suffix);
return new RegExp(`${p}\\s*([^}]+?)\\s*${s}`, "g");
}
}

View File

@@ -0,0 +1,40 @@
/**
* Type definitions for the interpolation engine.
*/
/** Interpolation variable values — the types that can appear in templates */
export type InterpolationValues = Record<
string,
string | number | boolean | Date | null | undefined
>;
/** Base interpolation configuration */
export interface InterpolationConfig {
prefix?: string;
suffix?: string;
escapeHtml?: boolean;
maxDepth?: number;
}
/** Custom formatter function for a single interpolation slot */
export type ValueFormatter = (
value: unknown,
locale: string,
formatName?: string
) => string;
/** Extended interpolation options including formatters */
export interface InterpolationOptions extends InterpolationConfig {
formatters?: Record<string, ValueFormatter>;
defaultFormatter?: ValueFormatter;
/** Canonical open delimiter (alternative to legacy `prefix`). */
open?: string;
/** Canonical close delimiter (alternative to legacy `suffix`). */
close?: string;
}
/** Result of an interpolation render call */
export interface InterpolationResult {
text: string;
missingVariables: string[];
}

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,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordInterpolationDuration,
recordParseDuration,
recordPluralDuration,
} from "./metrics.js";
describe("observability/metrics", () => {
it("recordParseDuration records without throwing", {
timeout: 5000,
}, () => {
expect(() => recordParseDuration(1.234, true)).not.toThrow();
expect(() => recordParseDuration(0, false)).not.toThrow();
});
it("recordInterpolationDuration records without throwing", {
timeout: 5000,
}, () => {
expect(() => recordInterpolationDuration(2.5, true)).not.toThrow();
expect(() => recordInterpolationDuration(0, false)).not.toThrow();
});
it("recordPluralDuration records without throwing", {
timeout: 5000,
}, () => {
expect(() => recordPluralDuration(0.1, "en", false)).not.toThrow();
expect(() => recordPluralDuration(0.2, "ar", true)).not.toThrow();
});
});

View File

@@ -0,0 +1,72 @@
import { metrics } from "@opentelemetry/api";
/**
* OTel meter shared across this package's hot-path histograms. Named
* after the package so log/metric pipelines can group emissions per
* leaf without parsing histogram names.
*/
const meter = metrics.getMeter("@suaveplan/i18n-icu-parser");
const parseDuration = meter.createHistogram("i18n.parse.duration_ms", {
description: "Duration of MessageParser.parse() in milliseconds",
unit: "ms",
});
const interpolationDuration = meter.createHistogram(
"i18n.interpolation.duration_ms",
{
description: "Duration of InterpolationEngine.render() in milliseconds",
unit: "ms",
}
);
const pluralDuration = meter.createHistogram("i18n.plural.duration_ms", {
description:
"Duration of PluralizationEngine.resolve()/getCategory() in milliseconds",
unit: "ms",
});
/**
* Record a parse-duration sample. Called from the hot path of
* `MessageParser.parse`. Wrapped in a function so the histogram is
* recorded with a stable attribute set the consumer's view layer
* can group on.
*/
export function recordParseDuration(
durationMs: number,
icuSyntax: boolean
): void {
parseDuration.record(durationMs, {
"i18n.parse.icu_syntax": icuSyntax,
});
}
/**
* Record an interpolation-duration sample. Called from the hot
* path of `InterpolationEngine.render`. The `escapeHtml` attribute
* lets dashboards group by escape mode.
*/
export function recordInterpolationDuration(
durationMs: number,
escapeHtml: boolean
): void {
interpolationDuration.record(durationMs, {
"i18n.interpolation.escape_html": escapeHtml,
});
}
/**
* Record a plural-resolution duration sample. Called from the hot
* path of `PluralizationEngine.resolve`. The `locale` attribute is
* the normalised base locale so cardinality stays bounded.
*/
export function recordPluralDuration(
durationMs: number,
locale: string,
ordinal: boolean
): void {
pluralDuration.record(durationMs, {
"i18n.plural.locale": locale,
"i18n.plural.ordinal": ordinal,
});
}

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,67 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { type FormatBridge, noopFormatBridge } from "./format-bridge.js";
describe("FormatBridge type", () => {
it("noopFormatBridge implements FormatBridge", { timeout: 5000 }, () => {
expectTypeOf(noopFormatBridge).toMatchTypeOf<FormatBridge>();
});
it("FormatBridge methods are all optional", { timeout: 5000 }, () => {
const partial: FormatBridge = {};
expect(partial).toBeDefined();
const onlyNumber: FormatBridge = {
number: (v) => `n:${String(v)}`,
};
expect(onlyNumber.number?.(42, "en")).toBe("n:42");
});
});
describe("noopFormatBridge", () => {
it("noopFormatBridge.number coerces to string", { timeout: 5000 }, () => {
expect(noopFormatBridge.number?.(42, "en-GB")).toBe("42");
});
it("noopFormatBridge.number ignores style", { timeout: 5000 }, () => {
expect(noopFormatBridge.number?.(0.5, "en-GB", "percent")).toBe("0.5");
});
it("noopFormatBridge.currency includes the ISO code", {
timeout: 5000,
}, () => {
expect(noopFormatBridge.currency?.(99, "USD", "en-US")).toBe("USD 99");
});
it("noopFormatBridge.date coerces Date to string", {
timeout: 5000,
}, () => {
const d = new Date(0);
expect(noopFormatBridge.date?.(d, "en-GB")).toBe(String(d));
});
it("noopFormatBridge.date coerces epoch number to string", {
timeout: 5000,
}, () => {
expect(noopFormatBridge.date?.(0, "en-GB", "short")).toBe("0");
});
it("noopFormatBridge.time coerces Date to string", {
timeout: 5000,
}, () => {
const d = new Date(0);
expect(noopFormatBridge.time?.(d, "en-GB", true)).toBe(String(d));
});
it("noopFormatBridge.relativeTime coerces number to string", {
timeout: 5000,
}, () => {
expect(noopFormatBridge.relativeTime?.(-1, "en-GB", "long")).toBe("-1");
});
it("noopFormatBridge is frozen — mutation attempts throw", {
timeout: 5000,
}, () => {
expect(() => {
(noopFormatBridge as { number?: unknown }).number = undefined;
}).toThrow();
});
});

View File

@@ -0,0 +1,77 @@
import type {
DateStyle,
NumberFormatStyle,
RelativeTimeStyle,
} from "./format-bridge.types.js";
/**
* Dependency-injection seam between this ICU parser
* package and the per-formatter packages
* (`@suaveplan/i18n-formatter-number`, `-date`, `-currency`,
* `-relative-time`, …).
*
* The renderer (`renderNodes`) consults the bridge whenever an ICU
* format placeholder (`{value, number}`, `{amount, number,
* currency=USD}`, `{date, date, short}`, `{ts, time, long}`,
* `{delta, relativeTime}`) needs to materialise a localised string.
* Every method is OPTIONAL: when the method is undefined OR the
* entire bridge is undefined, the renderer falls back to
* `String(value)` and never throws — the parser remains usable in
* isolation, with no Intl dependency.
*
* The slim `@suaveplan/i18n` core (Wave 6) constructs a default
* bridge by importing the formatter packages and threads it through
* every render call so consumers who use `createI18n({ ... })` get
* locale-aware output automatically. Direct `MessageParser` users
* MAY pass their own bridge or accept the no-Intl fallback.
*/
export interface FormatBridge {
/** Format a number with the given locale and optional style. */
number?(
value: number,
locale: string,
style?: NumberFormatStyle | string
): string;
/** Format a monetary amount with an ISO 4217 currency code. */
currency?(value: number, currency: string, locale: string): string;
/** Format a Date or epoch-millis with the given locale and style. */
date?(value: Date | number, locale: string, style?: DateStyle): string;
/**
* Format the time portion of a Date or epoch-millis. `longForm`
* SHOULD include seconds when `true`.
*/
time?(value: Date | number, locale: string, longForm?: boolean): string;
/** Format a relative time delta against the current locale. */
relativeTime?(
value: number,
locale: string,
style?: RelativeTimeStyle
): string;
}
/**
* Identity bridge whose every method coerces the input to its
* default `String(value)` representation. Surfaced as a public
* export so:
*
* 1. Consumers who do not need locale-aware formatting can pass
* `noopFormatBridge` explicitly, signalling intent at the
* call-site.
* 2. Tests that assert the renderer's no-Intl path can do so
* without manually constructing a bridge object.
* 3. Downstream packages can clone-and-extend by spreading
* `noopFormatBridge` into a partial implementation that
* overrides only the methods they care about.
*
* The bridge is `Object.freeze`d so accidental mutation surfaces as
* a TypeError at call-time rather than silently corrupting the
* shared singleton.
*/
export const noopFormatBridge: Readonly<FormatBridge> = Object.freeze({
number: (value: number): string => String(value),
currency: (value: number, currency: string): string =>
`${currency} ${String(value)}`,
date: (value: Date | number): string => String(value),
time: (value: Date | number): string => String(value),
relativeTime: (value: number): string => String(value),
}) as Readonly<FormatBridge>;

View File

@@ -0,0 +1,38 @@
/**
* Style enums consumed by {@link FormatBridge} method signatures.
*
* Defined as a separate types file so the renderer and bridge can
* import them without dragging in any runtime symbols, and so the
* slim core (Wave 6) can wire formatter packages by referencing the
* style names without re-declaring them.
*/
/**
* Number-format style accepted by {@link FormatBridge.number}.
*
* Maps the ICU `{var, number, <style>}` placeholder onto the three
* style families surfaced by `Intl.NumberFormat`. `decimal` is the
* default; `percent` produces locale-correct percent formatting; and
* `integer` rounds and disables fraction digits. Notation styles
* (`compact`, `scientific`, `engineering`) are passed through as raw
* strings — bridges that recognise them MAY render natively;
* otherwise the renderer falls back to {@link String}.
*/
export type NumberFormatStyle = "decimal" | "percent" | "integer";
/**
* Date-format style accepted by {@link FormatBridge.date}.
*
* Mirrors the four ICU date presets (`short` / `medium` / `long` /
* `full`). Bridges typically materialise these as
* `Intl.DateTimeFormat` option bundles.
*/
export type DateStyle = "short" | "medium" | "long" | "full";
/**
* Relative-time style accepted by {@link FormatBridge.relativeTime}.
*
* Mirrors `Intl.RelativeTimeFormat`'s `style` option (`long` /
* `short` / `narrow`).
*/
export type RelativeTimeStyle = "long" | "short" | "narrow";

View File

@@ -0,0 +1,35 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export {
type FormatBridge,
noopFormatBridge,
} from "./format-bridge.js";
export type {
DateStyle,
NumberFormatStyle,
RelativeTimeStyle,
} from "./format-bridge.types.js";
export {
extractVariables,
findUnknownArgTypes,
MessageParser,
preprocessIcuApostrophes,
type RenderContext,
type RenderOptions,
renderNodes,
renderToChunks,
} from "./message.js";
export type {
MessageNode,
ParsedMessage,
ParserOptions,
PluralNode,
SelectNode,
SelectOrdinalNode,
TagNode,
TextNode,
TypedArgumentNode,
ValidationIssue,
ValidationResult,
VariableNode,
} from "./message.types.js";

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,341 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FormatBridge } from "./format-bridge.js";
import type { MessageNode } from "./message.types.js";
import { renderNodes } from "./message-render.js";
describe("renderNodes — FormatBridge dispatch", () => {
it("format placeholder dispatches to bridge", { timeout: 5000 }, () => {
const bridge: FormatBridge = {
number: () => "FORMATTED",
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "value",
argType: "number",
style: "decimal",
},
];
const result = renderNodes(
ast,
{ value: 42 },
{
locale: "en-GB",
formatBridge: bridge,
}
);
expect(result).toBe("FORMATTED");
});
it("missing bridge falls back to String", { timeout: 5000 }, () => {
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "value",
argType: "number",
style: "decimal",
},
];
const result = renderNodes(ast, { value: 42 }, { locale: "en-GB" });
expect(result).toBe("42");
});
it("bridge missing one method falls back", { timeout: 5000 }, () => {
// formatBridge.date is undefined but formatBridge.number is defined.
const bridge: FormatBridge = {
number: () => "NUM",
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "date",
argType: "date",
style: "short",
},
];
const date = new Date(0);
const result = renderNodes(
ast,
{ date },
{
locale: "en-GB",
formatBridge: bridge,
}
);
// Falls back to String(date)
expect(result).toBe(String(date));
});
it("missing variable throws INTERPOLATION_ERROR", {
timeout: 5000,
}, () => {
// Per the spec: missing-variable in renderNodes should throw
// I18nError(I18N.INTERPOLATION_ERROR). The pre-split monolith
// emitted "" for missing variables; the new package surface
// promotes this to an error so consumers cannot silently ship
// empty-string substitutions to users.
const ast: MessageNode[] = [
{
type: "variable",
name: "name",
},
];
try {
renderNodes(ast, {}, {});
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.INTERPOLATION_ERROR"
);
}
});
it("missing-variable error carries the variable name", {
timeout: 5000,
}, () => {
const ast: MessageNode[] = [{ type: "variable", name: "name" }];
try {
renderNodes(ast, {}, {});
throw new Error("expected throw");
} catch (err) {
expect((err as Error).message).toContain("name");
}
});
it("present-with-undefined value still renders empty (not throw)", {
timeout: 5000,
}, () => {
// When `name` IS in values but is null/undefined, the renderer
// emits "" — only ABSENT keys throw.
const ast: MessageNode[] = [{ type: "variable", name: "name" }];
expect(renderNodes(ast, { name: null }, {})).toBe("");
expect(renderNodes(ast, { name: undefined }, {})).toBe("");
});
it("renderNodes accepts a function pluralSelect", { timeout: 5000 }, () => {
// The legacy callable form must continue to work.
const ast: MessageNode[] = [
{
type: "plural",
variable: "n",
offset: 0,
choices: {
few: [{ type: "text", value: "few" }],
other: [{ type: "text", value: "other" }],
},
},
];
expect(renderNodes(ast, { n: 3 }, () => "few")).toBe("few");
});
it("currency placeholder dispatches to bridge.currency", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
currency: (v, c) => `${c}-${v}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "amount",
argType: "number",
style: "currency/USD",
},
];
expect(
renderNodes(
ast,
{ amount: 50 },
{
locale: "en",
formatBridge: bridge,
}
)
).toBe("USD-50");
});
it("currency without bridge falls back to `<code> <value>`", {
timeout: 5000,
}, () => {
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "amount",
argType: "number",
style: "currency/EUR",
},
];
expect(renderNodes(ast, { amount: 99 }, { locale: "en" })).toBe(
"EUR 99"
);
});
it("time placeholder dispatches with longForm flag", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
time: (_v, _l, longForm) => (longForm ? "LONG" : "SHORT"),
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "at",
argType: "time",
style: "long",
},
];
expect(
renderNodes(
ast,
{ at: new Date(0) },
{ locale: "en", formatBridge: bridge }
)
).toBe("LONG");
});
it("time placeholder without bridge falls back to String(date)", {
timeout: 5000,
}, () => {
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "at",
argType: "time",
style: "short",
},
];
const d = new Date(0);
expect(renderNodes(ast, { at: d }, { locale: "en" })).toBe(String(d));
});
it("date placeholder maps default style to medium", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
date: (_v, _l, style) => `STYLE:${style}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "at",
argType: "date",
style: "",
},
];
expect(
renderNodes(
ast,
{ at: new Date(0) },
{ locale: "en", formatBridge: bridge }
)
).toBe("STYLE:medium");
});
it("date placeholder passes through known styles", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
date: (_v, _l, style) => `STYLE:${style}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "at",
argType: "date",
style: "full",
},
];
expect(
renderNodes(
ast,
{ at: new Date(0) },
{ locale: "en", formatBridge: bridge }
)
).toBe("STYLE:full");
});
it("number style 'integer' is forwarded to bridge", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
number: (_v, _l, style) => `STYLE:${style}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "n",
argType: "number",
style: "integer",
},
];
expect(
renderNodes(ast, { n: 1 }, { locale: "en", formatBridge: bridge })
).toBe("STYLE:integer");
});
it("number style 'compact' (raw string) is forwarded to bridge", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
number: (_v, _l, style) => `STYLE:${style}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "n",
argType: "number",
style: "compact",
},
];
expect(
renderNodes(ast, { n: 1 }, { locale: "en", formatBridge: bridge })
).toBe("STYLE:compact");
});
it("number with empty style passes undefined style to bridge", {
timeout: 5000,
}, () => {
const bridge: FormatBridge = {
number: (_v, _l, style) => `STYLE:${String(style)}`,
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "n",
argType: "number",
style: "",
},
];
expect(
renderNodes(ast, { n: 7 }, { locale: "en", formatBridge: bridge })
).toBe("STYLE:undefined");
});
it("date placeholder accepts string input", { timeout: 5000 }, () => {
const bridge: FormatBridge = {
date: (v) => (v instanceof Date ? "DATE" : "OTHER"),
};
const ast: MessageNode[] = [
{
type: "typed-argument",
name: "at",
argType: "date",
style: "short",
},
];
expect(
renderNodes(
ast,
{ at: "2026-01-01T00:00:00Z" },
{ locale: "en", formatBridge: bridge }
)
).toBe("DATE");
});
it("renderNodes accepts undefined options (full default path)", {
timeout: 5000,
}, () => {
const ast: MessageNode[] = [{ type: "text", value: "hi" }];
expect(renderNodes(ast, {})).toBe("hi");
});
});

View File

@@ -0,0 +1,318 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import type { FormatBridge } from "./format-bridge.js";
import type { DateStyle, NumberFormatStyle } from "./format-bridge.types.js";
import type { MessageNode } from "./message.types.js";
/** Optional rendering context: locale + plural selector. */
export interface RenderContext {
readonly locale?: string;
readonly pluralSelect?: (count: number, category: string) => string;
readonly ordinalSelect?: (count: number) => string;
}
/**
* Extended render options accepted by {@link renderNodes}. Bundles
* the existing {@link RenderContext} with an optional
* {@link FormatBridge} that the renderer consults whenever an ICU
* format placeholder (`{value, number}`, `{amount, number,
* currency=USD}`, `{date, date, short}`, `{ts, time, long}`) needs
* to materialise a localised string.
*
* If `formatBridge` is undefined OR the relevant method on the
* bridge is undefined, the renderer falls back to `String(value)`
* and never throws.
*/
export interface RenderOptions extends RenderContext {
readonly formatBridge?: FormatBridge;
}
/**
* Map an ICU `{var, number, <style>}` style sub-token onto the
* {@link FormatBridge.number} `style` enum. Unknown styles fall
* through as raw strings so a bridge that recognises notation
* styles (`compact` / `scientific` / `engineering`) MAY render
* natively. The renderer never throws on unknown styles.
*/
function mapNumberStyle(style: string): NumberFormatStyle | string | undefined {
if (style === "decimal") return "decimal";
if (style === "percent") return "percent";
if (style === "integer") return "integer";
if (style === "") return undefined;
return style;
}
/**
* Map an ICU `{var, date, <style>}` style sub-token onto the
* {@link FormatBridge.date} `style` enum. Empty / missing style
* defaults to `medium`, mirroring the monolith's
* `formatDate(..., { preset: "medium" })` baseline.
*/
function mapDateStyle(style: string): DateStyle {
if (style === "short" || style === "long" || style === "full") {
return style;
}
return "medium";
}
/**
* Decode the sentinel codepoints emitted by `preprocessIcuApostrophes`
* back to their literal characters. Inlined here so `message-render` does
* not import `message` (which would create a circular module dependency).
*
* - U+0001 → `'`
* - U+0002 → `{`
* - U+0003 → `}`
* - U+0004 → `#`
*/
function decodeSentinels(text: string): string {
return text
.replaceAll("", "'")
.replaceAll("", "{")
.replaceAll("", "}")
.replaceAll("", "#");
}
/**
* Extract all unique variable names from a flat list of AST nodes.
* Recurses into plural / selectordinal / select choice branches and tag
* children. Tag names are NOT variable names — they're rendered through
* the same `values` map but are not interpolation placeholders.
*/
export function extractVariables(nodes: MessageNode[]): string[] {
const vars: string[] = [];
for (const node of nodes) {
if (node.type === "variable" && node.name !== "#") {
if (!vars.includes(node.name)) vars.push(node.name);
} else if (
node.type === "plural" ||
node.type === "selectordinal" ||
node.type === "select"
) {
if (!vars.includes(node.variable)) vars.push(node.variable);
for (const choice of Object.values(node.choices)) {
for (const v of extractVariables(choice)) {
if (!vars.includes(v)) vars.push(v);
}
}
} else if (node.type === "typed-argument") {
if (!vars.includes(node.name)) vars.push(node.name);
} else if (node.type === "tag") {
for (const v of extractVariables(node.children)) {
if (!vars.includes(v)) vars.push(v);
}
}
}
return vars;
}
/**
* Render a list of AST nodes to a string using the supplied values.
*
* The third argument may be a legacy `pluralSelect` callback (preserved for
* backward compatibility) OR a `RenderOptions` carrying locale + selectors
* + an optional {@link FormatBridge}. Text nodes are decoded via
* `decodeSentinels` so ICU apostrophe-quoted literals round-trip back to
* their original characters before emission.
*
* Typed-argument nodes (`{var, number}`, `{var, number, currency/USD}`,
* `{var, date, short}`, `{var, time, long}`) route through `options.formatBridge`
* when supplied. If `formatBridge` is undefined OR the relevant method
* on the bridge is undefined, the renderer falls back to `String(value)`
* (or `${currency} ${String(value)}` for currency) and SHALL NOT throw.
*/
export function renderNodes(
nodes: MessageNode[],
values: Record<string, unknown>,
contextOrPluralSelect?:
| RenderOptions
| RenderContext
| ((count: number, category: string) => string)
): string {
const options: RenderOptions =
typeof contextOrPluralSelect === "function"
? { pluralSelect: contextOrPluralSelect }
: (contextOrPluralSelect ?? {});
return renderInternal(nodes, values, options);
}
function renderInternal(
nodes: MessageNode[],
values: Record<string, unknown>,
context: RenderOptions
): string {
let result = "";
for (const node of nodes) {
switch (node.type) {
case "text":
result += decodeSentinels(node.value);
break;
case "variable":
if (node.name === "#") {
result += String(values["#"] ?? "");
} else if (!(node.name in values)) {
// Per the i18n-icu-parser spec, an absent variable
// (vs. a present-but-null/undefined value) is a
// hard interpolation failure — emitting empty
// string would silently ship blank substitutions
// to end users. Present-with-null/undefined keys
// continue to render as "" (callers can model
// optional values that way).
throw new I18nError(
I18N_CODES.INTERPOLATION_ERROR,
`Missing variable '${node.name}' in interpolation values`,
{ context: { variable: node.name } }
);
} else {
const val = values[node.name];
result +=
val !== null && val !== undefined ? String(val) : "";
}
break;
case "plural": {
const count = Number(values[node.variable] ?? 0);
const adjusted = count - node.offset;
const cat = context.pluralSelect
? context.pluralSelect(adjusted, "cardinal")
: adjusted === 1
? "one"
: "other";
const exactKey = `=${count}`;
const choiceNodes =
node.choices[exactKey] ??
node.choices[cat] ??
node.choices["other"] ??
[];
result += renderInternal(
choiceNodes,
{ ...values, "#": adjusted },
context
);
break;
}
case "selectordinal": {
const count = Number(values[node.variable] ?? 0);
const adjusted = count - node.offset;
const cat = context.ordinalSelect
? context.ordinalSelect(adjusted)
: context.pluralSelect
? context.pluralSelect(adjusted, "ordinal")
: "other";
const exactKey = `=${count}`;
const choiceNodes =
node.choices[exactKey] ??
node.choices[cat] ??
node.choices["other"] ??
[];
result += renderInternal(
choiceNodes,
{ ...values, "#": adjusted },
context
);
break;
}
case "select": {
const rawVal = values[node.variable];
const val =
rawVal !== null && rawVal !== undefined
? String(rawVal)
: "";
const choiceNodes =
node.choices[val] ?? node.choices["other"] ?? [];
result += renderInternal(choiceNodes, values, context);
break;
}
case "typed-argument":
result += renderTypedArgument(node, values, context);
break;
case "tag": {
const fn = values[node.name];
const inner = renderInternal(node.children, values, context);
if (typeof fn === "function") {
result += String(
(fn as (chunks: string) => unknown)(inner)
);
} else {
result += inner;
}
break;
}
}
}
return result;
}
/**
* Render an AST to an `Array<string | T>` where every tag node is mapped
* via `chunkValues[name]`. Used by `MessageParser.formatChunks` and the
* React `<Trans>` component.
*
* Adjacent string segments are NOT merged — the array preserves whatever
* granularity the renderer produced so the React diff can map placeholders
* to keyed JSX elements deterministically.
*/
export function renderToChunks<T>(
nodes: MessageNode[],
values: Record<string, unknown>,
chunkValues: Record<string, (chunks: Array<string | T>) => T>,
context: RenderOptions = {}
): Array<string | T> {
const out: Array<string | T> = [];
for (const node of nodes) {
if (node.type === "tag") {
const inner = renderToChunks<T>(
node.children,
values,
chunkValues,
context
);
const fn = chunkValues[node.name];
if (fn) {
out.push(fn(inner));
} else {
// Tag without a chunk handler: fall through to string render.
out.push(renderInternal(node.children, values, context));
}
} else {
// For non-tag nodes, defer to the string renderer for that
// segment. This loses zero information because tags are the
// only node type that produces chunked output.
out.push(renderInternal([node], values, context));
}
}
return out;
}
function renderTypedArgument(
node: Extract<MessageNode, { type: "typed-argument" }>,
values: Record<string, unknown>,
context: RenderOptions
): string {
const raw = values[node.name];
const locale = context.locale ?? "en-US";
const bridge = context.formatBridge;
if (node.argType === "date") {
const date = raw instanceof Date ? raw : new Date(raw as string);
const style = mapDateStyle(node.style);
const formatted = bridge?.date?.(date, locale, style);
return formatted ?? String(date);
}
if (node.argType === "time") {
const date = raw instanceof Date ? raw : new Date(raw as string);
const longForm = node.style === "long";
const formatted = bridge?.time?.(date, locale, longForm);
return formatted ?? String(date);
}
// number
const value = Number(raw ?? 0);
if (node.style.startsWith("currency/")) {
const code = node.style.slice("currency/".length).toUpperCase();
const formatted = bridge?.currency?.(value, code, locale);
return formatted ?? `${code} ${String(value)}`;
}
const numberStyle = mapNumberStyle(node.style);
const formatted =
numberStyle === undefined
? bridge?.number?.(value, locale)
: bridge?.number?.(value, locale, numberStyle);
return formatted ?? String(value);
}

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,890 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { FormatBridge } from "./format-bridge.js";
import { extractVariables, MessageParser, renderNodes } from "./message.js";
/**
* Test-local Intl-backed bridge. Mirrors what the slim `@suaveplan/i18n`
* core (Wave 6) wires through `createI18n`. Declared here (not imported
* from a formatter package) so the parser package stays a true leaf —
* the test file's bridge is part of the spec, not a runtime dep.
*/
const intlBridge: FormatBridge = {
number: (value, locale, style) => {
const opts: Intl.NumberFormatOptions = {};
if (style === "percent") opts.style = "percent";
else if (style === "decimal") opts.style = "decimal";
else if (style === "compact") opts.notation = "compact";
else if (style === "scientific") opts.notation = "scientific";
else if (style === "engineering") opts.notation = "engineering";
return new Intl.NumberFormat(locale, opts).format(value);
},
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: {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
medium: {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
},
long: {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
},
full: {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "long",
},
};
return new Intl.DateTimeFormat(locale, presets[style]).format(d);
},
time: (value, locale, longForm) => {
const d = value instanceof Date ? value : new Date(value);
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "numeric",
...(longForm ? { second: "numeric" } : {}),
}).format(d);
},
};
describe("extractVariables", () => {
it("returns empty for text-only nodes", { timeout: 5000 }, () => {
expect(extractVariables([{ type: "text", value: "hello" }])).toEqual(
[]
);
});
it("returns unique variable names", { timeout: 5000 }, () => {
const nodes = [
{ type: "variable" as const, name: "a" },
{ type: "variable" as const, name: "b" },
{ type: "variable" as const, name: "a" },
];
expect(extractVariables(nodes)).toEqual(["a", "b"]);
});
it("excludes # sentinel", { timeout: 5000 }, () => {
expect(
extractVariables([{ type: "variable" as const, name: "#" }])
).toEqual([]);
});
it("recurses into plural choices", { timeout: 5000 }, () => {
const nodes = [
{
type: "plural" as const,
variable: "count",
offset: 0,
choices: { one: [{ type: "variable" as const, name: "name" }] },
},
];
const vars = extractVariables(nodes);
expect(vars).toContain("count");
expect(vars).toContain("name");
});
it("recurses into select choices", { timeout: 5000 }, () => {
const nodes = [
{
type: "select" as const,
variable: "gender",
choices: {
male: [{ type: "variable" as const, name: "name" }],
},
},
];
const vars = extractVariables(nodes);
expect(vars).toContain("gender");
expect(vars).toContain("name");
});
it("deduplicates variables across plural and outer scope", {
timeout: 5000,
}, () => {
const nodes = [
{ type: "variable" as const, name: "count" },
{
type: "plural" as const,
variable: "count",
offset: 0,
choices: {
one: [{ type: "variable" as const, name: "count" }],
},
},
];
const vars = extractVariables(nodes);
expect(vars.filter((v) => v === "count")).toHaveLength(1);
});
});
describe("renderNodes", () => {
it("renders text nodes", { timeout: 5000 }, () => {
expect(renderNodes([{ type: "text", value: "hello" }], {})).toBe(
"hello"
);
});
it("renders variable nodes", { timeout: 5000 }, () => {
expect(
renderNodes([{ type: "variable", name: "name" }], { name: "World" })
).toBe("World");
});
it("renders # in plural context", { timeout: 5000 }, () => {
expect(renderNodes([{ type: "variable", name: "#" }], { "#": 5 })).toBe(
"5"
);
});
it("renders # as empty string when not in values", {
timeout: 5000,
}, () => {
expect(renderNodes([{ type: "variable", name: "#" }], {})).toBe("");
});
it("renders null/undefined as empty", { timeout: 5000 }, () => {
expect(
renderNodes([{ type: "variable", name: "x" }], { x: null })
).toBe("");
expect(
renderNodes([{ type: "variable", name: "x" }], { x: undefined })
).toBe("");
});
it("renders select nodes", { timeout: 5000 }, () => {
const nodes = [
{
type: "select" as const,
variable: "g",
choices: {
male: [{ type: "text" as const, value: "He" }],
other: [{ type: "text" as const, value: "They" }],
},
},
];
expect(renderNodes(nodes, { g: "male" })).toBe("He");
expect(renderNodes(nodes, { g: "other" })).toBe("They");
expect(renderNodes(nodes, { g: "unknown" })).toBe("They");
});
it("renders plural nodes with default rule", { timeout: 5000 }, () => {
const nodes = [
{
type: "plural" as const,
variable: "n",
offset: 0,
choices: {
one: [{ type: "text" as const, value: "one" }],
other: [{ type: "text" as const, value: "many" }],
},
},
];
expect(renderNodes(nodes, { n: 1 })).toBe("one");
expect(renderNodes(nodes, { n: 5 })).toBe("many");
});
it("treats missing plural variable as 0", { timeout: 5000 }, () => {
const nodes = [
{
type: "plural" as const,
variable: "n",
offset: 0,
choices: {
other: [{ type: "text" as const, value: "zero-or-many" }],
},
},
];
expect(renderNodes(nodes, {})).toBe("zero-or-many");
});
it("treats missing select variable as empty string", {
timeout: 5000,
}, () => {
const nodes = [
{
type: "select" as const,
variable: "g",
choices: {
other: [{ type: "text" as const, value: "They" }],
},
},
];
expect(renderNodes(nodes, {})).toBe("They");
});
it("renders plural node via exactKey match", { timeout: 5000 }, () => {
const nodes = [
{
type: "plural" as const,
variable: "n",
offset: 0,
choices: {
"=2": [{ type: "text" as const, value: "exactly two" }],
other: [{ type: "text" as const, value: "many" }],
},
},
];
expect(renderNodes(nodes, { n: 2 })).toBe("exactly two");
});
it("renders empty string when no plural choice matches", {
timeout: 5000,
}, () => {
const nodes = [
{
type: "plural" as const,
variable: "n",
offset: 0,
choices: {
few: [{ type: "text" as const, value: "few" }],
},
},
];
expect(renderNodes(nodes, { n: 2 }, () => "zero")).toBe("");
});
it("renders empty string when no select choice matches and no other", {
timeout: 5000,
}, () => {
const nodes = [
{
type: "select" as const,
variable: "g",
choices: {
male: [{ type: "text" as const, value: "He" }],
},
},
];
expect(renderNodes(nodes, { g: "female" })).toBe("");
});
});
describe("MessageParser", () => {
const parser = new MessageParser();
it("parses simple text", { timeout: 5000 }, () => {
const r = parser.parse("Hello world");
expect(r.nodes).toHaveLength(1);
expect(r.nodes[0]).toEqual({ type: "text", value: "Hello world" });
expect(r.variables).toEqual([]);
});
it("parses simple variable", { timeout: 5000 }, () => {
const r = parser.parse("Hello, {name}!");
expect(r.variables).toContain("name");
});
it("parses plural expression", { timeout: 5000 }, () => {
const r = parser.parse("{count, plural, one {# item} other {# items}}");
expect(r.variables).toContain("count");
expect(r.nodes[0]?.type).toBe("plural");
});
it("parses select expression", { timeout: 5000 }, () => {
const r = parser.parse(
"{g, select, male {He} female {She} other {They}}"
);
expect(r.variables).toContain("g");
expect(r.nodes[0]?.type).toBe("select");
});
it("format() renders in one step", { timeout: 5000 }, () => {
expect(
parser.format("{n, plural, one {1 item} other {# items}}", { n: 3 })
).toBe("3 items");
});
it("handles exact count match (=2)", { timeout: 5000 }, () => {
const text = parser.format(
"{n, plural, =0 {none} =2 {exactly two} other {other}}",
{ n: 2 }
);
expect(text).toBe("exactly two");
});
it("uses pluralSelect callback", { timeout: 5000 }, () => {
const text = parser.format(
"{n, plural, few {few} other {other}}",
{ n: 3 },
() => "few"
);
expect(text).toBe("few");
});
it("handles offset in plural", { timeout: 5000 }, () => {
const text = parser.format(
"{n, plural, offset:1 one {# person} other {# people}}",
{ n: 2 }
);
expect(text).toBe("1 person");
});
it("ICU syntax disabled: simple variable only", { timeout: 5000 }, () => {
const simpleParser = new MessageParser({ icuSyntax: false });
const text = simpleParser.format("{name}", { name: "World" });
expect(text).toBe("World");
});
it("non-ICU mode with custom delimiters", { timeout: 5000 }, () => {
const p = new MessageParser({
icuSyntax: false,
variablePrefix: "<<",
variableSuffix: ">>",
});
const text = p.format("Hello <<name>>!", { name: "World" });
expect(text).toBe("Hello World!");
});
it("nested ICU expressions", { timeout: 5000 }, () => {
const text = parser.format(
"{g, select, male {{n, plural, one {He has 1 item} other {He has # items}}} other {Other}}",
{ g: "male", n: 3 }
);
expect(text).toBe("He has 3 items");
});
it("parse then render with render()", { timeout: 5000 }, () => {
const parsed = parser.parse("Hi {name}");
expect(parser.render(parsed, { name: "Bob" })).toBe("Hi Bob");
});
it("handles empty message", { timeout: 5000 }, () => {
const r = parser.parse("");
expect(r.nodes).toHaveLength(0);
});
it("treats unknown ICU type as plain variable", { timeout: 5000 }, () => {
const text = parser.format("{name, magic}", { name: "Alice" });
expect(text).toBe("Alice");
});
it("renders {amount, number, currency/USD}", { timeout: 5000 }, () => {
const result = parser.format(
"Total: {amount, number, currency/USD}",
{ amount: 1234.5 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toContain("$1,234.50");
});
it("renders {at, date, medium}", { timeout: 5000 }, () => {
const result = parser.format(
"Date: {at, date, medium}",
{ at: new Date(Date.UTC(2026, 4, 4, 12, 0, 0)) },
{ locale: "en-GB", formatBridge: intlBridge }
);
expect(result).toContain("2026");
expect(result).toContain("May");
});
it("renders {n, number, percent}", { timeout: 5000 }, () => {
const result = parser.format(
"{n, number, percent}",
{ n: 0.255 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toMatch(/^25%|^26%$/);
});
it("renders {n, number, compact}", { timeout: 5000 }, () => {
const result = parser.format(
"{n, number, compact}",
{ n: 1234 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toBe("1.2K");
});
it("selectordinal selects 'few' for English '3'", {
timeout: 5000,
}, () => {
const ordinal = (n: number) => {
if (n % 10 === 3 && n % 100 !== 13) return "few";
if (n % 10 === 1 && n % 100 !== 11) return "one";
if (n % 10 === 2 && n % 100 !== 12) return "two";
return "other";
};
const result = parser.format(
"{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place",
{ place: 3 },
{ ordinalSelect: ordinal }
);
expect(result).toBe("3rd place");
});
it("selectordinal falls back to pluralSelect when ordinalSelect missing", {
timeout: 5000,
}, () => {
const result = parser.format(
"{place, selectordinal, one {#st} other {#th}}",
{ place: 1 },
{
pluralSelect: (n, type) =>
type === "ordinal" && n === 1 ? "one" : "other",
}
);
expect(result).toBe("1st");
});
it("selectordinal falls back to 'other' when no selector supplied", {
timeout: 5000,
}, () => {
const result = parser.format(
"{place, selectordinal, one {#st} other {#th}}",
{ place: 1 }
);
expect(result).toBe("1th");
});
it("extractVariables dedups typed-argument names appearing twice", {
timeout: 5000,
}, () => {
const parsed = parser.parse(
"{x, number, decimal} and {x, number, percent}"
);
expect(parsed.variables).toEqual(["x"]);
});
it("extractVariables dedups variables appearing inside multiple tags", {
timeout: 5000,
}, () => {
const parsed = parser.parse("<a>{x}</a> <b>{x}</b>");
expect(parsed.variables).toEqual(["x"]);
});
it("selectordinal selects 'one' for 1 and 'two' for 2", {
timeout: 5000,
}, () => {
const ordinal = (n: number) => {
if (n % 10 === 1 && n % 100 !== 11) return "one";
if (n % 10 === 2 && n % 100 !== 12) return "two";
if (n % 10 === 3 && n % 100 !== 13) return "few";
return "other";
};
const tpl =
"{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}";
expect(
parser.format(tpl, { place: 1 }, { ordinalSelect: ordinal })
).toBe("1st");
expect(
parser.format(tpl, { place: 2 }, { ordinalSelect: ordinal })
).toBe("2nd");
});
it("handles malformed plural choice with no opening brace", {
timeout: 5000,
}, () => {
const result = parser.parse("{n, plural, one}");
expect(result.nodes).toBeDefined();
});
it("handles malformed select choice with no opening brace", {
timeout: 5000,
}, () => {
const result = parser.parse("{g, select, male}");
expect(result.nodes).toBeDefined();
});
it("handles offset: with empty value", { timeout: 5000 }, () => {
const text = parser.format(
"{n, plural, offset: one {# item} other {# items}}",
{ n: 1 }
);
expect(text).toBeDefined();
});
it("handles trailing space before closing brace in plural", {
timeout: 5000,
}, () => {
const text = parser.format("{n, plural, one {X} }", { n: 1 });
expect(text).toBe("X");
});
it("handles trailing space before closing brace in select", {
timeout: 5000,
}, () => {
const text = parser.format("{g, select, male {He} }", { g: "male" });
expect(text).toBe("He");
});
it("handles space after opening brace in ICU expression", {
timeout: 5000,
}, () => {
const text = parser.format("Hello, { name }!", { name: "World" });
expect(text).toBe("Hello, World!");
});
});
describe("ICU tag interpolation", () => {
const parser = new MessageParser();
it("tag formats inline as a string when value is a function", {
timeout: 5000,
}, () => {
const result = parser.format("Hello <bold>world</bold>!", {
bold: (chunks: string) => `**${chunks}**`,
});
expect(result).toBe("Hello **world**!");
});
it("nested tags format correctly", { timeout: 5000 }, () => {
const result = parser.format("<a>read <b>more</b> here</a>", {
a: (c: string) => `[${c}]`,
b: (c: string) => `*${c}*`,
});
expect(result).toBe("[read *more* here]");
});
it("tag without a function value renders inner content as-is", {
timeout: 5000,
}, () => {
const result = parser.format("Hello <bold>world</bold>!", {});
expect(result).toBe("Hello world!");
});
it("tag interpolation alongside variable substitution", {
timeout: 5000,
}, () => {
const result = parser.format("Hello <b>{name}</b>!", {
name: "Alice",
b: (c: string) => `**${c}**`,
});
expect(result).toBe("Hello **Alice**!");
});
it("formatChunks falls back to string render when chunk handler missing", {
timeout: 5000,
}, () => {
const result = parser.formatChunks<string>(
"Hello <bold>world</bold>!",
{},
{}
);
const flat = result.join("");
expect(flat).toBe("Hello world!");
});
it("formatChunks returns mixed string + framework-node array", {
timeout: 5000,
}, () => {
type Chunk = { tag: string; children: Array<string | Chunk> };
const result = parser.formatChunks<Chunk>(
"Hello <bold>world</bold>!",
{},
{ bold: (c) => ({ tag: "b", children: c }) }
);
expect(result).toEqual([
"Hello ",
{ tag: "b", children: ["world"] },
"!",
]);
});
it("`<` not followed by an identifier is rendered literally", {
timeout: 5000,
}, () => {
const result = parser.format("a < b", {});
expect(result).toBe("a < b");
});
it("extractVariables ignores tag names and surfaces tag-children variables", {
timeout: 5000,
}, () => {
const parsed = parser.parse("Hello <b>{name}</b>");
expect(parsed.variables).toEqual(["name"]);
});
});
describe("ICU apostrophe quoting", () => {
const parser = new MessageParser();
it("doubled apostrophe renders as a single literal apostrophe", {
timeout: 5000,
}, () => {
expect(parser.format("it''s here", {})).toBe("it's here");
});
it("quoted run preserves literal braces", {
timeout: 5000,
}, () => {
expect(parser.format("'{literal}' is {actual}", { actual: "X" })).toBe(
"{literal} is X"
);
});
it("standalone apostrophe is rendered literally", {
timeout: 5000,
}, () => {
expect(parser.format("don't", {})).toBe("don't");
});
it("doubled apostrophe inside a quoted run renders as one apostrophe", {
timeout: 5000,
}, () => {
expect(parser.format("'{literal''quoted}'", {})).toBe(
"{literal'quoted}"
);
});
it("apostrophe before # opens a quoted run that escapes the #", {
timeout: 5000,
}, () => {
const result = parser.format("'#' is the count", {});
expect(result).toBe("# is the count");
});
it("plural continues to work alongside apostrophe rules", {
timeout: 5000,
}, () => {
const result = parser.format(
"{count, plural, one {# item} other {# items}}",
{ count: 3 }
);
expect(result).toBe("3 items");
});
});
describe("MessageParser.validate", () => {
const parser = new MessageParser();
it("ok: true on a clean message", { timeout: 5000 }, () => {
expect(parser.validate("Hello {name}").ok).toBe(true);
});
it("ok: true on plural with all required branches", {
timeout: 5000,
}, () => {
expect(
parser.validate("{n, plural, one {# item} other {# items}}").ok
).toBe(true);
});
it("flags MISSING_OTHER_BRANCH on plural with no `other`", {
timeout: 5000,
}, () => {
const r = parser.validate("{n, plural, one {# item}}");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.errors[0]?.code).toBe("MISSING_OTHER_BRANCH");
}
});
it("flags MISSING_OTHER_BRANCH on select with no `other`", {
timeout: 5000,
}, () => {
const r = parser.validate("{g, select, male {He}}");
expect(r.ok).toBe(false);
});
it("flags MISSING_OTHER_BRANCH on selectordinal", {
timeout: 5000,
}, () => {
const r = parser.validate("{n, selectordinal, one {#st} two {#nd}}");
expect(r.ok).toBe(false);
});
it("flags UNBALANCED_BRACE on stray `}`", {
timeout: 5000,
}, () => {
const r = parser.validate("hi}");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.errors.some((e) => e.code === "UNBALANCED_BRACE")).toBe(
true
);
}
});
it("flags UNBALANCED_BRACE on missing `}`", {
timeout: 5000,
}, () => {
const r = parser.validate("hello {name");
expect(r.ok).toBe(false);
});
it("flags UNKNOWN_ARG_TYPE on `{x, magic, foo}`", {
timeout: 5000,
}, () => {
const r = parser.validate("{x, magic, foo}");
expect(r.ok).toBe(false);
if (!r.ok) {
const issue = r.errors.find((e) => e.code === "UNKNOWN_ARG_TYPE");
expect(issue).toBeDefined();
expect(issue?.field).toBe("magic");
}
});
it("selectordinal with no count value defaults to 0", {
timeout: 5000,
}, () => {
const result = parser.format(
"{n, selectordinal, =0 {first} other {nth}}",
{}
);
expect(result).toBe("first");
});
it("selectordinal with no matching choice (no `other`) renders empty", {
timeout: 5000,
}, () => {
const result = parser.format("{n, selectordinal, one {one}}", { n: 5 });
expect(result).toBe("");
});
it("ok: true on a select with `other` branch", {
timeout: 5000,
}, () => {
expect(
parser.validate("{g, select, male {He} female {She} other {They}}")
.ok
).toBe(true);
});
it("opening tag without matching close is treated as literal text", {
timeout: 5000,
}, () => {
const result = parser.format("Hello <b>world", {});
expect(result).toContain("<b>");
});
it("walks tag children during validate", {
timeout: 5000,
}, () => {
const r = parser.validate("<b>{n, plural, one {# item}}</b>");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(
r.errors.some((e) => e.code === "MISSING_OTHER_BRANCH")
).toBe(true);
}
});
});
describe("MessageParser typed-arg + render edge cases", () => {
const parser = new MessageParser();
it("renders typed time argument (short style)", {
timeout: 5000,
}, () => {
const result = parser.format(
"{at, time, short}",
{ at: new Date(Date.UTC(2026, 4, 4, 12, 30, 0)) },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result.length).toBeGreaterThan(0);
});
it("renders typed time argument (long style)", { timeout: 5000 }, () => {
const result = parser.format(
"{at, time, long}",
{ at: new Date(Date.UTC(2026, 4, 4, 12, 30, 45)) },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result.length).toBeGreaterThan(0);
});
it("renders typed number with no style → empty options", {
timeout: 5000,
}, () => {
const result = parser.format(
"{n, number}",
{ n: 1234 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toBe("1,234");
});
it("renders typed date with raw string input", {
timeout: 5000,
}, () => {
const result = parser.format(
"{at, date, short}",
{ at: "2026-05-04T00:00:00Z" },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result.length).toBeGreaterThan(0);
});
it("typed-number defaults to 0 when value is missing", {
timeout: 5000,
}, () => {
const result = parser.format(
"{n, number, decimal}",
{},
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toBe("0");
});
it("renders without locale falls back to en-US", {
timeout: 5000,
}, () => {
const result = parser.format(
"{n, number, decimal}",
{ n: 1 },
{
formatBridge: intlBridge,
}
);
expect(result).toBe("1");
});
it("typed-date with no style defaults to medium", {
timeout: 5000,
}, () => {
const result = parser.format(
"{at, date}",
{ at: new Date(Date.UTC(2026, 4, 4, 0, 0, 0)) },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toContain("2026");
});
it("typed-time accepts a string input", { timeout: 5000 }, () => {
const result = parser.format(
"{at, time}",
{ at: "2026-05-04T12:30:00Z" },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result.length).toBeGreaterThan(0);
});
it("typed-number scientific style", { timeout: 5000 }, () => {
const result = parser.format(
"{n, number, scientific}",
{ n: 123456 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toMatch(/E5/i);
});
it("typed-number engineering style", { timeout: 5000 }, () => {
const result = parser.format(
"{n, number, engineering}",
{ n: 123456 },
{ locale: "en-US", formatBridge: intlBridge }
);
expect(result).toContain("E");
});
});

View File

@@ -0,0 +1,580 @@
import { recordParseDuration } from "../observability/metrics.js";
import type {
MessageNode,
ParsedMessage,
ParserOptions,
ValidationIssue,
ValidationResult,
} from "./message.types.js";
import {
extractVariables,
type RenderContext,
renderNodes,
renderToChunks,
} from "./message-render.js";
export type { RenderContext, RenderOptions } from "./message-render.js";
// Re-export so existing imports of extractVariables/renderNodes from message.ts work.
export {
extractVariables,
renderNodes,
renderToChunks,
} from "./message-render.js";
type ParseResult = { nodes: MessageNode[]; end: number };
type ExprResult = { node: MessageNode; end: number };
function escapeRegexChars(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const TAG_OPEN_RE = /^<([A-Za-z][A-Za-z0-9_-]*)>/;
/**
* Match an opening tag `<name>` at `message[i]`. Returns the tag name and
* the index immediately after the closing `>`, or `undefined` when the
* substring is not a tag (so the parser can treat `<` as a literal).
*/
function matchOpenTag(
message: string,
i: number
): { name: string; end: number } | undefined {
const m = TAG_OPEN_RE.exec(message.slice(i));
if (!m) return undefined;
return { name: m[1] as string, end: i + m[0].length };
}
/**
* Sentinel codepoints used by `preprocessIcuApostrophes` to hide
* literal-quoted content from the ICU parser. Decoded back to their
* original characters by `decodeIcuSentinels` after rendering.
*
* Picked from C0 control range: never appear in human translations.
*/
const SENTINEL_APOSTROPHE = "\u0001";
const SENTINEL_OPEN_BRACE = "\u0002";
const SENTINEL_CLOSE_BRACE = "\u0003";
const SENTINEL_HASH = "\u0004";
/**
* Apply the ICU MessageFormat apostrophe quoting rules:
*
* 1. `''` → literal `'`
* 2. `'<followed by '{' / '}' / "'">` → start of quoted run; content
* inside is literal until the matching `'`
* 3. Standalone `'` (any other context) → literal `'`
*
* Returns a transformed string where literal characters that would
* otherwise be interpreted by the ICU parser are replaced with sentinel
* codepoints. The renderer (`renderNodes`) decodes the sentinels back
* after substitution.
*/
export function preprocessIcuApostrophes(message: string): string {
let out = "";
let i = 0;
while (i < message.length) {
const ch = message[i];
if (ch !== "'") {
out += ch;
i++;
continue;
}
const next = message[i + 1];
// Rule 1: doubled apostrophe → single literal apostrophe.
if (next === "'") {
out += SENTINEL_APOSTROPHE;
i += 2;
continue;
}
// Rule 2: quoted run starts when next char is '{', '}', or '#'
// (the ICU spec also allows leading-quote on '|' for choice
// formats, which we do not support — skip).
if (next === "{" || next === "}" || next === "#") {
i++; // consume opening apostrophe
while (i < message.length) {
const inner = message[i] as string;
if (inner === "'") {
// Doubled apostrophe inside a quoted run is a single
// literal `'`; otherwise the run terminates.
if (message[i + 1] === "'") {
out += SENTINEL_APOSTROPHE;
i += 2;
continue;
}
i++; // consume closing apostrophe
break;
}
if (inner === "{") out += SENTINEL_OPEN_BRACE;
else if (inner === "}") out += SENTINEL_CLOSE_BRACE;
else if (inner === "#") out += SENTINEL_HASH;
else out += inner;
i++;
}
continue;
}
// Rule 3: standalone apostrophe → literal.
out += SENTINEL_APOSTROPHE;
i++;
}
return out;
}
// `decodeIcuSentinels` is inlined in `message-render.ts` to avoid the
// circular import (`message-render` would otherwise depend on `message`).
// The two implementations are kept in sync deliberately.
/**
* ICU-lite message parser.
*
* Parses message strings into an AST supporting:
* - Simple variable interpolation: {name}
* - Plural forms: {count, plural, one {# item} other {# items}}
* - Select forms: {gender, select, male {He} female {She} other {They}}
*
* Non-ICU mode parses only {name} variable substitution using configurable
* delimiters.
*/
export class MessageParser {
protected readonly variablePrefix: string;
protected readonly variableSuffix: string;
protected readonly icuSyntax: boolean;
constructor(options?: ParserOptions) {
this.variablePrefix = options?.variablePrefix ?? "{";
this.variableSuffix = options?.variableSuffix ?? "}";
this.icuSyntax = options?.icuSyntax ?? true;
}
parse(message: string): ParsedMessage {
const t0 = performance.now();
const nodes = this.icuSyntax
? this.parseIcu(preprocessIcuApostrophes(message), 0).nodes
: this.parseSimple(message);
const result = { nodes, variables: extractVariables(nodes) };
recordParseDuration(performance.now() - t0, this.icuSyntax);
return result;
}
render(
parsed: ParsedMessage,
values: Record<string, unknown>,
contextOrPluralSelect?:
| RenderContext
| ((count: number, category: string) => string)
): string {
return renderNodes(parsed.nodes, values, contextOrPluralSelect);
}
format(
message: string,
values: Record<string, unknown>,
contextOrPluralSelect?:
| RenderContext
| ((count: number, category: string) => string)
): string {
return this.render(this.parse(message), values, contextOrPluralSelect);
}
/**
* React-friendly render: returns an `Array<string | T>` with tag
* placeholders replaced by `chunkValues[name](innerChunks)`.
*
* Useful for `<Trans>` in `@suaveplan/react-i18n` where every tag must
* map to a JSX element rather than a string.
*/
formatChunks<T>(
message: string,
values: Record<string, unknown>,
chunkValues: Record<string, (chunks: Array<string | T>) => T>,
context?: RenderContext
): Array<string | T> {
const parsed = this.parse(message);
return renderToChunks<T>(parsed.nodes, values, chunkValues, context);
}
/**
* Statically validate a message string. Detects:
*
* - Unbalanced `{`/`}` braces (after apostrophe-quoted runs are skipped)
* - Missing `other` branch in `plural` / `selectordinal` / `select`
* - Unknown typed-argument types (anything other than `date | time |
* number | plural | selectordinal | select`)
*
* Returns `{ ok: true }` on a clean message, or `{ ok: false, errors }`
* with a non-empty issue list otherwise.
*/
validate(message: string): ValidationResult {
const errors: ValidationIssue[] = [];
const balance = checkBraceBalance(message);
if (!balance.ok) {
errors.push({
code: "UNBALANCED_BRACE",
field: balance.field,
message: balance.message,
});
}
// Parser is permissive (balance is already checked above); ICU
// syntax errors surface through walkValidate's structural checks.
const parsed = this.parse(message);
walkValidate(parsed.nodes, errors);
// Check for unknown typed-argument types that the parser silently
// lowered to plain VariableNode.
errors.push(...findUnknownArgTypes(message));
if (errors.length === 0) return { ok: true };
return { ok: false, errors };
}
private parseSimple(message: string): MessageNode[] {
const nodes: MessageNode[] = [];
const p = escapeRegexChars(this.variablePrefix);
const s = escapeRegexChars(this.variableSuffix);
const regex = new RegExp(`${p}([^}]+?)${s}`, "g");
let lastIndex = 0;
let match: RegExpExecArray | null;
match = regex.exec(message);
while (match !== null) {
if (match.index > lastIndex) {
nodes.push({
type: "text",
value: message.slice(lastIndex, match.index),
});
}
nodes.push({ type: "variable", name: (match[1] as string).trim() });
lastIndex = match.index + match[0].length;
match = regex.exec(message);
}
if (lastIndex < message.length) {
nodes.push({ type: "text", value: message.slice(lastIndex) });
}
return nodes;
}
private parseIcu(message: string, start: number): ParseResult {
const nodes: MessageNode[] = [];
let i = start;
let textStart = i;
const flushText = (until: number) => {
if (until > textStart) {
nodes.push({
type: "text",
value: message.slice(textStart, until),
});
}
};
while (i < message.length) {
const ch = message[i];
if (ch === "{") {
flushText(i);
const result = this.parseIcuExpr(message, i);
nodes.push(result.node);
i = result.end;
textStart = i;
} else if (ch === "}") {
flushText(i);
return { nodes, end: i + 1 };
} else if (ch === "#") {
flushText(i);
nodes.push({ type: "variable", name: "#" });
i++;
textStart = i;
} else if (ch === "<" && message[i + 1] !== "/") {
// Possible opening tag: <name>...</name>
const tagMatch = matchOpenTag(message, i);
if (tagMatch) {
flushText(i);
const closing = `</${tagMatch.name}>`;
const closeIdx = message.indexOf(closing, tagMatch.end);
if (closeIdx !== -1) {
const inner = this.parseIcu(message, tagMatch.end);
// parseIcu walks until matching `}` or end; for tag
// bodies we instead bound on closeIdx.
const innerSlice = message.slice(
tagMatch.end,
closeIdx
);
const childNodes = this.parseIcu(innerSlice, 0).nodes;
nodes.push({
type: "tag",
name: tagMatch.name,
children: childNodes,
});
i = closeIdx + closing.length;
textStart = i;
// Suppress unused var lint without changing flow.
void inner;
continue;
}
}
i++;
} else {
i++;
}
}
flushText(i);
return { nodes, end: i };
}
private parseIcuExpr(message: string, start: number): ExprResult {
let i = start + 1;
while (i < message.length && message[i] === " ") i++;
let name = "";
while (i < message.length && message[i] !== "," && message[i] !== "}") {
name += message[i];
i++;
}
name = name.trim();
if (i >= message.length || message[i] === "}") {
return { node: { type: "variable", name }, end: i + 1 };
}
i++; // skip ","
while (i < message.length && message[i] === " ") i++;
let type = "";
while (i < message.length && message[i] !== "," && message[i] !== "}") {
type += message[i];
i++;
}
type = type.trim();
if (message[i] === ",") i++;
while (i < message.length && message[i] === " ") i++;
if (type === "plural") return this.parsePluralChoices(message, i, name);
if (type === "selectordinal")
return this.parseSelectOrdinalChoices(message, i, name);
if (type === "select") return this.parseSelectChoices(message, i, name);
if (type === "date" || type === "time" || type === "number") {
return this.parseTypedArgument(message, i, name, type);
}
return { node: { type: "variable", name }, end: i + 1 };
}
private parseSelectOrdinalChoices(
message: string,
start: number,
variable: string
): ExprResult {
const result = this.parsePluralChoices(message, start, variable);
// Re-tag as selectordinal; reuse the plural-parser's body.
const plural = result.node as Extract<MessageNode, { type: "plural" }>;
return {
node: {
type: "selectordinal",
variable: plural.variable,
offset: plural.offset,
choices: plural.choices,
},
end: result.end,
};
}
private parseTypedArgument(
message: string,
start: number,
name: string,
argType: "date" | "time" | "number"
): ExprResult {
// After the `,type` we may have an optional `,style}` or just `}`.
let style = "";
let i = start;
while (i < message.length && message[i] !== "}") {
style += message[i];
i++;
}
return {
node: {
type: "typed-argument",
name,
argType,
style: style.trim(),
},
end: i + 1,
};
}
private parsePluralChoices(
message: string,
start: number,
variable: string
): ExprResult {
const choices: Record<string, MessageNode[]> = {};
let offset = 0;
let i = start;
while (i < message.length && message[i] !== "}") {
while (i < message.length && message[i] === " ") i++;
if (message[i] === "}") break;
let key = "";
while (
i < message.length &&
message[i] !== "{" &&
message[i] !== " "
) {
key += message[i];
i++;
}
key = key.trim();
if (key.startsWith("offset:")) {
offset = Number.parseInt(key.split(":")[1] || "0", 10);
while (i < message.length && message[i] === " ") i++;
continue;
}
while (i < message.length && message[i] === " ") i++;
if (message[i] === "{") {
const result = this.parseIcu(message, i + 1);
choices[key] = result.nodes;
i = result.end;
}
}
return {
node: { type: "plural", variable, offset, choices },
end: i + 1,
};
}
private parseSelectChoices(
message: string,
start: number,
variable: string
): ExprResult {
const choices: Record<string, MessageNode[]> = {};
let i = start;
while (i < message.length && message[i] !== "}") {
while (i < message.length && message[i] === " ") i++;
if (message[i] === "}") break;
let key = "";
while (
i < message.length &&
message[i] !== "{" &&
message[i] !== " "
) {
key += message[i];
i++;
}
key = key.trim();
while (i < message.length && message[i] === " ") i++;
if (message[i] === "{") {
const result = this.parseIcu(message, i + 1);
choices[key] = result.nodes;
i = result.end;
}
}
return { node: { type: "select", variable, choices }, end: i + 1 };
}
}
function checkBraceBalance(
message: string
): { ok: true } | { ok: false; field: string; message: string } {
// Run the apostrophe pre-processor first so quoted '{' / '}' don't count.
const stripped = preprocessIcuApostrophes(message);
let depth = 0;
for (const ch of stripped) {
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth < 0) {
return {
ok: false,
field: "message",
message: "Unmatched closing brace `}`",
};
}
}
}
if (depth !== 0) {
return {
ok: false,
field: "message",
message: `Unbalanced braces: ${depth} open brace(s) without close`,
};
}
return { ok: true };
}
const KNOWN_ARG_TYPES = new Set([
"plural",
"selectordinal",
"select",
"date",
"time",
"number",
]);
function walkValidate(nodes: MessageNode[], errors: ValidationIssue[]): void {
for (const node of nodes) {
if (node.type === "plural" || node.type === "selectordinal") {
if (!("other" in node.choices)) {
errors.push({
code: "MISSING_OTHER_BRANCH",
field: node.variable,
message: `${node.type} on '${node.variable}' must have an 'other' branch`,
});
}
for (const choice of Object.values(node.choices)) {
walkValidate(choice, errors);
}
} else if (node.type === "select") {
if (!("other" in node.choices)) {
errors.push({
code: "MISSING_OTHER_BRANCH",
field: node.variable,
message: `select on '${node.variable}' must have an 'other' branch`,
});
}
for (const choice of Object.values(node.choices)) {
walkValidate(choice, errors);
}
} else if (node.type === "tag") {
walkValidate(node.children, errors);
}
// Note: typed-argument types are validated by `findUnknownArgTypes`
// walking the raw message — `validate()` calls both helpers and
// merges the results.
}
}
/**
* Detect unknown typed-argument types in the raw message string. Used by
* `MessageParser.validate` because the parser silently lowers unknown
* types to plain `VariableNode` (preserves the runtime contract that
* "{x, magic}" still renders the variable rather than throwing).
*/
export function findUnknownArgTypes(message: string): ValidationIssue[] {
const errors: ValidationIssue[] = [];
const stripped = preprocessIcuApostrophes(message);
// Match `{name, type` where type is the second clause but stops at
// either `,` or `}`.
const re = /\{\s*([A-Za-z_][\w-]*)\s*,\s*([A-Za-z_][\w-]*)/g;
let m: RegExpExecArray | null;
m = re.exec(stripped);
while (m !== null) {
const argType = m[2] as string;
if (!KNOWN_ARG_TYPES.has(argType)) {
errors.push({
code: "UNKNOWN_ARG_TYPE",
field: argType,
message: `Unknown ICU argument type '${argType}' on variable '${m[1]}'`,
});
}
m = re.exec(stripped);
}
return errors;
}

View File

@@ -0,0 +1,98 @@
/**
* Type definitions for the ICU-like message parser AST.
*/
/** A literal text segment */
export interface TextNode {
type: "text";
value: string;
}
/** An interpolation variable reference (also used for `#` in plural contexts) */
export interface VariableNode {
type: "variable";
name: string;
}
/** A plural selection node following ICU plural syntax */
export interface PluralNode {
type: "plural";
variable: string;
offset: number;
choices: Record<string, MessageNode[]>;
}
/** A select / gender / choice node */
export interface SelectNode {
type: "select";
variable: string;
choices: Record<string, MessageNode[]>;
}
/** Ordinal-plural selection node — `{var, selectordinal, ...}` */
export interface SelectOrdinalNode {
type: "selectordinal";
variable: string;
offset: number;
choices: Record<string, MessageNode[]>;
}
/** Typed-argument node — `{name, date|time|number, style?}` */
export interface TypedArgumentNode {
type: "typed-argument";
name: string;
argType: "date" | "time" | "number";
/** Style sub-token: `short | medium | long | full` for date/time;
* `decimal | percent | currency/<ISO> | compact | scientific |
* engineering` for number. May be empty for default. */
style: string;
}
/** Rich-text tag node — `<bold>...</bold>` */
export interface TagNode {
type: "tag";
name: string;
children: MessageNode[];
}
/** Union of all AST node types */
export type MessageNode =
| TextNode
| VariableNode
| PluralNode
| SelectNode
| SelectOrdinalNode
| TypedArgumentNode
| TagNode;
/** Parsed message result containing the AST and extracted variable names */
export interface ParsedMessage {
nodes: MessageNode[];
variables: string[];
}
/** Options for MessageParser construction */
export interface ParserOptions {
/** Open delimiter for simple variable syntax (default `{`) */
variablePrefix?: string;
/** Close delimiter for simple variable syntax (default `}`) */
variableSuffix?: string;
/** Enable ICU-style plural/select syntax (default `true`) */
icuSyntax?: boolean;
}
/** Validation issue surfaced by `MessageParser.validate`. */
export interface ValidationIssue {
readonly code:
| "UNBALANCED_BRACE"
| "UNBALANCED_QUOTE"
| "MISSING_OTHER_BRANCH"
| "UNKNOWN_ARG_TYPE";
readonly field: string;
readonly message: string;
}
/** Result of validating a message string. */
export type ValidationResult =
| { ok: true }
| { ok: false; errors: ValidationIssue[] };

View File

@@ -0,0 +1,28 @@
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
export {
arabicPlural,
chinesePlural,
defaultPluralRule,
englishPlural,
frenchPlural,
germanPlural,
getBaseLocale,
japanesePlural,
normalizeLocale,
PluralizationEngine,
type PluralizationEngineOptions,
polishPlural,
russianPlural,
spanishPlural,
welshPlural,
} from "./pluralization.js";
export type {
PluralCategory,
PluralizationConfig,
PluralMessages,
PluralResolveOptions,
PluralResolveResult,
PluralRuleFunction,
PluralRuleRegistry,
} from "./pluralization.types.js";

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

View File

@@ -0,0 +1,384 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
arabicPlural,
chinesePlural,
defaultPluralRule,
englishPlural,
frenchPlural,
germanPlural,
getBaseLocale,
normalizeLocale,
PluralizationEngine,
polishPlural,
russianPlural,
spanishPlural,
welshPlural,
} from "./pluralization.js";
describe("rule functions", () => {
it("defaultPluralRule: 1=one, others=other", { timeout: 5000 }, () => {
expect(defaultPluralRule(1)).toBe("one");
expect(defaultPluralRule(0)).toBe("other");
expect(defaultPluralRule(2)).toBe("other");
});
describe("englishPlural", () => {
it("cardinal: 1=one, others=other", { timeout: 5000 }, () => {
expect(englishPlural(1)).toBe("one");
expect(englishPlural(0)).toBe("other");
expect(englishPlural(2)).toBe("other");
});
it("ordinal: 1st/2nd/3rd/other", { timeout: 5000 }, () => {
expect(englishPlural(1, true)).toBe("one"); // 1st
expect(englishPlural(2, true)).toBe("two"); // 2nd
expect(englishPlural(3, true)).toBe("few"); // 3rd
expect(englishPlural(4, true)).toBe("other"); // 4th
expect(englishPlural(11, true)).toBe("other"); // 11th (exception)
expect(englishPlural(12, true)).toBe("other"); // 12th
expect(englishPlural(13, true)).toBe("other"); // 13th
expect(englishPlural(21, true)).toBe("one"); // 21st
});
});
it("spanishPlural: 1=one, millions=many, others=other", {
timeout: 5000,
}, () => {
expect(spanishPlural(1)).toBe("one");
expect(spanishPlural(0)).toBe("other");
expect(spanishPlural(1_000_000)).toBe("many");
expect(spanishPlural(2)).toBe("other");
});
it("frenchPlural: 0,1=one, millions=many, others=other", {
timeout: 5000,
}, () => {
expect(frenchPlural(0)).toBe("one");
expect(frenchPlural(1)).toBe("one");
expect(frenchPlural(2)).toBe("other");
expect(frenchPlural(1_000_000)).toBe("many");
});
it("germanPlural: 1=one, others=other", { timeout: 5000 }, () => {
expect(germanPlural(1)).toBe("one");
expect(germanPlural(2)).toBe("other");
});
it("welshPlural: 0=zero, 1=one, 2=two, 3=few, 6=many, others=other", {
timeout: 5000,
}, () => {
expect(welshPlural(0)).toBe("zero");
expect(welshPlural(1)).toBe("one");
expect(welshPlural(2)).toBe("two");
expect(welshPlural(3)).toBe("few");
expect(welshPlural(6)).toBe("many");
expect(welshPlural(4)).toBe("other");
expect(welshPlural(100)).toBe("other");
});
it("chinesePlural: always other", { timeout: 5000 }, () => {
expect(chinesePlural(0)).toBe("other");
expect(chinesePlural(1)).toBe("other");
expect(chinesePlural(100)).toBe("other");
});
describe("arabicPlural", () => {
it("0=zero, 1=one, 2=two", { timeout: 5000 }, () => {
expect(arabicPlural(0)).toBe("zero");
expect(arabicPlural(1)).toBe("one");
expect(arabicPlural(2)).toBe("two");
});
it("3-10=few", { timeout: 5000 }, () => {
for (let n = 3; n <= 10; n++) expect(arabicPlural(n)).toBe("few");
});
it("11-99=many", { timeout: 5000 }, () => {
expect(arabicPlural(11)).toBe("many");
expect(arabicPlural(99)).toBe("many");
});
it("100=other (mod 100 = 0)", { timeout: 5000 }, () => {
expect(arabicPlural(100)).toBe("other");
});
it("103=few (mod 100 = 3)", { timeout: 5000 }, () => {
expect(arabicPlural(103)).toBe("few");
});
});
describe("russianPlural", () => {
it("1=one, 11=many, 2-4=few, 5-9=many", { timeout: 5000 }, () => {
expect(russianPlural(1)).toBe("one");
expect(russianPlural(11)).toBe("many");
expect(russianPlural(21)).toBe("one");
expect(russianPlural(2)).toBe("few");
expect(russianPlural(4)).toBe("few");
expect(russianPlural(5)).toBe("many");
expect(russianPlural(12)).toBe("many");
expect(russianPlural(14)).toBe("many");
});
it("0=many", { timeout: 5000 }, () => {
expect(russianPlural(0)).toBe("many");
});
});
describe("polishPlural", () => {
it("1=one", { timeout: 5000 }, () => {
expect(polishPlural(1)).toBe("one");
});
it("2-4=few (not teens)", { timeout: 5000 }, () => {
expect(polishPlural(2)).toBe("few");
expect(polishPlural(4)).toBe("few");
expect(polishPlural(22)).toBe("few");
});
it("5-9=many", { timeout: 5000 }, () => {
expect(polishPlural(5)).toBe("many");
expect(polishPlural(9)).toBe("many");
});
it("0=many", { timeout: 5000 }, () => {
expect(polishPlural(0)).toBe("many");
});
it("12-14=many (teens exception)", { timeout: 5000 }, () => {
expect(polishPlural(12)).toBe("many");
expect(polishPlural(13)).toBe("many");
expect(polishPlural(14)).toBe("many");
});
});
});
describe("normalizeLocale", () => {
it("lowercases and strips region", { timeout: 5000 }, () => {
expect(normalizeLocale("en-GB")).toBe("en");
expect(normalizeLocale("ZH")).toBe("zh");
});
});
describe("getBaseLocale", () => {
it("returns first subtag", { timeout: 5000 }, () => {
expect(getBaseLocale("en-GB")).toBe("en");
expect(getBaseLocale("zh-CN")).toBe("zh");
expect(getBaseLocale("fr")).toBe("fr");
});
});
describe("PluralizationEngine", () => {
const engine = new PluralizationEngine();
it("resolve returns correct category", { timeout: 5000 }, () => {
const r = engine.resolve(
1,
{ one: "1 item", other: "# items" },
{ locale: "en" }
);
expect(r.category).toBe("one");
expect(r.message).toBe("1 item");
});
it("resolve falls back to other when category missing", {
timeout: 5000,
}, () => {
const r = engine.resolve(0, { other: "none" }, { locale: "en" });
expect(r.message).toBe("none");
});
it("resolve returns undefined message when category and other missing", {
timeout: 5000,
}, () => {
const r = engine.resolve(5, { one: "one" }, { locale: "en" });
expect(r.message).toBeUndefined();
});
it("getCategory works for ar", { timeout: 5000 }, () => {
expect(engine.getCategory(0, "ar")).toBe("zero");
expect(engine.getCategory(2, "ar")).toBe("two");
});
it("registerRule overrides built-in", { timeout: 5000 }, () => {
const e = new PluralizationEngine();
e.registerRule("en", () => "other");
expect(e.getCategory(1, "en")).toBe("other");
});
it("hasRule returns true for built-ins", { timeout: 5000 }, () => {
expect(engine.hasRule("en")).toBe(true);
expect(engine.hasRule("ar")).toBe(true);
});
it("hasRule returns false for unknown locale", { timeout: 5000 }, () => {
expect(engine.hasRule("xx")).toBe(false);
});
it("getRule returns defaultPluralRule for unknown locale", {
timeout: 5000,
}, () => {
const rule = engine.getRule("xx");
expect(rule(1)).toBe("one");
expect(rule(2)).toBe("other");
});
it("getCategories returns categories for a locale", {
timeout: 5000,
}, () => {
const cats = engine.getCategories("cy");
expect(cats).toContain("zero");
expect(cats).toContain("one");
expect(cats).toContain("two");
});
it("uses custom rules from constructor", { timeout: 5000 }, () => {
const e = new PluralizationEngine({ xx: () => "few" });
expect(e.getCategory(1, "xx")).toBe("few");
});
it("uses customRules from PluralizationConfig", { timeout: 5000 }, () => {
const e = new PluralizationEngine({
customRules: { yy: () => "many" },
});
expect(e.getCategory(1, "yy")).toBe("many");
});
it("strict + customRules constructor honours strict flag", {
timeout: 5000,
}, () => {
const e = new PluralizationEngine({
strict: true,
customRules: { zz: () => "few" },
});
expect(e.getCategory(1, "zz")).toBe("few");
});
it("handles region variant locale", { timeout: 5000 }, () => {
expect(engine.getCategory(1, "en-GB")).toBe("one");
expect(engine.getCategory(1, "ru-RU")).toBe("one");
});
it("resolve with ordinal=true", { timeout: 5000 }, () => {
const r = engine.resolve(
3,
{ few: "3rd" },
{ locale: "en", ordinal: true }
);
expect(r.category).toBe("few");
});
it("resolve without options defaults to en cardinal", {
timeout: 5000,
}, () => {
// No options → locale defaults to "en", ordinal to false
const r = engine.resolve(1, { one: "one item", other: "many" });
expect(r.category).toBe("one");
expect(r.message).toBe("one item");
});
});
describe("PluralizationEngine strict mode + Intl-fallback chain", () => {
it("strict + garbage locale + no built-in rule → throws PLURAL_RULE_NOT_FOUND", {
timeout: 5000,
}, () => {
const engine = new PluralizationEngine({ strict: true });
const original = Intl.PluralRules;
// Force the Intl path to throw so the fallback chain runs.
(Intl as { PluralRules: unknown }).PluralRules = (() => {
throw new RangeError("synthetic");
}) as unknown as typeof Intl.PluralRules;
try {
try {
engine.getRule("xx");
throw new Error("expected throw");
} catch (err) {
// I18nError(PLURAL_RULE_NOT_FOUND).
expect((err as Error).message).toContain("xx");
}
} finally {
(Intl as { PluralRules: unknown }).PluralRules = original;
}
});
it("non-strict + Intl missing falls through to hand-written rule", {
timeout: 5000,
}, () => {
const engine = new PluralizationEngine();
const original = Intl.PluralRules;
(Intl as { PluralRules: unknown }).PluralRules = (() => {
throw new RangeError("synthetic");
}) as unknown as typeof Intl.PluralRules;
try {
// 'pl' has a hand-written polishPlural rule; with Intl
// unavailable the engine falls back to it.
const rule = engine.getRule("pl");
expect(typeof rule).toBe("function");
expect(rule(2)).toBe("few");
} finally {
(Intl as { PluralRules: unknown }).PluralRules = original;
}
});
it("non-strict + garbage locale → returns defaultPluralRule", {
timeout: 5000,
}, () => {
const engine = new PluralizationEngine();
const rule = engine.getRule("en US");
expect(typeof rule).toBe("function");
// defaultPluralRule: 1 → 'one', else → 'other'
expect(rule(1)).toBe("one");
expect(rule(5)).toBe("other");
});
it("registerRule sets an explicit override that wins over Intl", {
timeout: 5000,
}, () => {
const engine = new PluralizationEngine();
engine.registerRule("en", () => "few" as const);
// Override forces every call to return 'few', overriding Intl.
expect(engine.getCategory(5, "en")).toBe("few");
});
});
describe("spec-conformance — canonical scenario titles", () => {
const engine = new PluralizationEngine();
it("English plural rule", { timeout: 5000 }, () => {
// Cardinal: 1=one, others=other.
expect(engine.getCategory(1, "en")).toBe("one");
expect(engine.getCategory(2, "en")).toBe("other");
});
it("Welsh plural covers all six categories", { timeout: 5000 }, () => {
// Welsh hits zero/one/two/few/many/other across the boundary set.
const cats = new Set<string>();
for (const n of [0, 1, 2, 3, 6, 11]) {
cats.add(engine.getCategory(n, "cy"));
}
expect(cats.has("zero")).toBe(true);
expect(cats.has("one")).toBe(true);
expect(cats.has("two")).toBe(true);
expect(cats.has("few")).toBe(true);
expect(cats.has("many")).toBe(true);
expect(cats.has("other")).toBe(true);
});
it("unknown locale throws PLURAL_RULE_NOT_FOUND", { timeout: 5000 }, () => {
const strict = new PluralizationEngine({ strict: true });
const original = Intl.PluralRules;
(Intl as { PluralRules: unknown }).PluralRules = (() => {
throw new RangeError("synthetic");
}) as unknown as typeof Intl.PluralRules;
try {
try {
strict.getCategory(1, "xx-XX");
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
"I18N.PLURAL_RULE_NOT_FOUND"
);
}
} finally {
(Intl as { PluralRules: unknown }).PluralRules = original;
}
});
});
describe("japanesePlural", () => {
it("japanesePlural always returns 'other'", {
timeout: 5000,
}, async () => {
const mod = await import("./pluralization.js");
expect(mod.japanesePlural()).toBe("other");
});
});

View File

@@ -0,0 +1,313 @@
import { I18N_CODES, I18nError } from "@suaveplan/i18n-shared";
import { recordPluralDuration } from "../observability/metrics.js";
import type {
PluralCategory,
PluralizationConfig,
PluralMessages,
PluralResolveOptions,
PluralResolveResult,
PluralRuleFunction,
PluralRuleRegistry,
} from "./pluralization.types.js";
// Re-export the shared `PluralizationConfig` so the legacy alias name
// `PluralizationEngineOptions` (declared below) and the canonical
// shape stay co-located in the public surface for consumers who
// import from `./pluralization.js` directly.
export type { PluralizationConfig } from "./pluralization.types.js";
// ── Built-in plural rule functions ────────────────────────────────────────────
/** Default rule: one vs other (Germanic baseline) */
export function defaultPluralRule(n: number): PluralCategory {
return n === 1 ? "one" : "other";
}
/** English: one/other cardinal; one/two/few/other ordinal */
export function englishPlural(n: number, ordinal?: boolean): PluralCategory {
if (ordinal) {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return "one";
if (mod10 === 2 && mod100 !== 12) return "two";
if (mod10 === 3 && mod100 !== 13) return "few";
return "other";
}
return n === 1 ? "one" : "other";
}
/** Spanish / Portuguese / Italian: one/many/other */
export function spanishPlural(n: number): PluralCategory {
if (n === 1) return "one";
if (n !== 0 && n % 1_000_000 === 0) return "many";
return "other";
}
/** French: zero/one treated as one; millions → many */
export function frenchPlural(n: number): PluralCategory {
if (n === 0 || n === 1) return "one";
if (n !== 0 && n % 1_000_000 === 0) return "many";
return "other";
}
/** German / Dutch: one/other */
export function germanPlural(n: number): PluralCategory {
return n === 1 ? "one" : "other";
}
/** Welsh: zero/one/two/few (3)/many (6)/other */
export function welshPlural(n: number): PluralCategory {
if (n === 0) return "zero";
if (n === 1) return "one";
if (n === 2) return "two";
if (n === 3) return "few";
if (n === 6) return "many";
return "other";
}
/** Chinese / Japanese / Korean: always other */
export function chinesePlural(): PluralCategory {
return "other";
}
/**
* Japanese plural rule. Japanese, Korean, and Chinese all use the
* same single-form CLDR plural category, but the spec surfaces
* `japanesePlural` as a named export so downstream consumers can
* import it intent-revealingly from the per-language slot.
*/
export function japanesePlural(): PluralCategory {
return chinesePlural();
}
/** Arabic: zero/one/two/few (3-10)/many (11-99)/other */
export function arabicPlural(n: number): PluralCategory {
if (n === 0) return "zero";
if (n === 1) return "one";
if (n === 2) return "two";
const mod100 = n % 100;
if (mod100 >= 3 && mod100 <= 10) return "few";
if (mod100 >= 11 && mod100 <= 99) return "many";
return "other";
}
/** Russian: one/few/many (complex modulo rules) */
export function russianPlural(n: number): PluralCategory {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return "one";
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14))
return "few";
return "many";
}
/** Polish: one/few/many (complex slavic rules) */
export function polishPlural(n: number): PluralCategory {
const mod10 = n % 10;
const mod100 = n % 100;
if (n === 1) return "one";
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14))
return "few";
return "many";
}
// ── Locale normalisation helpers ──────────────────────────────────────────────
/** Normalise a locale to its lowercase language-only subtag */
export function normalizeLocale(locale: string): string {
return (locale.toLowerCase().split("-") as [string, ...string[]])[0];
}
/** Extract the base language subtag from a locale string */
export function getBaseLocale(locale: string): string {
return (locale.split("-") as [string, ...string[]])[0].toLowerCase();
}
// ── PluralizationEngine ───────────────────────────────────────────────────────
/**
* CLDR-based pluralization engine.
*
* Comes pre-loaded with rules for Arabic, Chinese, English, French,
* German, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian,
* Spanish, and Welsh. Custom rules can be supplied at construction time or
* registered later via `registerRule`.
*/
/**
* Options-shape constructor argument for PluralizationEngine.
* Back-compat alias for {@link PluralizationConfig}.
*/
export type PluralizationEngineOptions = PluralizationConfig;
function isOptionsShape(
arg: Record<string, PluralRuleFunction> | PluralizationEngineOptions
): arg is PluralizationEngineOptions {
return Object.keys(arg).every((k) => k === "strict" || k === "customRules");
}
export class PluralizationEngine {
/**
* Combined view: explicit user overrides (set via `registerRule` or
* passed in `customRules`) plus built-in hand-written rules. The
* `overrides` set tracks which entries were explicitly registered so
* `getRule` can give them priority over `Intl.PluralRules`.
*/
protected readonly rules: PluralRuleRegistry = new Map();
protected readonly overrides: Set<string> = new Set();
protected readonly strict: boolean;
constructor(
optionsOrRules?:
| PluralizationEngineOptions
| Record<string, PluralRuleFunction>
) {
this.registerBuiltinRules();
if (!optionsOrRules) {
this.strict = false;
return;
}
if (isOptionsShape(optionsOrRules)) {
this.strict = optionsOrRules.strict ?? false;
if (optionsOrRules.customRules) {
for (const [locale, rule] of Object.entries(
optionsOrRules.customRules
)) {
const normalized = normalizeLocale(locale);
this.rules.set(normalized, rule);
this.overrides.add(normalized);
}
}
} else {
this.strict = false;
for (const [locale, rule] of Object.entries(optionsOrRules)) {
const normalized = normalizeLocale(locale);
this.rules.set(normalized, rule as PluralRuleFunction);
this.overrides.add(normalized);
}
}
}
/** Get the plural category for a count and locale */
getCategory(
count: number,
locale: string,
ordinal = false
): PluralCategory {
return this.getRule(locale)(count, ordinal);
}
/**
* Resolve a plural message from a set of category strings.
* Falls back to `other` when the exact category is absent.
*/
resolve(
count: number,
messages: PluralMessages,
options?: PluralResolveOptions
): PluralResolveResult {
const t0 = performance.now();
const locale = options?.locale ?? "en";
const ordinal = options?.ordinal ?? false;
const category = this.getCategory(count, locale, ordinal);
const message = messages[category] ?? messages.other;
recordPluralDuration(
performance.now() - t0,
normalizeLocale(locale),
ordinal
);
return { category, message, count };
}
/**
* Register a custom plural rule for a locale code. Explicit overrides
* are consulted before `Intl.PluralRules`.
*/
registerRule(locale: string, rule: PluralRuleFunction): void {
const normalized = normalizeLocale(locale);
this.rules.set(normalized, rule);
this.overrides.add(normalized);
}
/** Check whether a rule is registered for a locale */
hasRule(locale: string): boolean {
return this.rules.has(normalizeLocale(locale));
}
/**
* Retrieve the plural rule function for a locale. Resolution order:
*
* 1. Explicit override registered via `registerRule` (or via the
* `customRules` constructor option) — wins over Intl.
* 2. `Intl.PluralRules` when the runtime supports the locale.
* 3. Hand-written rule registry (built-ins for `en`, `es`, `fr`, …).
* 4. `defaultPluralRule` when strict mode is off.
*
* In strict mode, when none of (1)…(3) match, throws
* `I18nError(I18N.PLURAL_RULE_NOT_FOUND)`.
*/
getRule(locale: string): PluralRuleFunction {
const normalized = normalizeLocale(locale);
// 1. Explicit user override wins. (overrides + rules are paired
// in registerRule; the entry is always present when the flag
// is set.)
if (this.overrides.has(normalized)) {
return this.rules.get(normalized) as PluralRuleFunction;
}
// 2. Prefer Intl.PluralRules so the engine tracks CLDR over time.
try {
Intl.getCanonicalLocales(locale);
const pr = new Intl.PluralRules(locale);
const prOrdinal = new Intl.PluralRules(locale, { type: "ordinal" });
return (n: number, ordinal?: boolean): PluralCategory =>
(ordinal ? prOrdinal : pr).select(n) as PluralCategory;
} catch {
// 3. Fall back to the registered hand-written rule, if any.
const builtIn = this.rules.get(normalized);
if (builtIn) return builtIn;
// 4. Strict mode → throw; otherwise default.
if (this.strict) {
throw new I18nError(
I18N_CODES.PLURAL_RULE_NOT_FOUND,
`No plural rule for locale: ${locale}`
);
}
return defaultPluralRule;
}
}
/** Return all plural categories reachable for a locale */
getCategories(locale: string): PluralCategory[] {
const rule = this.getRule(locale);
const categories: PluralCategory[] = [];
for (const n of [0, 1, 2, 3, 6, 11, 100]) {
const cat = rule(n);
if (!categories.includes(cat)) categories.push(cat);
}
return categories;
}
private registerBuiltinRules(): void {
const rules: Array<[string, PluralRuleFunction]> = [
["en", englishPlural],
["es", spanishPlural],
["fr", frenchPlural],
["de", germanPlural],
["cy", welshPlural],
["zh", chinesePlural],
["ar", arabicPlural],
["ja", chinesePlural],
["ko", chinesePlural],
["pt", spanishPlural],
["it", spanishPlural],
["nl", germanPlural],
["ru", russianPlural],
["pl", polishPlural],
];
for (const [locale, rule] of rules) {
this.rules.set(locale, rule);
}
}
}

View File

@@ -0,0 +1,50 @@
/**
* Type definitions for the pluralization engine.
*/
/** CLDR plural categories */
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
/** A set of plural forms for a single message */
export type PluralMessages = Partial<Record<PluralCategory, string>>;
/** Function that maps a count to a plural category */
export type PluralRuleFunction = (
count: number,
ordinal?: boolean
) => PluralCategory;
/** Registry mapping locale codes to their rule functions */
export type PluralRuleRegistry = Map<string, PluralRuleFunction>;
/** Options for plural resolution */
export interface PluralResolveOptions {
ordinal?: boolean;
locale?: string;
}
/** Result of plural resolution */
export interface PluralResolveResult {
category: PluralCategory;
message: string | undefined;
count: number;
}
/**
* Configuration shape for {@link PluralizationEngine} — accepted as
* the canonical form alongside the legacy `Record<string,
* PluralRuleFunction>` shorthand.
*/
export interface PluralizationConfig {
/**
* When `true`, throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)`
* when the locale resolves to no rule. When `false`, fall back
* to {@link defaultPluralRule}. Defaults to `false`.
*/
strict?: boolean;
/**
* Optional map of locale (BCP-47 base subtag) → rule function.
* Entries here override built-in rules.
*/
customRules?: Record<string, PluralRuleFunction>;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/i18n-icu-parser",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/i18n-shared",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});