Files
2026-05-27 23:29:53 +01:00

9.8 KiB

Capability Spec: i18n-icu-parser (i18n-icu-parser)

Package: @suaveplan/i18n-icu-parser Status: ACTIVE

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

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: &lt;script&gt;"

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 "&lt;a href=&quot;x&quot;&gt;&#39;\\&amp;&#39;&lt;/a&gt;"

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"