Initial Commit
This commit is contained in:
182
packages/localization/i18n/i18n-icu-parser/README.md
Normal file
182
packages/localization/i18n/i18n-icu-parser/README.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# @suaveplan/i18n-icu-parser - ICU MessageFormat parser, {{var}} interpolation engine, and 9-language CLDR pluralization for the @suaveplan/i18n family. Universal leaf package — no formatter, backend, or core dependencies. Renders ICU placeholders through a FormatBridge DI seam so the package stays free of Intl wrappers.
|
||||
|
||||
## Summary
|
||||
|
||||
ICU MessageFormat parser, `{{var}}` interpolation engine, and
|
||||
9-language CLDR pluralization for the `@suaveplan/i18n` family.
|
||||
Universal leaf package — depends only on
|
||||
`@suaveplan/i18n-shared`, `@suaveplan/error`, `@suaveplan/types`,
|
||||
and the OTel API. No formatter, backend, detector, or core
|
||||
dependencies.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
|
||||
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
|
||||
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
|
||||
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
|
||||
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
|
||||
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
|
||||
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
|
||||
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
|
||||
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
|
||||
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
|
||||
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
|
||||
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
|
||||
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_- [escape-html](./src/interpolation/escape-html.md) — `escapeHtmlChars` converts the five HTML special characters (`&`, `<`, `>`, `"`, `'`) to their numeric / named entity e…
|
||||
- [escape-regex](./src/interpolation/escape-regex.md) — `escapeRegex` escapes every regular-expression metacharacter in a string so the string can be embedded literally inside…
|
||||
- [interpolation](./src/interpolation/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-bridge](./src/parser/format-bridge.md) — `FormatBridge` is the dependency-injection seam that lets `@suaveplan/i18n-icu-parser` render ICU format placeholders (…
|
||||
- [message-render](./src/parser/message-render.md) — `message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects al…
|
||||
- [message](./src/parser/message.md) — `MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to re…
|
||||
- [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pluralization](./src/pluralization/pluralization.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Why this exists
|
||||
|
||||
The 14-package i18n modular split needs one home for the three
|
||||
pure-text-processing concerns that share the `MessageNode` AST,
|
||||
the `INTERPOLATION_ERROR` / `PLURAL_RULE_NOT_FOUND` /
|
||||
`NESTING_DEPTH_EXCEEDED` error codes, and the same renderer
|
||||
contract: ICU parsing, `{{var}}` interpolation, and CLDR
|
||||
pluralization. Splitting them further would fragment the seam
|
||||
without benefit; bundling them as `@suaveplan/i18n-icu-parser`
|
||||
keeps the AST internal and exposes a clean public surface
|
||||
(`MessageParser`, `InterpolationEngine`, `PluralizationEngine`,
|
||||
`renderNodes`) for the slim core to compose.
|
||||
|
||||
## The FormatBridge seam
|
||||
|
||||
ICU format placeholders (`{value, number}`, `{amount, number,
|
||||
currency=USD}`, `{date, date, short}`, `{ts, time, long}`,
|
||||
`{delta, relativeTime}`) need locale-aware output. To stay free
|
||||
of the per-formatter packages, the renderer accepts a
|
||||
`FormatBridge` via `RenderOptions.formatBridge`. When the bridge
|
||||
(or a specific method on it) is undefined, the renderer falls
|
||||
back to `String(value)` and never throws — the parser remains
|
||||
fully usable in isolation, with no `Intl.*` runtime cost.
|
||||
|
||||
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
|
||||
bridge by importing the formatter packages and threads it through
|
||||
`createI18n({ ... })` so consumers who use the high-level façade
|
||||
get full ICU output with zero wiring.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MessageParser,
|
||||
type FormatBridge,
|
||||
} from "@suaveplan/i18n-icu-parser";
|
||||
|
||||
const bridge: FormatBridge = {
|
||||
number: (v, locale) => new Intl.NumberFormat(locale).format(v),
|
||||
currency: (v, currency, locale) =>
|
||||
new Intl.NumberFormat(locale, { style: "currency", currency }).format(v),
|
||||
};
|
||||
|
||||
const parser = new MessageParser();
|
||||
parser.format(
|
||||
"{amount, number, currency/USD} on {when, date, short}",
|
||||
{ amount: 1234.5, when: new Date() },
|
||||
{ locale: "en-US", formatBridge: bridge }
|
||||
);
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/i18n-icu-parser
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import {
|
||||
InterpolationEngine,
|
||||
MessageParser,
|
||||
PluralizationEngine,
|
||||
} from "@suaveplan/i18n-icu-parser";
|
||||
|
||||
const parser = new MessageParser();
|
||||
parser.format("{count, plural, one {1 item} other {# items}}", { count: 3 });
|
||||
// "3 items"
|
||||
|
||||
const engine = new InterpolationEngine({ escapeHtml: true });
|
||||
engine.render("hello {{ name }}", { name: "Ada" });
|
||||
// { text: "hello Ada", missingVariables: [] }
|
||||
|
||||
const plural = new PluralizationEngine();
|
||||
plural.resolve(0, { zero: "none", one: "one", other: "many" }, { locale: "ar" });
|
||||
// { category: "zero", message: "none", count: 0 }
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---|---|
|
||||
| `@suaveplan/i18n-icu-parser` | Root barrel — every public symbol |
|
||||
| `@suaveplan/i18n-icu-parser/parser` | `MessageParser`, `renderNodes`, `extractVariables`, `FormatBridge`, `noopFormatBridge`, AST types |
|
||||
| `@suaveplan/i18n-icu-parser/parser/format-bridge` | `FormatBridge` interface + `noopFormatBridge` ONLY (single-file import for slim-core wiring) |
|
||||
| `@suaveplan/i18n-icu-parser/interpolation` | `InterpolationEngine`, `escapeHtmlChars`, `escapeRegex`, `InterpolationConfigSchema`, types |
|
||||
| `@suaveplan/i18n-icu-parser/pluralization` | `PluralizationEngine`, the 9 plural-rule functions, `defaultPluralRule`, `normalizeLocale`, `getBaseLocale`, types |
|
||||
|
||||
## Modules Documentation
|
||||
|
||||
- [parser/format-bridge.md](src/parser/format-bridge.md) — DI seam between this package and the formatter packages
|
||||
- [parser/message.md](src/parser/message.md) — `MessageParser` class
|
||||
- [parser/message-render.md](src/parser/message-render.md) — `renderNodes` / `extractVariables` standalone helpers
|
||||
- [interpolation/interpolation.md](src/interpolation/interpolation.md) — `InterpolationEngine`
|
||||
- [interpolation/escape-html.md](src/interpolation/escape-html.md) — `escapeHtmlChars` standalone helper
|
||||
- [interpolation/escape-regex.md](src/interpolation/escape-regex.md) — `escapeRegex` standalone helper
|
||||
- [pluralization/pluralization.md](src/pluralization/pluralization.md) — `PluralizationEngine` + 9 plural-rule fns
|
||||
- [observability/metrics.md](src/observability/metrics.md) — OTel histogram recorders
|
||||
|
||||
## Public Surface (snapshot)
|
||||
|
||||
| Export | Kind | Origin |
|
||||
|---|---|---|
|
||||
| `MessageParser` | class | `parser/message.ts` |
|
||||
| `extractVariables` | function | `parser/message-render.ts` |
|
||||
| `renderNodes` | function | `parser/message-render.ts` |
|
||||
| `renderToChunks` | function | `parser/message-render.ts` |
|
||||
| `preprocessIcuApostrophes` | function | `parser/message.ts` |
|
||||
| `findUnknownArgTypes` | function | `parser/message.ts` |
|
||||
| `FormatBridge` | type | `parser/format-bridge.ts` |
|
||||
| `noopFormatBridge` | const | `parser/format-bridge.ts` |
|
||||
| `MessageNode`, `ParsedMessage`, `ParserOptions`, `RenderOptions`, `RenderContext`, `ValidationIssue`, `ValidationResult` | types | `parser/*.types.ts` |
|
||||
| `InterpolationEngine` | class | `interpolation/interpolation.ts` |
|
||||
| `escapeHtmlChars` | function | `interpolation/escape-html.ts` |
|
||||
| `escapeRegex` | function | `interpolation/escape-regex.ts` |
|
||||
| `InterpolationConfigSchema` | const | `interpolation/interpolation.schemas.ts` |
|
||||
| `PluralizationEngine` | class | `pluralization/pluralization.ts` |
|
||||
| `arabicPlural` / `chinesePlural` / `englishPlural` / `frenchPlural` / `germanPlural` / `japanesePlural` / `polishPlural` / `russianPlural` / `welshPlural` / `spanishPlural` / `defaultPluralRule` | functions | `pluralization/pluralization.ts` |
|
||||
| `recordParseDuration` / `recordInterpolationDuration` / `recordPluralDuration` | functions | `observability/metrics.ts` |
|
||||
|
||||
## Versioning
|
||||
|
||||
Part of the i18n modular-split rollout. Stays at `0.1.0` until
|
||||
the slim core (Wave 6) cuts the family `1.0.0` release. Test
|
||||
titles in `parser/message.test.ts`, `interpolation/interpolation.test.ts`,
|
||||
and `pluralization/pluralization.test.ts` mirror the pre-split
|
||||
monolith one-for-one so the core's conformance suite re-runs
|
||||
unchanged against the bridged surface.
|
||||
|
||||
## Wave 2 Scope
|
||||
|
||||
Pure addition. `packages/i18n/i18n/src/` retains its copies of
|
||||
the parser, interpolation engine, and pluralization sources; both
|
||||
copies coexist during Waves 2–5 and the monolith remains green.
|
||||
The slim core (Wave 6) deletes the monolith copies and re-exports
|
||||
from this package.
|
||||
|
||||
88
packages/localization/i18n/i18n-icu-parser/package.json
Normal file
88
packages/localization/i18n/i18n-icu-parser/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
125
packages/localization/i18n/i18n-icu-parser/src/index.test.ts
Normal file
125
packages/localization/i18n/i18n-icu-parser/src/index.test.ts
Normal 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("<");
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
85
packages/localization/i18n/i18n-icu-parser/src/index.ts
Normal file
85
packages/localization/i18n/i18n-icu-parser/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,96 @@
|
||||
# escape-html
|
||||
|
||||
## Purpose
|
||||
|
||||
`escapeHtmlChars` converts the five HTML special characters
|
||||
(`&`, `<`, `>`, `"`, `'`) to their numeric / named entity
|
||||
equivalents so a string can be safely concatenated into an HTML
|
||||
context without opening an XSS vector. The helper is the single
|
||||
source of truth for HTML escaping inside this package — the
|
||||
{@link InterpolationEngine} calls it when `escapeHtml: true` is
|
||||
configured, and downstream consumers can call it directly when
|
||||
they need to sanitise an already-rendered string before inserting
|
||||
into `innerHTML`, an attribute value, or a `<script type="application/json">`
|
||||
block.
|
||||
|
||||
The helper is extracted into its own file so consumers who only
|
||||
need the escape function can tree-shake the rest of the
|
||||
interpolation engine. This is particularly useful in
|
||||
size-sensitive bundles (mobile web, edge workers) that ship a
|
||||
prerendered translation catalog and never need the full
|
||||
interpolation surface.
|
||||
|
||||
## Features
|
||||
|
||||
- Escapes the canonical OWASP-recommended five characters
|
||||
- Order-stable replacement (`&` first, so subsequent
|
||||
`<`/`>` entities don't get double-escaped)
|
||||
- No HTML allow-list — escapes EVERY occurrence of every char
|
||||
- Pure function: no I/O, no allocation beyond the output string,
|
||||
no observable side effects
|
||||
- Zero dependencies — safe to inline in edge / worker contexts
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
const safe = escapeHtmlChars("<b>bold</b>");
|
||||
// "<b>bold</b>"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { escapeHtmlChars } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
// Sanitise user input before inserting into a template literal
|
||||
function tooltipHtml(label: string): string {
|
||||
return `<span class="tooltip" title="${escapeHtmlChars(label)}">…</span>`;
|
||||
}
|
||||
|
||||
console.log(tooltipHtml(`I'm "the boss" & <admin>`));
|
||||
// '<span class="tooltip" title="I'm "the boss" & <admin>">…</span>'
|
||||
|
||||
// Combine with InterpolationEngine for catalog values
|
||||
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
const engine = new InterpolationEngine({ escapeHtml: true });
|
||||
// Internally calls escapeHtmlChars on every interpolated value.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `escapeHtmlChars(str: string): string`
|
||||
|
||||
Escape every occurrence of `&`, `<`, `>`, `"`, `'` in `str` to
|
||||
its HTML entity equivalent.
|
||||
|
||||
| Input char | Output |
|
||||
|---|---|
|
||||
| `&` | `&` |
|
||||
| `<` | `<` |
|
||||
| `>` | `>` |
|
||||
| `"` | `"` |
|
||||
| `'` | `'` |
|
||||
|
||||
**Returns:** the escaped string. Strings without any of the five
|
||||
characters return unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The replacements run as five sequential `String.prototype.replace`
|
||||
calls. This is faster than a single regex with a callback for
|
||||
realistic translation-string sizes (10-200 characters), which
|
||||
is the dominant case for i18n.
|
||||
- `&` is escaped first so that the `&` in subsequent entity
|
||||
outputs (`<`, `>`, `"`, `'`) is not re-escaped
|
||||
on a second pass. The rule is invariant: every entity starts
|
||||
with `&`, and that `&` was already produced by the helper, so
|
||||
it is intentionally not re-escaped.
|
||||
- The single-quote escape uses the numeric entity `'` rather
|
||||
than the named entity `'` because the latter is not part
|
||||
of the HTML 4 entity set and is unsafe in attribute contexts
|
||||
served as `text/html` to legacy browsers.
|
||||
- The function is `O(n)` in the input length and produces at
|
||||
most `5n` output characters (worst case: a string of only `'`
|
||||
characters expands 5x to `'` per char).
|
||||
@@ -0,0 +1,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("<>&"'");
|
||||
});
|
||||
|
||||
it("escapes < to <", { timeout: 5000 }, () => {
|
||||
expect(escapeHtmlChars("<")).toBe("<");
|
||||
});
|
||||
|
||||
it("escapes > to >", { timeout: 5000 }, () => {
|
||||
expect(escapeHtmlChars(">")).toBe(">");
|
||||
});
|
||||
|
||||
it("escapes & to &", { timeout: 5000 }, () => {
|
||||
expect(escapeHtmlChars("&")).toBe("&");
|
||||
});
|
||||
|
||||
it('escapes " to "', { timeout: 5000 }, () => {
|
||||
expect(escapeHtmlChars('"')).toBe(""");
|
||||
});
|
||||
|
||||
it("escapes ' to '", { timeout: 5000 }, () => {
|
||||
expect(escapeHtmlChars("'")).toBe("'");
|
||||
});
|
||||
|
||||
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 < is not re-escaped.
|
||||
expect(escapeHtmlChars("&<")).toBe("&<");
|
||||
});
|
||||
|
||||
it("handles a tricky XSS payload", { timeout: 5000 }, () => {
|
||||
const out = escapeHtmlChars(`<script>alert('x')</script>`);
|
||||
expect(out).toBe("<script>alert('x')</script>");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# escape-regex
|
||||
|
||||
## Purpose
|
||||
|
||||
`escapeRegex` escapes every regular-expression metacharacter in a
|
||||
string so the string can be embedded literally inside a `RegExp`
|
||||
constructor. The {@link InterpolationEngine} uses it to compile
|
||||
placeholder patterns from caller-supplied delimiters (`{{`,
|
||||
`%{`, `<%`, …) into a working RegExp without inadvertently
|
||||
treating delimiter characters as regex syntax. Exposing it as a
|
||||
standalone export lets downstream consumers build their own
|
||||
delimiter-aware patterns or sanitise raw search queries.
|
||||
|
||||
The helper lives in its own file (alongside `escape-html`) so
|
||||
tree-shaking can strip the rest of the interpolation engine when
|
||||
a consumer needs only the escape primitive — for example, a
|
||||
documentation tooling pipeline that compiles user-typed search
|
||||
patterns into a forgiving regex.
|
||||
|
||||
## Features
|
||||
|
||||
- Escapes the canonical 12 ECMAScript regex metacharacters
|
||||
(`. * + ? ^ $ { } ( ) | [ ] \`) plus the closing brace
|
||||
- Pure function: no I/O, no state, deterministic per input
|
||||
- Single-pass `String.prototype.replace` with a precompiled
|
||||
regex literal — no allocation beyond the output string
|
||||
- Zero dependencies — drops cleanly into edge / worker bundles
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
const safe = escapeRegex("a.b*c");
|
||||
// "a\\.b\\*c"
|
||||
const re = new RegExp(safe);
|
||||
re.test("a.b*c"); // true (literal match)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { escapeRegex } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
// Build a placeholder pattern from caller-supplied delimiters.
|
||||
function buildPlaceholderPattern(open: string, close: string): RegExp {
|
||||
const o = escapeRegex(open);
|
||||
const c = escapeRegex(close);
|
||||
return new RegExp(`${o}\\s*([^${c}]+)\\s*${c}`, "g");
|
||||
}
|
||||
|
||||
const re = buildPlaceholderPattern("<%=", "%>");
|
||||
const matches = "Hello <%= name %>".matchAll(re);
|
||||
for (const m of matches) console.log(m[1]?.trim()); // "name"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `escapeRegex(str: string): string`
|
||||
|
||||
Escape every regular-expression metacharacter in `str`. The
|
||||
escaped output can be inserted unchanged into a `RegExp` source
|
||||
string and will match the original character literally.
|
||||
|
||||
Escaped characters: `.`, `*`, `+`, `?`, `^`, `$`, `{`, `}`,
|
||||
`(`, `)`, `|`, `[`, `]`, `\`.
|
||||
|
||||
**Returns:** the escaped string. Strings without metacharacters
|
||||
return unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The replacement uses `\\$&` (backref to the whole match)
|
||||
rather than enumerating every escape sequence, which keeps the
|
||||
function bytes-small and easy to audit.
|
||||
- The character class `[.*+?^${}()|[\\]\\\\]` is the canonical
|
||||
V8 / SpiderMonkey escape set for the standard `RegExp`
|
||||
grammar. Alternation operators inside character classes (`-`)
|
||||
do NOT need escaping at the outer level and are intentionally
|
||||
omitted.
|
||||
- The function is `O(n)` in input length. Worst case is a string
|
||||
of only metacharacters which expands 2x in the output.
|
||||
- This is the canonical regex escape across Suaveplan
|
||||
— every package that needs to embed user input in a RegExp
|
||||
consumes this helper or its inlined twin in the parser file.
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,111 @@
|
||||
# InterpolationEngine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`InterpolationEngine` replaces placeholder tokens (default: `{{ name }}`) in message template strings with caller-supplied values. It is the rendering workhorse behind `MessageCatalog.t()` and standalone template usage. The engine is designed to be instantiated once and reused across many render calls, with all configuration captured in the constructor.
|
||||
|
||||
The engine handles the common i18n case of named variable substitution, and additionally supports pluggable per-slot value formatters (e.g., number, date, currency) via a named formatter registry. Values can be HTML-escaped on output to prevent XSS when templates are rendered into HTML.
|
||||
|
||||
## Features
|
||||
|
||||
- Named placeholder substitution: `{{ name }}`, `{{ count, number }}`, etc.
|
||||
- Custom delimiter pairs (e.g., `%{` / `}` for Ruby-style templates)
|
||||
- Per-slot `ValueFormatter` registry — applied when a placeholder contains a format name
|
||||
- Optional `defaultFormatter` called for all slots without an explicit formatter
|
||||
- HTML escaping of rendered values (`escapeHtml: true`) via `escapeHtmlChars`
|
||||
- Recursive interpolation with configurable depth limit (`maxDepth`)
|
||||
- `missingVariables` tracking in the result — non-throwing for production resilience
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
const engine = new InterpolationEngine();
|
||||
const { text } = engine.render(
|
||||
"Hello, {{ name }}! You have {{ count }} messages.",
|
||||
{ name: "Alice", count: 3 }
|
||||
);
|
||||
// text === "Hello, Alice! You have 3 messages."
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { InterpolationEngine } from "@suaveplan/i18n-icu-parser/interpolation";
|
||||
|
||||
const engine = new InterpolationEngine({
|
||||
escapeHtml: true,
|
||||
formatters: {
|
||||
upper: (v) => String(v).toUpperCase(),
|
||||
number: (v, locale) =>
|
||||
new Intl.NumberFormat(locale).format(Number(v)),
|
||||
},
|
||||
});
|
||||
|
||||
const { text, missingVariables } = engine.render(
|
||||
"Welcome, {{ user, upper }}! Balance: {{ amount, number }}.",
|
||||
{ user: "alice", amount: 1234567 },
|
||||
"en-US"
|
||||
);
|
||||
// text === "Welcome, ALICE! Balance: 1,234,567."
|
||||
// missingVariables === []
|
||||
|
||||
// HTML escaping
|
||||
const { text: safe } = engine.render(
|
||||
"Comment: {{ comment }}",
|
||||
{ comment: "<script>alert('xss')</script>" }
|
||||
);
|
||||
// safe === "Comment: <script>alert('xss')</script>"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new InterpolationEngine(options?: InterpolationOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `prefix` | `string` | `"{{"` | Placeholder open delimiter |
|
||||
| `suffix` | `string` | `"}}"` | Placeholder close delimiter |
|
||||
| `escapeHtml` | `boolean` | `false` | HTML-escape rendered values |
|
||||
| `maxDepth` | `number` | `10` | Maximum recursion depth |
|
||||
| `formatters` | `Record<string, ValueFormatter>?` | `{}` | Named formatter map |
|
||||
| `defaultFormatter` | `ValueFormatter?` | — | Applied when no named formatter matches |
|
||||
|
||||
### `.render(template, values?, locale?): InterpolationResult`
|
||||
|
||||
Renders `template` replacing all placeholders. Returns `{ text: string; missingVariables: string[] }`.
|
||||
|
||||
### `.addFormatter(name, formatter): void`
|
||||
|
||||
Registers or replaces a named formatter at runtime.
|
||||
|
||||
### `.removeFormatter(name): boolean`
|
||||
|
||||
Removes a named formatter; returns `true` if it existed.
|
||||
|
||||
### `.hasPlaceholders(template): boolean`
|
||||
|
||||
Returns `true` if the template contains at least one placeholder.
|
||||
|
||||
### `.extractVariables(template): string[]`
|
||||
|
||||
Returns all unique variable names referenced in the template.
|
||||
|
||||
### `escapeHtmlChars(str: string): string`
|
||||
|
||||
Standalone export. Escapes `&`, `<`, `>`, `"`, `'`.
|
||||
|
||||
### `escapeRegex(str: string): string`
|
||||
|
||||
Standalone export. Escapes all regex metacharacters for safe use in `RegExp`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The placeholder regex is rebuilt on each call to `replaceValues` rather than cached, because `options.prefix` / `suffix` could theoretically contain characters whose escaped forms would produce a different pattern — rebuilding is correct and the cost is negligible for the expected template sizes
|
||||
- Recursive interpolation is capped at `maxDepth` (default 10) to prevent infinite loops when a value itself contains placeholder syntax
|
||||
- HTML escaping is applied _after_ formatting so that formatter output (e.g., `"$1,234"`) is escaped as a unit, not before
|
||||
- `missingVariables` accumulates in-place via the closure; the engine never throws on a missing variable — it leaves the placeholder in place and records the name
|
||||
@@ -0,0 +1,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
|
||||
>;
|
||||
@@ -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("<>&"'");
|
||||
});
|
||||
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("<b>");
|
||||
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: <script>");
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# observability/metrics
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module is the single home for the three OTel histograms emitted
|
||||
from the hot paths of the `@suaveplan/i18n-icu-parser` package:
|
||||
`i18n.parse.duration_ms`, `i18n.interpolation.duration_ms`, and
|
||||
`i18n.plural.duration_ms`. Centralising the metric construction here
|
||||
keeps the production code paths free of meter boilerplate and makes
|
||||
it trivial to audit (or relabel) every emission point in the
|
||||
package.
|
||||
|
||||
The module wraps `@opentelemetry/api` so that consumers without an
|
||||
SDK installed observe a no-op meter — there is no runtime cost when
|
||||
no SDK is present, and no leaf package or test fixture has to opt
|
||||
out of telemetry to avoid stray exports.
|
||||
|
||||
## Features
|
||||
|
||||
- Three OTel histograms with documented `description` and `unit`
|
||||
fields so dashboards can render them without per-app config
|
||||
- Bounded attribute cardinality: every recorder enforces a small
|
||||
fixed set of attributes (`icu_syntax`, `escape_html`, `locale`,
|
||||
`ordinal`) so metric pipelines do not blow up on unbounded
|
||||
cardinality
|
||||
- Single shared meter named `@suaveplan/i18n-icu-parser` so log
|
||||
sinks can group every package's emissions without parsing
|
||||
histogram names
|
||||
- No-op fallback when no OTel SDK is wired (the upstream API
|
||||
returns no-op meters) — production-safe with zero deps
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
recordInterpolationDuration,
|
||||
recordParseDuration,
|
||||
recordPluralDuration,
|
||||
} from "@suaveplan/i18n-icu-parser";
|
||||
|
||||
const t0 = performance.now();
|
||||
parser.parse(template);
|
||||
recordParseDuration(performance.now() - t0, true);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { recordInterpolationDuration } from "@suaveplan/i18n-icu-parser";
|
||||
|
||||
// Wire any SDK (NodeSDK, BrowserSDK, etc.) — the histograms below
|
||||
// flow through automatically because the package shares the
|
||||
// global meter provider.
|
||||
const provider = metrics.getMeterProvider();
|
||||
|
||||
// Inside a render loop:
|
||||
for (const tpl of templates) {
|
||||
const t0 = performance.now();
|
||||
engine.render(tpl, values);
|
||||
recordInterpolationDuration(performance.now() - t0, false);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordParseDuration(durationMs: number, icuSyntax: boolean): void`
|
||||
|
||||
Record a sample on the `i18n.parse.duration_ms` histogram.
|
||||
Attribute set: `{ "i18n.parse.icu_syntax": boolean }`.
|
||||
|
||||
### `recordInterpolationDuration(durationMs: number, escapeHtml: boolean): void`
|
||||
|
||||
Record a sample on the `i18n.interpolation.duration_ms` histogram.
|
||||
Attribute set: `{ "i18n.interpolation.escape_html": boolean }`.
|
||||
|
||||
### `recordPluralDuration(durationMs: number, locale: string, ordinal: boolean): void`
|
||||
|
||||
Record a sample on the `i18n.plural.duration_ms` histogram.
|
||||
Attribute set: `{ "i18n.plural.locale": string, "i18n.plural.ordinal": boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter is created once at module-load and shared across every
|
||||
recording path. OTel meters are designed to be long-lived; the
|
||||
module never re-creates them.
|
||||
- Attribute names use the `i18n.` prefix so consumers' dashboards
|
||||
can group by package without inspecting the histogram name.
|
||||
- The `locale` attribute is the **normalised** base locale (e.g.
|
||||
`"en"` not `"en-GB"`) — call sites pre-normalise so cardinality
|
||||
stays bounded by the CLDR set.
|
||||
- No counters, gauges, or span events are emitted from this module
|
||||
— only histograms. Span emission would require an active tracer;
|
||||
the parser stays pure to keep that surface clean.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
# FormatBridge
|
||||
|
||||
## Purpose
|
||||
|
||||
`FormatBridge` is the dependency-injection seam that lets
|
||||
`@suaveplan/i18n-icu-parser` render ICU format placeholders
|
||||
(`{value, number}`, `{amount, number, currency=USD}`,
|
||||
`{date, date, short}`, `{ts, time, long}`,
|
||||
`{delta, relativeTime}`) without statically depending on any
|
||||
formatter package. The renderer (`renderNodes`) consults whichever
|
||||
methods are present on the bridge and falls back to
|
||||
`String(value)` when a method (or the bridge itself) is undefined.
|
||||
This keeps the parser package a true universal leaf with no
|
||||
`Intl.*` runtime cost when the consumer doesn't need locale-aware
|
||||
formatting.
|
||||
|
||||
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
|
||||
bridge by importing the formatter packages
|
||||
(`@suaveplan/i18n-formatter-number`, `-date`, `-currency`,
|
||||
`-relative-time`) and threads it through every render call so
|
||||
consumers who use `createI18n({ ... })` get full ICU output with
|
||||
zero wiring. Consumers who construct a `MessageParser` directly
|
||||
MAY pass their own bridge or accept the no-Intl fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- Five method slots, every one OPTIONAL: `number`, `currency`,
|
||||
`date`, `time`, `relativeTime`
|
||||
- Renderer never throws on missing bridge or missing method —
|
||||
always falls back to `String(value)` (or
|
||||
`${currency} ${String(value)}` for currency)
|
||||
- Style enums (`NumberFormatStyle`, `DateStyle`,
|
||||
`RelativeTimeStyle`) declared in `format-bridge.types.ts` for
|
||||
type-only imports without runtime cost
|
||||
- `noopFormatBridge` exported as a frozen singleton consumers can
|
||||
use directly or spread-and-extend
|
||||
- Single-file subpath export (`@suaveplan/i18n-icu-parser/parser/format-bridge`)
|
||||
so the slim core's wiring imports only this one module — keeps
|
||||
bundle size minimal
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { renderNodes, type FormatBridge } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
const bridge: FormatBridge = {
|
||||
number: (value, locale) =>
|
||||
new Intl.NumberFormat(locale).format(value),
|
||||
};
|
||||
|
||||
const nodes = [
|
||||
{ type: "text" as const, value: "Total: " },
|
||||
{
|
||||
type: "typed-argument" as const,
|
||||
name: "amount",
|
||||
argType: "number" as const,
|
||||
style: "decimal",
|
||||
},
|
||||
];
|
||||
|
||||
const text = renderNodes(nodes, { amount: 1234.5 }, {
|
||||
locale: "en-US",
|
||||
formatBridge: bridge,
|
||||
});
|
||||
// "Total: 1,234.5"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MessageParser,
|
||||
noopFormatBridge,
|
||||
type FormatBridge,
|
||||
} from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
// Spread-and-extend pattern: keep the no-op fallback for anything
|
||||
// you don't care about, override just the methods you need.
|
||||
const bridge: FormatBridge = {
|
||||
...noopFormatBridge,
|
||||
currency: (value, currency, locale) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(value),
|
||||
date: (value, locale, style = "medium") => {
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
const presets: Record<string, Intl.DateTimeFormatOptions> = {
|
||||
short: { dateStyle: "short" },
|
||||
medium: { dateStyle: "medium" },
|
||||
long: { dateStyle: "long" },
|
||||
full: { dateStyle: "full" },
|
||||
};
|
||||
return new Intl.DateTimeFormat(locale, presets[style]).format(d);
|
||||
},
|
||||
};
|
||||
|
||||
const parser = new MessageParser();
|
||||
const out = parser.format(
|
||||
"Total: {amount, number, currency/USD} on {when, date, long}",
|
||||
{ amount: 1234.5, when: new Date("2026-05-05T00:00:00Z") },
|
||||
{ locale: "en-US", formatBridge: bridge }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface FormatBridge`
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `number?` | `(value, locale, style?) => string` | Style is `NumberFormatStyle \| string \| undefined` so notation styles (`compact`, `scientific`) pass through as raw strings |
|
||||
| `currency?` | `(value, currency, locale) => string` | `currency` is an ISO 4217 code |
|
||||
| `date?` | `(value, locale, style?) => string` | Default style is `medium` |
|
||||
| `time?` | `(value, locale, longForm?) => string` | `longForm: true` should include seconds |
|
||||
| `relativeTime?` | `(value, locale, style?) => string` | Style is `"long" \| "short" \| "narrow"` |
|
||||
|
||||
### `noopFormatBridge: Readonly<FormatBridge>`
|
||||
|
||||
Frozen identity bridge. Every method coerces inputs to
|
||||
`String(value)` (or `${currency} ${String(value)}` for
|
||||
`currency`). Safe to share by reference; mutation throws TypeError.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bridge is intentionally OPTIONAL across the entire surface
|
||||
area. The renderer never asserts `formatBridge != null`; it
|
||||
uses optional chaining (`bridge?.number?.(...)`) and falls
|
||||
through to `String(value)` on undefined results. This means a
|
||||
partial bridge (only `number` defined) still renders date and
|
||||
currency placeholders — they just emit raw `String(value)` for
|
||||
the missing methods.
|
||||
- `noopFormatBridge` is `Object.freeze`d at module load. This
|
||||
guards against accidental mutation in tests or downstream
|
||||
packages corrupting the shared singleton.
|
||||
- `currency` returns `${code} ${String(value)}` rather than
|
||||
`String(value)` alone so the no-op fallback still surfaces the
|
||||
ISO 4217 code in test output, making fallback hits visible
|
||||
during development.
|
||||
- The style enums are declared in `format-bridge.types.ts` to keep
|
||||
this implementation file zero-runtime-cost when imported for
|
||||
the bridge interface alone.
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# MessageParser
|
||||
|
||||
## Purpose
|
||||
|
||||
`MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to render that AST back to a string with caller-supplied values. It enables rich localised messages that include plural selection, gender/select branching, and nested variable substitution — without pulling in a full ICU implementation.
|
||||
|
||||
The parser is also available in a simpler non-ICU mode that only processes `{variable}` substitution with configurable delimiters, which is sufficient for many template-string use cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses ICU-style plural: `{count, plural, one {# item} other {# items}}`
|
||||
- Parses ICU-style select: `{gender, select, male {He} female {She} other {They}}`
|
||||
- Nested ICU expressions and recursive choices
|
||||
- Plural `offset:` support (`{count, plural, offset:1 ...}`)
|
||||
- Exact-value plural matching: `=0 {no results}`, `=1 {one result}`
|
||||
- `#` placeholder inside plural branches resolves to `count - offset`
|
||||
- Non-ICU simple-variable mode with configurable open/close delimiters
|
||||
- `extractVariables(nodes)` — extracts all referenced variable names from an AST
|
||||
- `renderNodes(nodes, values, pluralSelect?)` — renders an AST to a string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { MessageParser } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
const parser = new MessageParser();
|
||||
|
||||
// Simple variable
|
||||
const { nodes } = parser.parse("Hello, {name}!");
|
||||
const text = parser.render({ nodes, variables: ["name"] }, { name: "World" });
|
||||
// text === "Hello, World!"
|
||||
|
||||
// One-liner
|
||||
const msg = parser.format(
|
||||
"{count, plural, one {# apple} other {# apples}}",
|
||||
{ count: 3 }
|
||||
);
|
||||
// msg === "3 apples"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { MessageParser, renderNodes, extractVariables } from "@suaveplan/i18n-icu-parser/parser";
|
||||
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
|
||||
|
||||
const parser = new MessageParser();
|
||||
const engine = new PluralizationEngine();
|
||||
|
||||
const template = "{gender, select, male {He has} female {She has} other {They have}} {count, plural, one {# item} other {# items}}.";
|
||||
const parsed = parser.parse(template);
|
||||
|
||||
// Use PluralizationEngine for the plural select callback
|
||||
const text = parser.render(parsed, { gender: "female", count: 5 }, (count) =>
|
||||
engine.getCategory(count, "en")
|
||||
);
|
||||
// text === "She has 5 items."
|
||||
|
||||
// Extract variable names from a template without rendering
|
||||
const vars = extractVariables(parsed.nodes);
|
||||
// vars === ["gender", "count"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MessageParser(options?: ParserOptions)`
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `variablePrefix` | `"{"` | Simple-mode open delimiter |
|
||||
| `variableSuffix` | `"}"` | Simple-mode close delimiter |
|
||||
| `icuSyntax` | `true` | Enable plural/select parsing |
|
||||
|
||||
### `.parse(message): ParsedMessage`
|
||||
|
||||
Returns `{ nodes: MessageNode[]; variables: string[] }`.
|
||||
|
||||
### `.render(parsed, values, pluralSelect?): string`
|
||||
|
||||
Renders a `ParsedMessage` to a string. `pluralSelect(count, category)` is used to select plural forms — defaults to `one`/`other` if omitted.
|
||||
|
||||
### `.format(message, values, pluralSelect?): string`
|
||||
|
||||
Convenience: `parse` + `render` in one call.
|
||||
|
||||
### `extractVariables(nodes: MessageNode[]): string[]`
|
||||
|
||||
Standalone export. Returns all unique variable names (excluding `"#"`) referenced in the node tree.
|
||||
|
||||
### `renderNodes(nodes, values, pluralSelect?): string`
|
||||
|
||||
Standalone export. Renders an array of `MessageNode` to a string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser is a hand-written recursive descent parser; it does not use a grammar generator or regex for structural parsing (only for simple-mode delimiter matching)
|
||||
- ICU `plural` and `select` nodes are parsed in a single forward pass; backtracking is never required
|
||||
- The `#` sentinel in plural branches is emitted as a `VariableNode` with `name === "#"` and resolved during rendering by injecting `{ "#": adjusted }` into the values map
|
||||
- `renderNodes` is exported to allow rendering pre-parsed ASTs without re-parsing, which is the expected hot-path in production (parse once, render many times)
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
580
packages/localization/i18n/i18n-icu-parser/src/parser/message.ts
Normal file
580
packages/localization/i18n/i18n-icu-parser/src/parser/message.ts
Normal 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;
|
||||
}
|
||||
@@ -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[] };
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
12
packages/localization/i18n/i18n-icu-parser/tsconfig.lib.json
Normal file
12
packages/localization/i18n/i18n-icu-parser/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/localization/i18n/i18n-icu-parser/vite.config.ts
Normal file
38
packages/localization/i18n/i18n-icu-parser/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
29
packages/localization/i18n/i18n-icu-parser/vitest.config.ts
Normal file
29
packages/localization/i18n/i18n-icu-parser/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user