12 KiB
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.mdend-to-end (canonical capability spec) - 1.2 Read
openspec/AGENTS.mdSections 13 and 14 - 1.3 Confirm the in-flight
i18n-storage-backends+i18n-formatters+i18n-spec-conformanceproposals do not conflict with this delta (especially theMessageCatalogconstructor surface and OTel meter naming) - 1.4
openspec change validate i18n-completionexits with no errors
2. Ergonomic t() and TranslateOptions
- 2.1 Define
TranslateOptionsandPluralKeyinpackages/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 withcountauto-injected intovalues - 2.4 Implement
context→${key}_${context}lookup with fallback to base - 2.5 Implement
defaultValuefallback 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_EXCEEDEDerror code viadefineCodes - 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
SelectOrdinalNodetoMessageNodediscriminated union - 3.2 Implement
parsePluralChoices(...)branching forselectordinal(separate path sopluralandselectordinalcarry distincttypediscriminants) - 3.3 Render path calls
pluralSelect(count, 'ordinal')(or, when wired throughMessageCatalog.t,PluralizationEngine.getCategory(count, locale, true)) - 3.4
message.test.ts— selectordinal selects'few'for English3,'one'for1,'two'for2
4. ICU MessageFormat — typed arguments
- 4.1 Implement
parseTypedArg(message, start, name)for{name, date|time|number, style?}shapes - 4.2 Add
TypedArgumentNodetoMessageNodediscriminated union withtype: 'date' | 'time' | 'number'andstyle: string - 4.3 Render path calls
formatDate/formatTime/formatNumberfrom@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 unknowntypevalues
5. ICU MessageFormat — apostrophe quoting
- 5.1 Pre-process the source message in
parseIcuto 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[] }toMessageNode - 6.2
parseIcurecognises<name>...</name>pairs; nested tags supported - 6.3 Implement
MessageParser.format(message, values)where a valuename: (chunks: string) => stringrenders the tag inline - 6.4 Implement
MessageParser.formatChunks<T>(message, values, chunkValues)returningArray<string | T>— the React-friendly path - 6.5
extractVariablesskips tag names (they're not interpolation variables) - 6.6
message.test.ts— inline string tag, nested tags, formatChunks mixed array - 6.7 Update
message.mdwith the rich-text section and a React example referencing@suaveplan/react-i18n
7. MessageParser.validate
- 7.1 Implement
validate(message): ValidationResultreturning{ ok: true } | { ok: false; errors: ValidationError[] } - 7.2 Detect: unbalanced braces, unbalanced apostrophe quoted runs, missing
otherbranch inplural/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,PluginRegistryinsrc/core/plugin/plugin.types.ts - 8.2 Implement
createI18n(options) → I18nInstancecomposing existing engines + plugin registry - 8.3
i18n.use(plugin)— sync + async install; reject duplicate names withI18N.PLUGIN_ALREADY_INSTALLED - 8.4
i18n.dispose()— reverse-order plugin disposal - 8.5
i18n.changeLanguage(locale)— emitslanguageChanged - 8.6 Add
I18N.PLUGIN_ALREADY_INSTALLEDerror code viadefineCodes - 8.7
plugin.test.ts— install once, dispose order, duplicate rejection - 8.8 Sibling
plugin.mdwalks through the plugin contract with three example plugins
9. Event bus topics
- 9.1 Wire
@suaveplan/eventsinstance asi18n.events - 9.2 Emit
languageChangedfromchangeLanguage - 9.3 Emit
loadedfromMessageCatalog.set/mergeMessagesand from backend reads - 9.4 Emit
missingKeyfromMessageCatalog.getBEFORE invokingMissingKeyHandler - 9.5 Emit
keyAddedfromsaveMissingand HMR paths - 9.6 Emit
pluralRuleAddedfromPluralizationEngine.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?)atsrc/formatting/list/list.tswith cache + Zod options + OTel histogram - 10.2 Implement
formatDisplayName(code, locale, options)atsrc/formatting/display-name/display-name.ts - 10.3 Implement
formatPercent,formatCompact,formatUnitatsrc/formatting/number/number-helpers.ts— pre-boundformatNumbercalls - 10.4
list.test.ts,display-name.test.ts,number-helpers.test.ts— happy paths + Intl rejection mapping - 10.5 Sibling
.mdfor each - 10.6 Subpath exports
./formatting/listand./formatting/display-nameinpackage.json
11. Pluralization Intl-first refactor
- 11.1 Refactor
PluralizationEngine.getRuleto 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 (stubIntl.PluralRulesthrow)
12. Locale-aware first-day-of-week
- 12.1 Refactor
getDayNames(locale, style?)to usenew Intl.Locale(locale).weekInfo.firstDaywhen available - 12.2 Fall back to Monday-start when
weekInfois undefined or throws - 12.3
date.test.ts—en-US→ Sunday,en-GB→ Monday, fallback path
13. Accept-Language script casing
- 13.1 Refactor
parseAcceptLanguageto preserve script title-case + region upper-case + language lower-case - 13.2
negotiation.test.ts—zh-Hant-TWround-trips with original casing;EN-US;q=0.9parses toen-US
14. Pseudo-localization
- 14.1 Implement
pseudoLocalize(text, options?)atsrc/post-processors/pseudo-localize.tswith three modes - 14.2 Implement
pseudoLocalizationPlugin(options?)returning anI18nPluginthat registers as a post-processor - 14.3
pseudo-localize.test.ts— accent, bracket, expand modes; plugin transformst()output
15. Segmenter and collator wrappers
- 15.1 Implement
formatSegment(text, locale, granularity?)atsrc/locale/segment/segment.ts - 15.2 Implement
compareLocale(a, b, locale, options?)atsrc/locale/collator/collator.tswith 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 inMessageCatalog.get - 16.2 Wire
i18n.translate.duration_ms{locale}histogram inMessageCatalog.get - 16.3 Wire
i18n.locale.resolve.total{strategy,locale}increment inLocaleResolver.resolveandnegotiateLocale - 16.4 Add
i18n.interpolation.duration_mshistogram and emit fromInterpolationEngine.renderandMessageParser.format - 16.5 Add
i18n.missing_key.total{locale,namespace}counter; increment fromMessageCatalog.geton miss before handler invocation - 16.6
metrics.spec-conformance.test.ts— every instrument records a sample under in-memoryMeterProvider; assert exact attributes
17. Missing-key reporter plugin
- 17.1 Implement
createMissingKeyReporter(options?)returning anI18nPlugin - 17.2 Default behaviour: log
warnvia@suaveplan/logger(peer; no-op when absent) - 17.3
onMissing(payload)callback option - 17.4
persistMissing: trueopt-in routing tocatalog.saveMissing(requiresi18n-storage-backendsBackendwithsave) - 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) topackage.json - 18.2 Update
src/index.tsto exportcreateI18n,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 Startwith anI18nInstance+ plugin example - 18.5 Update
## Modulestable 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;
expectTypeOfassertions onTranslateOptions['count']narrowing,MessageNodediscriminant - 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:
changeLanguageto 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.mdif dep set changed (@suaveplan/events, optional@suaveplan/logger)