Initial Commit
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: i18n-icu-parser (i18n-icu-parser)
|
||||
|
||||
**Package**: `@suaveplan/i18n-icu-parser`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ICU MessageFormat parser
|
||||
|
||||
The package SHALL export a `MessageParser` class that parses ICU MessageFormat templates into a `MessageNode[]` AST. Supported node kinds: plain text, `{var}` placeholders, `{var, format, style}` format placeholders, `{var, plural, one{...} other{...}}` plural placeholders, `{var, select, key{...} other{...}}` select placeholders, and arbitrarily-nested combinations. The parser SHALL throw `I18nError(I18N.INVALID_OPTIONS)` on malformed templates.
|
||||
|
||||
#### Scenario: parses a plain-text template
|
||||
|
||||
- **WHEN** `parser.parse("hello world")` is called
|
||||
- **THEN** the result is a single-element array with kind `"plain"` and value `"hello world"`
|
||||
|
||||
#### Scenario: parses a variable placeholder
|
||||
|
||||
- **WHEN** `parser.parse("hello {name}")` is called
|
||||
- **THEN** the result has two nodes: a plain `"hello "` and a var `"name"`
|
||||
|
||||
#### Scenario: parses a plural placeholder
|
||||
|
||||
- **WHEN** `parser.parse("{count, plural, one {# item} other {# items}}")` is called
|
||||
- **THEN** the result has one plural node with two branches keyed `"one"` and `"other"`
|
||||
|
||||
#### Scenario: rejects malformed template
|
||||
|
||||
- **WHEN** `parser.parse("{unclosed")` is called
|
||||
- **THEN** the call throws `I18nError` with code `"I18N.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/parser/message.test.ts::"parses plain text"` AND `packages/i18n/i18n-icu-parser/src/parser/message.test.ts::"parses variable placeholder"` AND `packages/i18n/i18n-icu-parser/src/parser/message.test.ts::"parses plural placeholder"` AND `packages/i18n/i18n-icu-parser/src/parser/message.test.ts::"rejects unclosed brace"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ICU render with FormatBridge dependency injection
|
||||
|
||||
The package SHALL export a `renderNodes(nodes, values, locale, options)` function that walks a `MessageNode[]` AST and produces a final string. Format placeholders (`{var, number}`, `{amount, number, currency=USD}`, `{date, date, short}`, `{ts, time, long}`, `{delta, relativeTime}`) SHALL be rendered by calling methods on `options.formatBridge` if supplied; if `formatBridge` is undefined OR the relevant method is undefined on the bridge, the renderer SHALL fall back to `String(value)` and SHALL NOT throw. Plural and select placeholders SHALL be resolved against the supplied `PluralizationEngine`. Missing variables SHALL throw `I18nError(I18N.INTERPOLATION_ERROR)`.
|
||||
|
||||
#### Scenario: format placeholder dispatches to bridge
|
||||
|
||||
- **GIVEN** a parsed AST containing a `{value, number}` node
|
||||
- **AND** an `options.formatBridge` whose `number(v, l)` returns `"FORMATTED"`
|
||||
- **WHEN** `renderNodes(ast, { value: 42 }, "en-GB", options)` is called
|
||||
- **THEN** the result equals `"FORMATTED"`
|
||||
|
||||
#### Scenario: missing bridge falls back to string coercion
|
||||
|
||||
- **GIVEN** a parsed AST containing a `{value, number}` node
|
||||
- **AND** `options.formatBridge` is undefined
|
||||
- **WHEN** `renderNodes(ast, { value: 42 }, "en-GB", options)` is called
|
||||
- **THEN** the result equals `"42"`
|
||||
|
||||
#### Scenario: bridge missing one method falls back to string
|
||||
|
||||
- **GIVEN** a parsed AST containing a `{date, date, short}` node
|
||||
- **AND** `options.formatBridge.date` is undefined but `formatBridge.number` is defined
|
||||
- **WHEN** `renderNodes(ast, { date: new Date(0) }, "en-GB", options)` is called
|
||||
- **THEN** the result equals `"Thu Jan 01 1970 00:00:00 GMT+0000 (Coordinated Universal Time)"` (or platform-equivalent default `String(date)` form)
|
||||
|
||||
#### Scenario: missing variable throws
|
||||
|
||||
- **GIVEN** a parsed AST containing a `{name}` node
|
||||
- **WHEN** `renderNodes(ast, {}, "en-GB", {})` is called
|
||||
- **THEN** the call throws `I18nError` with code `"I18N.INTERPOLATION_ERROR"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/parser/message-render.test.ts::"format placeholder dispatches to bridge"` AND `packages/i18n/i18n-icu-parser/src/parser/message-render.test.ts::"missing bridge falls back to String"` AND `packages/i18n/i18n-icu-parser/src/parser/message-render.test.ts::"bridge missing one method falls back"` AND `packages/i18n/i18n-icu-parser/src/parser/message-render.test.ts::"missing variable throws INTERPOLATION_ERROR"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FormatBridge interface
|
||||
|
||||
The package SHALL export a `FormatBridge` interface declaring optional `number`, `currency`, `date`, `time`, `relativeTime` methods. Every method SHALL return `string`. The package SHALL also export a `noopFormatBridge: FormatBridge` whose every method returns `String(value)` (or `String(value[0])` for list-like inputs).
|
||||
|
||||
#### Scenario: noopFormatBridge implements FormatBridge
|
||||
|
||||
- **WHEN** `expectTypeOf(noopFormatBridge).toMatchTypeOf<FormatBridge>()` runs
|
||||
- **THEN** the type-level check passes
|
||||
|
||||
#### Scenario: noopFormatBridge.number coerces to string
|
||||
|
||||
- **WHEN** `noopFormatBridge.number!(42, "en-GB")` is called
|
||||
- **THEN** the result equals `"42"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/parser/format-bridge.test.ts::"noopFormatBridge implements FormatBridge"` AND `packages/i18n/i18n-icu-parser/src/parser/format-bridge.test.ts::"noopFormatBridge.number coerces to string"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `{{var}}` interpolation engine
|
||||
|
||||
The package SHALL export an `InterpolationEngine` class that performs `{{varname}}` substitution with configurable delimiters (defaults `{{` and `}}` per `DEFAULT_INTERPOLATION` from `@suaveplan/i18n-shared`). The engine SHALL enforce a `maxDepth` limit (default 10) on nested substitutions and SHALL throw `I18nError(I18N.NESTING_DEPTH_EXCEEDED)` on overflow. The engine SHALL HTML-escape values when `escapeHtml: true` is supplied.
|
||||
|
||||
#### Scenario: substitutes a single variable
|
||||
|
||||
- **WHEN** `engine.interpolate("hello {{name}}", { name: "Ada" })` is called
|
||||
- **THEN** the result equals `"hello Ada"`
|
||||
|
||||
#### Scenario: enforces depth limit
|
||||
|
||||
- **GIVEN** values that recursively reference themselves via interpolation
|
||||
- **WHEN** `engine.interpolate(template, values)` exceeds `maxDepth`
|
||||
- **THEN** the call throws `I18nError` with code `"I18N.NESTING_DEPTH_EXCEEDED"`
|
||||
|
||||
#### Scenario: HTML escapes when configured
|
||||
|
||||
- **WHEN** `engine.interpolate("safe: {{html}}", { html: "<script>" }, { escapeHtml: true })` is called
|
||||
- **THEN** the result equals `"safe: <script>"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/interpolation/interpolation.test.ts::"substitutes single variable"` AND `packages/i18n/i18n-icu-parser/src/interpolation/interpolation.test.ts::"enforces maxDepth"` AND `packages/i18n/i18n-icu-parser/src/interpolation/interpolation.test.ts::"escapes HTML when configured"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pluralization with 9 CLDR rule sets
|
||||
|
||||
The package SHALL export a `PluralizationEngine` class and 9 plural-rule functions: `arabicPlural`, `englishPlural`, `welshPlural`, `russianPlural`, `polishPlural`, `chinesePlural`, `japanesePlural`, `frenchPlural`, `germanPlural`. Each function SHALL accept `(n: number)` and return one of the CLDR `PluralCategory` values (`"zero" | "one" | "two" | "few" | "many" | "other"`). The engine SHALL throw `I18nError(I18N.PLURAL_RULE_NOT_FOUND)` when asked for a rule with no matching locale.
|
||||
|
||||
#### Scenario: English returns "one" for 1 and "other" for 2
|
||||
|
||||
- **WHEN** `englishPlural(1)` and `englishPlural(2)` are called
|
||||
- **THEN** the results are `"one"` and `"other"`
|
||||
|
||||
#### Scenario: Welsh covers all six categories
|
||||
|
||||
- **WHEN** `welshPlural` is called for boundary values 0, 1, 2, 3, 6, 11
|
||||
- **THEN** the results cover `"zero"`, `"one"`, `"two"`, `"few"`, `"many"`, `"other"`
|
||||
|
||||
#### Scenario: Unknown locale rule throws
|
||||
|
||||
- **WHEN** `engine.pluralize("xx-XX", 1)` is called for an unregistered locale
|
||||
- **THEN** the call throws `I18nError` with code `"I18N.PLURAL_RULE_NOT_FOUND"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/pluralization/pluralization.test.ts::"English plural rule"` AND `packages/i18n/i18n-icu-parser/src/pluralization/pluralization.test.ts::"Welsh plural covers all six categories"` AND `packages/i18n/i18n-icu-parser/src/pluralization/pluralization.test.ts::"unknown locale throws PLURAL_RULE_NOT_FOUND"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HTML and regex escape helpers
|
||||
|
||||
The package SHALL export `escapeHtmlChars(value: string): string` that escapes `<`, `>`, `&`, `"`, `'` to their HTML entity equivalents, and `escapeRegex(value: string): string` that escapes every regular-expression meta character.
|
||||
|
||||
#### Scenario: HTML escape covers all five chars
|
||||
|
||||
- **WHEN** `escapeHtmlChars("<a href=\"x\">'\\&'</a>")` is called
|
||||
- **THEN** the result equals `"<a href="x">'\\&'</a>"`
|
||||
|
||||
#### Scenario: regex escape covers all meta chars
|
||||
|
||||
- **WHEN** `escapeRegex(".*+?^${}()|[]\\")` is called
|
||||
- **THEN** the result equals `"\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\"`
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/interpolation/escape-html.test.ts::"escapes all five HTML chars"` AND `packages/i18n/i18n-icu-parser/src/interpolation/escape-regex.test.ts::"escapes all regex meta chars"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL declare subpath exports so consumers can import only the surfaces they need:
|
||||
|
||||
- `@suaveplan/i18n-icu-parser` — root barrel
|
||||
- `@suaveplan/i18n-icu-parser/parser` — parser + render + format-bridge + types
|
||||
- `@suaveplan/i18n-icu-parser/parser/format-bridge` — `FormatBridge` interface + `noopFormatBridge` only
|
||||
- `@suaveplan/i18n-icu-parser/interpolation` — interpolation engine + helpers + types
|
||||
- `@suaveplan/i18n-icu-parser/pluralization` — `PluralizationEngine` + every plural-rule function + types
|
||||
|
||||
#### Scenario: every subpath resolves
|
||||
|
||||
- **WHEN** every documented subpath is imported in a consumer test fixture
|
||||
- **THEN** every import resolves with no `Module not found` errors
|
||||
|
||||
**Verified by:** `packages/i18n/i18n-icu-parser/src/index.test.ts::"every documented subpath export resolves"`
|
||||
Reference in New Issue
Block a user