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

12 KiB

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

  • 1.1 Read openspec/specs/i18n/spec.md end-to-end (canonical capability spec)
  • 1.2 Read openspec/AGENTS.md Sections 13 and 14
  • 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)
  • 1.4 openspec change validate i18n-completion exits with no errors

2. Ergonomic t() and TranslateOptions

  • 2.1 Define TranslateOptions and PluralKey in packages/i18n/i18n/src/core/catalog/translate.types.ts
  • 2.2 Implement MessageCatalog.t(key, options?) accepting the options bag (≤300 lines after refactor)
  • 2.3 Implement count → PluralizationEngine.resolve → branch selection with count auto-injected into values
  • 2.4 Implement context → ${key}_${context} lookup with fallback to base
  • 2.5 Implement defaultValue fallback on miss
  • 2.6 Implement nested-key references $t(other.key) / $t(ns:key) with depth limit (default 4) and cycle detection
  • 2.7 Add I18N.NESTING_DEPTH_EXCEEDED error code via defineCodes
  • 2.8 t.plural(key, count, values?) shortcut helper
  • 2.9 translate.test.ts — count, context, defaultValue, nested refs (success + cycle), shortcut helper
  • 2.10 Sibling translate.md

3. ICU MessageFormat — selectordinal

  • 3.1 Add SelectOrdinalNode to MessageNode discriminated union
  • 3.2 Implement parsePluralChoices(...) branching for selectordinal (separate path so plural and selectordinal carry distinct type discriminants)
  • 3.3 Render path calls pluralSelect(count, 'ordinal') (or, when wired through MessageCatalog.t, PluralizationEngine.getCategory(count, locale, true))
  • 3.4 message.test.ts — selectordinal selects 'few' for English 3, 'one' for 1, 'two' for 2

