Files
suaveplan/openspec/changes/archive/2026-05-04-i18n-completion/tasks.md
2026-05-27 23:29:53 +01:00

177 lines
12 KiB
Markdown

# Tasks — i18n completion (ergonomic t, full ICU, plugin surface, Intl wrappers)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick.
## Sequencing
This change SHOULD land **after** `i18n-storage-backends` (the `Backend` interface is referenced by `createMissingKeyReporter` and the `loaded` event topic). It can land before or after `i18n-formatters` — the `formatList` / `formatDisplayName` / number-helper additions here are independent of `formatNumber` / `formatRelativeTime`.
## 1. Spec authoring
- [x] 1.1 Read `openspec/specs/i18n/spec.md` end-to-end (canonical capability spec)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Confirm the in-flight `i18n-storage-backends` + `i18n-formatters` + `i18n-spec-conformance` proposals do not conflict with this delta (especially the `MessageCatalog` constructor surface and OTel meter naming)
- [x] 1.4 `openspec change validate i18n-completion` exits with no errors
## 2. Ergonomic t() and TranslateOptions
- [x] 2.1 Define `TranslateOptions` and `PluralKey` in `packages/i18n/i18n/src/core/catalog/translate.types.ts`
- [x] 2.2 Implement `MessageCatalog.t(key, options?)` accepting the options bag (≤300 lines after refactor)
- [x] 2.3 Implement `count` → `PluralizationEngine.resolve` → branch selection with `count` auto-injected into `values`
- [x] 2.4 Implement `context` → `${key}_${context}` lookup with fallback to base
- [x] 2.5 Implement `defaultValue` fallback on miss
- [x] 2.6 Implement nested-key references `$t(other.key)` / `$t(ns:key)` with depth limit (default 4) and cycle detection
- [x] 2.7 Add `I18N.NESTING_DEPTH_EXCEEDED` error code via `defineCodes`
- [x] 2.8 `t.plural(key, count, values?)` shortcut helper
- [x] 2.9 `translate.test.ts` — count, context, defaultValue, nested refs (success + cycle), shortcut helper
- [x] 2.10 Sibling `translate.md`
## 3. ICU MessageFormat — selectordinal
- [x] 3.1 Add `SelectOrdinalNode` to `MessageNode` discriminated union
- [x] 3.2 Implement `parsePluralChoices(...)` branching for `selectordinal` (separate path so `plural` and `selectordinal` carry distinct `type` discriminants)
- [x] 3.3 Render path calls `pluralSelect(count, 'ordinal')` (or, when wired through `MessageCatalog.t`, `PluralizationEngine.getCategory(count, locale, true)`)
- [x] 3.4 `message.test.ts` — selectordinal selects `'few'` for English `3`, `'one'` for `1`, `'two'` for `2`
## 4. ICU MessageFormat — typed arguments
- [x] 4.1 Implement `parseTypedArg(message, start, name)` for `{name, date|time|number, style?}` shapes
- [x] 4.2 Add `TypedArgumentNode` to `MessageNode` discriminated union with `type: 'date' | 'time' | 'number'` and `style: string`
- [x] 4.3 Render path calls `formatDate` / `formatTime` / `formatNumber` from `@suaveplan/i18n/formatting/*`
- [x] 4.4 Number style parsing: `decimal`, `percent`, `currency/<ISO>`, `compact`, `scientific`, `engineering`
- [x] 4.5 Date/time style parsing: `short`, `medium`, `long`, `full`
- [x] 4.6 `message.test.ts` — typed currency, typed date, typed percent, typed compact
- [x] 4.7 `MessageParser.validate(message)` rejects unknown `type` values
## 5. ICU MessageFormat — apostrophe quoting
- [x] 5.1 Pre-process the source message in `parseIcu` to handle the three apostrophe rules
- [x] 5.2 `''` → single literal `'`
- [x] 5.3 `'<followed by`{`,`}`, or another`'`>...'` → quoted run, content is literal until matching `'`
- [x] 5.4 Standalone `'` (any other context) → literal apostrophe
- [x] 5.5 `message.test.ts` — `it''s here`, `'{literal}' is X`, `don't`
## 6. ICU MessageFormat — tag interpolation
- [x] 6.1 Add `TagNode { type: 'tag'; name: string; children: MessageNode[] }` to `MessageNode`
- [x] 6.2 `parseIcu` recognises `<name>...</name>` pairs; nested tags supported
- [x] 6.3 Implement `MessageParser.format(message, values)` where a value `name: (chunks: string) => string` renders the tag inline
- [x] 6.4 Implement `MessageParser.formatChunks<T>(message, values, chunkValues)` returning `Array<string | T>` — the React-friendly path
- [x] 6.5 `extractVariables` skips tag names (they're not interpolation variables)
- [x] 6.6 `message.test.ts` — inline string tag, nested tags, formatChunks mixed array
- [x] 6.7 Update `message.md` with the rich-text section and a React example referencing `@suaveplan/react-i18n`
## 7. MessageParser.validate
- [x] 7.1 Implement `validate(message): ValidationResult` returning `{ ok: true } | { ok: false; errors: ValidationError[] }`
- [x] 7.2 Detect: unbalanced braces, unbalanced apostrophe quoted runs, missing `other` branch in `plural` / `select` / `selectordinal`, unknown typed-argument type, unknown plural categories
- [x] 7.3 `message-validate.test.ts` — happy path + each error case
## 8. I18nInstance facade and plugin lifecycle
- [x] 8.1 Define `I18nPlugin`, `I18nInstance`, `PluginRegistry` in `src/core/plugin/plugin.types.ts`
- [x] 8.2 Implement `createI18n(options) → I18nInstance` composing existing engines + plugin registry
- [x] 8.3 `i18n.use(plugin)` — sync + async install; reject duplicate names with `I18N.PLUGIN_ALREADY_INSTALLED`
- [x] 8.4 `i18n.dispose()` — reverse-order plugin disposal
- [x] 8.5 `i18n.changeLanguage(locale)` — emits `languageChanged`
- [x] 8.6 Add `I18N.PLUGIN_ALREADY_INSTALLED` error code via `defineCodes`
- [x] 8.7 `plugin.test.ts` — install once, dispose order, duplicate rejection
- [x] 8.8 Sibling `plugin.md` walks through the plugin contract with three example plugins
## 9. Event bus topics
- [x] 9.1 Wire `@suaveplan/events` instance as `i18n.events`
- [x] 9.2 Emit `languageChanged` from `changeLanguage`
- [x] 9.3 Emit `loaded` from `MessageCatalog.set` / `mergeMessages` and from backend reads
- [x] 9.4 Emit `missingKey` from `MessageCatalog.get` BEFORE invoking `MissingKeyHandler`
- [x] 9.5 Emit `keyAdded` from `saveMissing` and HMR paths
- [x] 9.6 Emit `pluralRuleAdded` from `PluralizationEngine.registerRule`
- [x] 9.7 `events.test.ts` — every topic emits with the documented payload shape
## 10. List + display-name + number-helper formatters
- [x] 10.1 Implement `formatList(values, locale, options?)` at `src/formatting/list/list.ts` with cache + Zod options + OTel histogram
- [x] 10.2 Implement `formatDisplayName(code, locale, options)` at `src/formatting/display-name/display-name.ts`
- [x] 10.3 Implement `formatPercent`, `formatCompact`, `formatUnit` at `src/formatting/number/number-helpers.ts` — pre-bound `formatNumber` calls
- [x] 10.4 `list.test.ts`, `display-name.test.ts`, `number-helpers.test.ts` — happy paths + Intl rejection mapping
- [x] 10.5 Sibling `.md` for each
- [x] 10.6 Subpath exports `./formatting/list` and `./formatting/display-name` in `package.json`
## 11. Pluralization Intl-first refactor
- [x] 11.1 Refactor `PluralizationEngine.getRule` to prefer override → `Intl.PluralRules` → hand-written → default
- [x] 11.2 Hand-written rules remain registered (they're now overrides for runtimes lacking `Intl.PluralRules`)
- [x] 11.3 `pluralization.test.ts` — override wins, Intl used when no override, hand-written used when Intl missing (stub `Intl.PluralRules` throw)
## 12. Locale-aware first-day-of-week
- [x] 12.1 Refactor `getDayNames(locale, style?)` to use `new Intl.Locale(locale).weekInfo.firstDay` when available
- [x] 12.2 Fall back to Monday-start when `weekInfo` is undefined or throws
- [x] 12.3 `date.test.ts` — `en-US` → Sunday, `en-GB` → Monday, fallback path
## 13. Accept-Language script casing
- [x] 13.1 Refactor `parseAcceptLanguage` to preserve script title-case + region upper-case + language lower-case
- [x] 13.2 `negotiation.test.ts` — `zh-Hant-TW` round-trips with original casing; `EN-US;q=0.9` parses to `en-US`
## 14. Pseudo-localization
- [x] 14.1 Implement `pseudoLocalize(text, options?)` at `src/post-processors/pseudo-localize.ts` with three modes
- [x] 14.2 Implement `pseudoLocalizationPlugin(options?)` returning an `I18nPlugin` that registers as a post-processor
- [x] 14.3 `pseudo-localize.test.ts` — accent, bracket, expand modes; plugin transforms `t()` output
## 15. Segmenter and collator wrappers
- [x] 15.1 Implement `formatSegment(text, locale, granularity?)` at `src/locale/segment/segment.ts`
- [x] 15.2 Implement `compareLocale(a, b, locale, options?)` at `src/locale/collator/collator.ts` with cache
- [x] 15.3 `segment.test.ts`, `collator.test.ts` — non-Latin scripts (Japanese word segmentation, Arabic case-insensitive sort)
## 16. OTel instrumentation wiring
- [x] 16.1 Wire `i18n.translate.total{outcome,locale}` increment in `MessageCatalog.get`
- [x] 16.2 Wire `i18n.translate.duration_ms{locale}` histogram in `MessageCatalog.get`
- [x] 16.3 Wire `i18n.locale.resolve.total{strategy,locale}` increment in `LocaleResolver.resolve` and `negotiateLocale`
- [x] 16.4 Add `i18n.interpolation.duration_ms` histogram and emit from `InterpolationEngine.render` and `MessageParser.format`
- [x] 16.5 Add `i18n.missing_key.total{locale,namespace}` counter; increment from `MessageCatalog.get` on miss before handler invocation
- [x] 16.6 `metrics.spec-conformance.test.ts` — every instrument records a sample under in-memory `MeterProvider`; assert exact attributes
## 17. Missing-key reporter plugin
- [x] 17.1 Implement `createMissingKeyReporter(options?)` returning an `I18nPlugin`
- [x] 17.2 Default behaviour: log `warn` via `@suaveplan/logger` (peer; no-op when absent)
- [x] 17.3 `onMissing(payload)` callback option
- [x] 17.4 `persistMissing: true` opt-in routing to `catalog.saveMissing` (requires `i18n-storage-backends` `Backend` with `save`)
- [x] 17.5 `missing-key-reporter.test.ts` — warn-by-default, callback, persistMissing path
## 18. Wiring and exports
- [x] 18.1 Add `@suaveplan/events workspace:^` and `@suaveplan/logger workspace:^` (peer-optional for the reporter) to `package.json`
- [x] 18.2 Update `src/index.ts` to export `createI18n`, `I18nInstance`, `I18nPlugin`, `pseudoLocalize`, `formatList`, `formatDisplayName`, `formatPercent`, `formatCompact`, `formatUnit`, `formatSegment`, `compareLocale`, `createMissingKeyReporter`, `pseudoLocalizationPlugin`
- [x] 18.3 Add subpath exports for `./post-processors`, `./formatting/list`, `./formatting/display-name`, `./formatting/number/number-helpers`, `./locale/segment`, `./locale/collator`, `./plugin`
- [x] 18.4 Update root `README.md` `## Quick Start` with an `I18nInstance` + plugin example
- [x] 18.5 Update `## Modules` table with every new co-located `.md`
## 19. Pre-archive verification (the 5-gate triple)
- [x] 19.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 19.2 `bunx turbo lint typecheck test --filter=@suaveplan/i18n` — green
- [x] 19.3 `bunx turbo test:coverage --filter=@suaveplan/i18n` — 100/100/100/100
- [x] 19.4 `bun scripts/openspec-conformance.ts @suaveplan/i18n` — exit 0
- [x] 19.5 `bunx turbo build --filter=@suaveplan/i18n` — green
## 20. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 20.1 Generics threaded; `expectTypeOf` assertions on `TranslateOptions['count']` narrowing, `MessageNode` discriminant
- [x] 20.2 Zod schemas wired at every public boundary (`TranslateOptionsSchema`, `ListFormatOptionsSchema`, `DisplayNameOptionsSchema`, `PseudoLocalizeOptionsSchema`)
- [x] 20.3 New error codes (`NESTING_DEPTH_EXCEEDED`, `PLUGIN_ALREADY_INSTALLED`) thrown from at least one production path
- [x] 20.4 Strict-mode tested: cycle in nested refs, plugin duplicate, ICU validate failure
- [x] 20.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 20.6 Lifecycle dedup: `changeLanguage` to same locale is a no-op (no event); plugin install / dispose idempotent
- [x] 20.7 No retroactive tick — every box reflects work done in this change
## 21. Archive
- [x] 21.1 Fold spec delta into `openspec/specs/i18n/spec.md`
- [x] 21.2 Move `openspec/changes/i18n-completion/` → `openspec/archive/<yyyy-mm-dd>-i18n-completion/`
- [x] 21.3 Refresh `MANIFEST.md` if dep set changed (`@suaveplan/events`, optional `@suaveplan/logger`)