first commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
# FormatBridge
|
||||
|
||||
## Purpose
|
||||
|
||||
`FormatBridge` is the dependency-injection seam that lets
|
||||
`@suaveplan/i18n-icu-parser` render ICU format placeholders
|
||||
(`{value, number}`, `{amount, number, currency=USD}`,
|
||||
`{date, date, short}`, `{ts, time, long}`,
|
||||
`{delta, relativeTime}`) without statically depending on any
|
||||
formatter package. The renderer (`renderNodes`) consults whichever
|
||||
methods are present on the bridge and falls back to
|
||||
`String(value)` when a method (or the bridge itself) is undefined.
|
||||
This keeps the parser package a true universal leaf with no
|
||||
`Intl.*` runtime cost when the consumer doesn't need locale-aware
|
||||
formatting.
|
||||
|
||||
The slim `@suaveplan/i18n` core (Wave 6) constructs a default
|
||||
bridge by importing the formatter packages
|
||||
(`@suaveplan/i18n-formatter-number`, `-date`, `-currency`,
|
||||
`-relative-time`) and threads it through every render call so
|
||||
consumers who use `createI18n({ ... })` get full ICU output with
|
||||
zero wiring. Consumers who construct a `MessageParser` directly
|
||||
MAY pass their own bridge or accept the no-Intl fallback.
|
||||
|
||||
## Features
|
||||
|
||||
- Five method slots, every one OPTIONAL: `number`, `currency`,
|
||||
`date`, `time`, `relativeTime`
|
||||
- Renderer never throws on missing bridge or missing method —
|
||||
always falls back to `String(value)` (or
|
||||
`${currency} ${String(value)}` for currency)
|
||||
- Style enums (`NumberFormatStyle`, `DateStyle`,
|
||||
`RelativeTimeStyle`) declared in `format-bridge.types.ts` for
|
||||
type-only imports without runtime cost
|
||||
- `noopFormatBridge` exported as a frozen singleton consumers can
|
||||
use directly or spread-and-extend
|
||||
- Single-file subpath export (`@suaveplan/i18n-icu-parser/parser/format-bridge`)
|
||||
so the slim core's wiring imports only this one module — keeps
|
||||
bundle size minimal
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { renderNodes, type FormatBridge } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
const bridge: FormatBridge = {
|
||||
number: (value, locale) =>
|
||||
new Intl.NumberFormat(locale).format(value),
|
||||
};
|
||||
|
||||
const nodes = [
|
||||
{ type: "text" as const, value: "Total: " },
|
||||
{
|
||||
type: "typed-argument" as const,
|
||||
name: "amount",
|
||||
argType: "number" as const,
|
||||
style: "decimal",
|
||||
},
|
||||
];
|
||||
|
||||
const text = renderNodes(nodes, { amount: 1234.5 }, {
|
||||
locale: "en-US",
|
||||
formatBridge: bridge,
|
||||
});
|
||||
// "Total: 1,234.5"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MessageParser,
|
||||
noopFormatBridge,
|
||||
type FormatBridge,
|
||||
} from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
// Spread-and-extend pattern: keep the no-op fallback for anything
|
||||
// you don't care about, override just the methods you need.
|
||||
const bridge: FormatBridge = {
|
||||
...noopFormatBridge,
|
||||
currency: (value, currency, locale) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(value),
|
||||
date: (value, locale, style = "medium") => {
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
const presets: Record<string, Intl.DateTimeFormatOptions> = {
|
||||
short: { dateStyle: "short" },
|
||||
medium: { dateStyle: "medium" },
|
||||
long: { dateStyle: "long" },
|
||||
full: { dateStyle: "full" },
|
||||
};
|
||||
return new Intl.DateTimeFormat(locale, presets[style]).format(d);
|
||||
},
|
||||
};
|
||||
|
||||
const parser = new MessageParser();
|
||||
const out = parser.format(
|
||||
"Total: {amount, number, currency/USD} on {when, date, long}",
|
||||
{ amount: 1234.5, when: new Date("2026-05-05T00:00:00Z") },
|
||||
{ locale: "en-US", formatBridge: bridge }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface FormatBridge`
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `number?` | `(value, locale, style?) => string` | Style is `NumberFormatStyle \| string \| undefined` so notation styles (`compact`, `scientific`) pass through as raw strings |
|
||||
| `currency?` | `(value, currency, locale) => string` | `currency` is an ISO 4217 code |
|
||||
| `date?` | `(value, locale, style?) => string` | Default style is `medium` |
|
||||
| `time?` | `(value, locale, longForm?) => string` | `longForm: true` should include seconds |
|
||||
| `relativeTime?` | `(value, locale, style?) => string` | Style is `"long" \| "short" \| "narrow"` |
|
||||
|
||||
### `noopFormatBridge: Readonly<FormatBridge>`
|
||||
|
||||
Frozen identity bridge. Every method coerces inputs to
|
||||
`String(value)` (or `${currency} ${String(value)}` for
|
||||
`currency`). Safe to share by reference; mutation throws TypeError.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bridge is intentionally OPTIONAL across the entire surface
|
||||
area. The renderer never asserts `formatBridge != null`; it
|
||||
uses optional chaining (`bridge?.number?.(...)`) and falls
|
||||
through to `String(value)` on undefined results. This means a
|
||||
partial bridge (only `number` defined) still renders date and
|
||||
currency placeholders — they just emit raw `String(value)` for
|
||||
the missing methods.
|
||||
- `noopFormatBridge` is `Object.freeze`d at module load. This
|
||||
guards against accidental mutation in tests or downstream
|
||||
packages corrupting the shared singleton.
|
||||
- `currency` returns `${code} ${String(value)}` rather than
|
||||
`String(value)` alone so the no-op fallback still surfaces the
|
||||
ISO 4217 code in test output, making fallback hits visible
|
||||
during development.
|
||||
- The style enums are declared in `format-bridge.types.ts` to keep
|
||||
this implementation file zero-runtime-cost when imported for
|
||||
the bridge interface alone.
|
||||
@@ -0,0 +1,117 @@
|
||||
# message-render
|
||||
|
||||
## Purpose
|
||||
|
||||
`message-render` provides two standalone utility functions for `@suaveplan/i18n`: `extractVariables`, which collects all unique variable names referenced in a parsed message AST, and `renderNodes`, which renders an AST to a string using caller-supplied values. These are the hot-path primitives consumed by `MessageParser.render` and exposed as public exports for callers that need to work with pre-parsed ASTs directly.
|
||||
|
||||
Separating these pure functions from the `MessageParser` class allows them to be imported without pulling in the full parser, which is useful for rendering pre-parsed messages in tight loops or edge environments.
|
||||
|
||||
## Features
|
||||
|
||||
- `extractVariables` — collects unique variable names from a `MessageNode[]`, recursing into plural and select choices
|
||||
- `renderNodes` — renders a `MessageNode[]` to a string, supporting text, variable, plural, and select node types
|
||||
- Plural `offset` support: `count - offset` is passed as `"#"` into nested renders
|
||||
- Exact-match plural keys (`=0`, `=1`) take priority over category keys (`one`, `other`)
|
||||
- Nullable/undefined values are rendered as empty strings
|
||||
- Optional `pluralSelect` callback for language-specific plural category resolution
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { extractVariables, renderNodes } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
const nodes = [
|
||||
{ type: "text", value: "Hello, " },
|
||||
{ type: "variable", name: "name" },
|
||||
{ type: "text", value: "!" },
|
||||
];
|
||||
|
||||
const vars = extractVariables(nodes);
|
||||
console.log(vars); // ["name"]
|
||||
|
||||
const text = renderNodes(nodes, { name: "Alice" });
|
||||
console.log(text); // "Hello, Alice!"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { renderNodes } from "@suaveplan/i18n-icu-parser/parser";
|
||||
import type { MessageNode } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
// Pre-parsed plural node (normally produced by MessageParser)
|
||||
const nodes: MessageNode[] = [
|
||||
{
|
||||
type: "plural",
|
||||
variable: "count",
|
||||
offset: 0,
|
||||
choices: {
|
||||
one: [{ type: "variable", name: "#" }, { type: "text", value: " item" }],
|
||||
other: [{ type: "variable", name: "#" }, { type: "text", value: " items" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Custom plural selector for a language with more categories
|
||||
const pluralSelect = (n: number) => (n === 1 ? "one" : "other");
|
||||
|
||||
console.log(renderNodes(nodes, { count: 1 }, pluralSelect)); // "1 item"
|
||||
console.log(renderNodes(nodes, { count: 5 }, pluralSelect)); // "5 items"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `extractVariables(nodes: MessageNode[]): string[]`
|
||||
|
||||
Extract all unique variable names referenced in `nodes`. Recurses into `plural` and `select` choice branches. Excludes the special `"#"` sentinel used inside plural branches.
|
||||
|
||||
**Returns:** Array of unique variable name strings in order of first appearance.
|
||||
|
||||
### `renderNodes(nodes: MessageNode[], values: Record<string, unknown>, options?: RenderOptions | RenderContext | pluralSelect): string`
|
||||
|
||||
Render `nodes` to a string.
|
||||
|
||||
| Node type | Behaviour |
|
||||
|-----------|-----------|
|
||||
| `text` | Appended verbatim |
|
||||
| `variable` | Replaced with `String(values[name])`, or `""` if `null`/`undefined`. Throws `I18nError(I18N.INTERPOLATION_ERROR)` when the key is ABSENT. `"#"` resolves from `values["#"]` |
|
||||
| `plural` | Resolves count, adjusts by offset, selects branch by exact key then category then `"other"`, recurses |
|
||||
| `select` | Selects branch by string value of variable, falls back to `"other"`, recurses |
|
||||
| `typed-argument` | Routes through `options.formatBridge` (`number` / `currency` / `date` / `time`); falls back to `String(value)` if bridge or method is undefined |
|
||||
| `tag` | If `values[tagName]` is a function, calls it with the rendered inner string; otherwise renders inner content as-is |
|
||||
|
||||
The `pluralSelect(count, category)` callback is used to select plural categories for plural nodes. When absent, falls back to `count === 1 ? "one" : "other"`.
|
||||
|
||||
### `RenderOptions`
|
||||
|
||||
```typescript
|
||||
interface RenderOptions extends RenderContext {
|
||||
readonly formatBridge?: FormatBridge;
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
readonly locale?: string;
|
||||
readonly pluralSelect?: (count: number, category: string) => string;
|
||||
readonly ordinalSelect?: (count: number) => string;
|
||||
}
|
||||
```
|
||||
|
||||
The `formatBridge` slot is the dependency-injection seam between
|
||||
this parser package and the per-formatter packages
|
||||
(see `format-bridge.md`). When supplied, the renderer dispatches
|
||||
ICU format placeholders (`{value, number}`, `{amount, number,
|
||||
currency=USD}`, `{date, date, short}`, `{ts, time, long}`) to the
|
||||
bridge methods. When the bridge OR a specific method is undefined,
|
||||
the renderer coerces the value to `String(value)` and never throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`renderNodes` is recursive: plural and select nodes recursively call `renderNodes` on the matched choice's node array. For plural nodes, the adjusted count (`count - offset`) is injected as `values["#"]` into the recursive call so that `#` placeholders inside plural branches resolve correctly.
|
||||
|
||||
Exact-match keys (e.g. `=0`, `=2`) in plural choices are checked before category keys, matching the ICU specification for exact-value overrides.
|
||||
|
||||
The `formatBridge` slot keeps this package a true universal
|
||||
leaf with no `Intl.*` dependency. The slim `@suaveplan/i18n` core
|
||||
(Wave 6) constructs a default bridge over the formatter packages
|
||||
and threads it through `createI18n({ ... })` so consumers get
|
||||
locale-aware output automatically.
|
||||
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal file
99
docs/localization/i18n/i18n-icu-parser/src/parser/message.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# MessageParser
|
||||
|
||||
## Purpose
|
||||
|
||||
`MessageParser` converts ICU-like message strings into a typed AST (Abstract Syntax Tree), and provides utilities to render that AST back to a string with caller-supplied values. It enables rich localised messages that include plural selection, gender/select branching, and nested variable substitution — without pulling in a full ICU implementation.
|
||||
|
||||
The parser is also available in a simpler non-ICU mode that only processes `{variable}` substitution with configurable delimiters, which is sufficient for many template-string use cases.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses ICU-style plural: `{count, plural, one {# item} other {# items}}`
|
||||
- Parses ICU-style select: `{gender, select, male {He} female {She} other {They}}`
|
||||
- Nested ICU expressions and recursive choices
|
||||
- Plural `offset:` support (`{count, plural, offset:1 ...}`)
|
||||
- Exact-value plural matching: `=0 {no results}`, `=1 {one result}`
|
||||
- `#` placeholder inside plural branches resolves to `count - offset`
|
||||
- Non-ICU simple-variable mode with configurable open/close delimiters
|
||||
- `extractVariables(nodes)` — extracts all referenced variable names from an AST
|
||||
- `renderNodes(nodes, values, pluralSelect?)` — renders an AST to a string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { MessageParser } from "@suaveplan/i18n-icu-parser/parser";
|
||||
|
||||
const parser = new MessageParser();
|
||||
|
||||
// Simple variable
|
||||
const { nodes } = parser.parse("Hello, {name}!");
|
||||
const text = parser.render({ nodes, variables: ["name"] }, { name: "World" });
|
||||
// text === "Hello, World!"
|
||||
|
||||
// One-liner
|
||||
const msg = parser.format(
|
||||
"{count, plural, one {# apple} other {# apples}}",
|
||||
{ count: 3 }
|
||||
);
|
||||
// msg === "3 apples"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { MessageParser, renderNodes, extractVariables } from "@suaveplan/i18n-icu-parser/parser";
|
||||
import { PluralizationEngine } from "@suaveplan/i18n-icu-parser/pluralization";
|
||||
|
||||
const parser = new MessageParser();
|
||||
const engine = new PluralizationEngine();
|
||||
|
||||
const template = "{gender, select, male {He has} female {She has} other {They have}} {count, plural, one {# item} other {# items}}.";
|
||||
const parsed = parser.parse(template);
|
||||
|
||||
// Use PluralizationEngine for the plural select callback
|
||||
const text = parser.render(parsed, { gender: "female", count: 5 }, (count) =>
|
||||
engine.getCategory(count, "en")
|
||||
);
|
||||
// text === "She has 5 items."
|
||||
|
||||
// Extract variable names from a template without rendering
|
||||
const vars = extractVariables(parsed.nodes);
|
||||
// vars === ["gender", "count"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new MessageParser(options?: ParserOptions)`
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `variablePrefix` | `"{"` | Simple-mode open delimiter |
|
||||
| `variableSuffix` | `"}"` | Simple-mode close delimiter |
|
||||
| `icuSyntax` | `true` | Enable plural/select parsing |
|
||||
|
||||
### `.parse(message): ParsedMessage`
|
||||
|
||||
Returns `{ nodes: MessageNode[]; variables: string[] }`.
|
||||
|
||||
### `.render(parsed, values, pluralSelect?): string`
|
||||
|
||||
Renders a `ParsedMessage` to a string. `pluralSelect(count, category)` is used to select plural forms — defaults to `one`/`other` if omitted.
|
||||
|
||||
### `.format(message, values, pluralSelect?): string`
|
||||
|
||||
Convenience: `parse` + `render` in one call.
|
||||
|
||||
### `extractVariables(nodes: MessageNode[]): string[]`
|
||||
|
||||
Standalone export. Returns all unique variable names (excluding `"#"`) referenced in the node tree.
|
||||
|
||||
### `renderNodes(nodes, values, pluralSelect?): string`
|
||||
|
||||
Standalone export. Renders an array of `MessageNode` to a string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The parser is a hand-written recursive descent parser; it does not use a grammar generator or regex for structural parsing (only for simple-mode delimiter matching)
|
||||
- ICU `plural` and `select` nodes are parsed in a single forward pass; backtracking is never required
|
||||
- The `#` sentinel in plural branches is emitted as a `VariableNode` with `name === "#"` and resolved during rendering by injecting `{ "#": adjusted }` into the values map
|
||||
- `renderNodes` is exported to allow rendering pre-parsed ASTs without re-parsing, which is the expected hot-path in production (parse once, render many times)
|
||||
Reference in New Issue
Block a user