4. ICU MessageFormat — typed arguments

  • 4.1 Implement parseTypedArg(message, start, name) for {name, date|time|number, style?} shapes
  • 4.2 Add TypedArgumentNode to MessageNode discriminated union with type: 'date' | 'time' | 'number' and style: string
  • 4.3 Render path calls formatDate / formatTime / formatNumber from @suaveplan/i18n/formatting/*
  • 4.4 Number style parsing: decimal, percent, currency/<ISO>, compact, scientific, engineering
  • 4.5 Date/time style parsing: short, medium, long, full
  • 4.6 message.test.ts — typed currency, typed date, typed percent, typed compact
  • 4.7 MessageParser.validate(message) rejects unknown type values

5. ICU MessageFormat — apostrophe quoting

  • 5.1 Pre-process the source message in parseIcu to handle the three apostrophe rules
  • 5.2 '' → single literal '
  • 5.3 '<followed by{,}, or another'>...' → quoted run, content is literal until matching '
  • 5.4 Standalone ' (any other context) → literal apostrophe
  • 5.5 message.test.ts — it''s here, '{literal}' is X, don't

6. ICU MessageFormat — tag interpolation

  • 6.1 Add TagNode { type: 'tag'; name: string; children: MessageNode[] } to MessageNode
  • 6.2 parseIcu recognises <name>...</name> pairs; nested tags supported
  • 6.3 Implement MessageParser.format(message, values) where a value name: (chunks: string) => string renders the tag inline
  • 6.4 Implement MessageParser.formatChunks<T>(message, values, chunkValues) returning Array<string | T> — the React-friendly path
  • 6.5 extractVariables skips tag names (they're not interpolation variables)
  • 6.6 message.test.ts — inline string tag, nested tags, formatChunks mixed array
  • 6.7 Update message.md with the rich-text section and a React example referencing @suaveplan/react-i18n

7. MessageParser.validate

  • 7.1 Implement validate(message): ValidationResult returning { ok: true } | { ok: false; errors: ValidationError[] }
  • 7.2 Detect: unbalanced braces, unbalanced apostrophe quoted runs, missing other branch in plural / select / selectordinal, unknown typed-argument type, unknown plural categories
  • 7.3 message-validate.test.ts — happy path + each error case

8. I18nInstance facade and plugin lifecycle

  • 8.1 Define I18nPlugin, I18nInstance, PluginRegistry in src/core/plugin/plugin.types.ts
  • 8.2 Implement createI18n(options) → I18nInstance composing existing engines + plugin registry
  • 8.3 i18n.use(plugin) — sync + async install; reject duplicate names with I18N.PLUGIN_ALREADY_INSTALLED
  • 8.4 i18n.dispose() — reverse-order plugin disposal
  • 8.5 i18n.changeLanguage(locale) — emits languageChanged
  • 8.6 Add I18N.PLUGIN_ALREADY_INSTALLED error code via defineCodes
  • 8.7 plugin.test.ts — install once, dispose order, duplicate rejection
  • 8.8 Sibling plugin.md walks through the plugin contract with three example plugins

9. Event bus topics

  • 9.1 Wire @suaveplan/events instance as i18n.events
  • 9.2 Emit languageChanged from changeLanguage
  • 9.3 Emit loaded from MessageCatalog.set / mergeMessages and from backend reads
  • 9.4 Emit missingKey from MessageCatalog.get BEFORE invoking MissingKeyHandler
  • 9.5 Emit keyAdded from saveMissing and HMR paths
  • 9.6 Emit pluralRuleAdded from PluralizationEngine.registerRule
  • 9.7 events.test.ts — every topic emits with the documented payload shape

10. List + display-name + number-helper formatters

  • 10.1 Implement formatList(values, locale, options?) at src/formatting/list/list.ts with cache + Zod options + OTel histogram
  • 10.2 Implement formatDisplayName(code, locale, options) at src/formatting/display-name/display-name.ts
  • 10.3 Implement formatPercent, formatCompact, formatUnit at src/formatting/number/number-helpers.ts — pre-bound formatNumber calls
  • 10.4 list.test.ts, display-name.test.ts, number-helpers.test.ts — happy paths + Intl rejection mapping
  • 10.5 Sibling .md for each
  • 10.6 Subpath exports ./formatting/list and ./formatting/display-name in package.json

11. Pluralization Intl-first refactor

  • 11.1 Refactor PluralizationEngine.getRule to prefer override → Intl.PluralRules → hand-written → default
  • 11.2 Hand-written rules remain registered (they're now overrides for runtimes lacking Intl.PluralRules)
  • 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

  • 12.1 Refactor getDayNames(locale, style?) to use new Intl.Locale(locale).weekInfo.firstDay when available
  • 12.2 Fall back to Monday-start when weekInfo is undefined or throws
  • 12.3 date.test.ts — en-US → Sunday, en-GB → Monday, fallback path

13. Accept-Language script casing

  • 13.1 Refactor parseAcceptLanguage to preserve script title-case + region upper-case + language lower-case
  • 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

  • 14.1 Implement pseudoLocalize(text, options?) at src/post-processors/pseudo-localize.ts with three modes
  • 14.2 Implement pseudoLocalizationPlugin(options?) returning an I18nPlugin that registers as a post-processor
  • 14.3 pseudo-localize.test.ts — accent, bracket, expand modes; plugin transforms t() output

15. Segmenter and collator wrappers

  • 15.1 Implement formatSegment(text, locale, granularity?) at src/locale/segment/segment.ts
  • 15.2 Implement compareLocale(a, b, locale, options?) at src/locale/collator/collator.ts with cache
  • 15.3 segment.test.ts, collator.test.ts — non-Latin scripts (Japanese word segmentation, Arabic case-insensitive sort)

16. OTel instrumentation wiring

  • 16.1 Wire i18n.translate.total{outcome,locale} increment in MessageCatalog.get
  • 16.2 Wire i18n.translate.duration_ms{locale} histogram in MessageCatalog.get
  • 16.3 Wire i18n.locale.resolve.total{strategy,locale} increment in LocaleResolver.resolve and negotiateLocale
  • 16.4 Add i18n.interpolation.duration_ms histogram and emit from InterpolationEngine.render and MessageParser.format
  • 16.5 Add i18n.missing_key.total{locale,namespace} counter; increment from MessageCatalog.get on miss before handler invocation
  • 16.6 metrics.spec-conformance.test.ts — every instrument records a sample under in-memory MeterProvider; assert exact attributes

17. Missing-key reporter plugin

  • 17.1 Implement createMissingKeyReporter(options?) returning an I18nPlugin
  • 17.2 Default behaviour: log warn via @suaveplan/logger (peer; no-op when absent)
  • 17.3 onMissing(payload) callback option
  • 17.4 persistMissing: true opt-in routing to catalog.saveMissing (requires i18n-storage-backends Backend with save)
  • 17.5 missing-key-reporter.test.ts — warn-by-default, callback, persistMissing path

18. Wiring and exports

  • 18.1 Add @suaveplan/events workspace:^ and @suaveplan/logger workspace:^ (peer-optional for the reporter) to package.json
  • 18.2 Update src/index.ts to export createI18n, I18nInstance, I18nPlugin, pseudoLocalize, formatList, formatDisplayName, formatPercent, formatCompact, formatUnit, formatSegment, compareLocale, createMissingKeyReporter, pseudoLocalizationPlugin
  • 18.3 Add subpath exports for ./post-processors, ./formatting/list, ./formatting/display-name, ./formatting/number/number-helpers, ./locale/segment, ./locale/collator, ./plugin
  • 18.4 Update root README.md ## Quick Start with an I18nInstance + plugin example
  • 18.5 Update ## Modules table with every new co-located .md

19. Pre-archive verification (the 5-gate triple)

  • 19.1 bun run check — Biome 0/0/0 across the monorepo
  • 19.2 bunx turbo lint typecheck test --filter=@suaveplan/i18n — green
  • 19.3 bunx turbo test:coverage --filter=@suaveplan/i18n — 100/100/100/100
  • 19.4 bun scripts/openspec-conformance.ts @suaveplan/i18n — exit 0
  • 19.5 bunx turbo build --filter=@suaveplan/i18n — green

20. Pre-archive 7-row checklist (AGENTS.md Section 13)

  • 20.1 Generics threaded; expectTypeOf assertions on TranslateOptions['count'] narrowing, MessageNode discriminant
  • 20.2 Zod schemas wired at every public boundary (TranslateOptionsSchema, ListFormatOptionsSchema, DisplayNameOptionsSchema, PseudoLocalizeOptionsSchema)
  • 20.3 New error codes (NESTING_DEPTH_EXCEEDED, PLUGIN_ALREADY_INSTALLED) thrown from at least one production path
  • 20.4 Strict-mode tested: cycle in nested refs, plugin duplicate, ICU validate failure
  • 20.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory MeterProvider
  • 20.6 Lifecycle dedup: changeLanguage to same locale is a no-op (no event); plugin install / dispose idempotent
  • 20.7 No retroactive tick — every box reflects work done in this change

21. Archive

  • 21.1 Fold spec delta into openspec/specs/i18n/spec.md
  • 21.2 Move openspec/changes/i18n-completion/ → openspec/archive/<yyyy-mm-dd>-i18n-completion/
  • 21.3 Refresh MANIFEST.md if dep set changed (@suaveplan/events, optional @suaveplan/logger)