18 KiB
@suaveplan/i18n core slim — Wave 6 (destructive)
Package: @suaveplan/i18n (REWRITE — slim down)
Status: PROPOSED
Type: refactor (destructive — deletes ~70% of monolith source; full back-compat preserved via root barrel)
Date: 2026-05-05
Wave: 6 (LAST — depends on Waves 1–5 having shipped and committed)
Design doc: openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md (especially §8)
Path: packages/i18n/i18n/
Why
Waves 1–5 ship every leaf package the modular split needs: foundation (@suaveplan/types/i18n + @suaveplan/i18n-shared), parser (@suaveplan/i18n-icu-parser), 8 formatter packages, 1 detector package, 5 backend packages. With those landed, the @suaveplan/i18n monolith becomes the integration layer: it owns only the catalog, the plugin slot (createI18n / I18nInstance), middleware, observability state, locale negotiation/resolver, the schemas, the keys utils, the missing-key flow, and the conformance test. Every other module has a new home — keeping the duplicate copies in the monolith creates a single-source-of-truth violation and undermines the per-package coverage gate.
This change deletes the moved-out modules, adds Waves 1–5 as workspace deps, and rewrites the root barrel so every public symbol that existed pre-split continues to resolve via re-export. The 745 existing tests in packages/i18n/i18n/src/conformance.test.ts and the 17 distinct downstream import paths from @suaveplan/react-i18n / react-ui / react-forms are the contract gates: every one must remain green after the slim. This is the only wave that touches packages/i18n/i18n/src/ destructively, and the only wave that can break downstream consumers — which is why it ships last, after every leaf package is on disk and committed.
What changes
@suaveplan/i18n (rewrite)
- ADDED: workspace-dep entries in
packages/i18n/i18n/package.jsondependencies:@suaveplan/i18n-shared workspace:^@suaveplan/i18n-icu-parser workspace:^@suaveplan/i18n-formatter-number workspace:^@suaveplan/i18n-formatter-relative-time workspace:^@suaveplan/i18n-formatter-list workspace:^@suaveplan/i18n-formatter-display-name workspace:^@suaveplan/i18n-formatter-collator workspace:^@suaveplan/i18n-formatter-segmenter workspace:^@suaveplan/i18n-formatter-date workspace:^@suaveplan/i18n-formatter-currency workspace:^@suaveplan/i18n-detector workspace:^@suaveplan/i18n-backend-memory workspace:^@suaveplan/i18n-backend-http workspace:^@suaveplan/i18n-backend-storage workspace:^@suaveplan/i18n-backend-chained workspace:^- (
@suaveplan/i18n-backend-fs-multifile workspace:^is NOT added to universal-tier deps; it is server-tier and consumers import it directly. The slim core MAY add it as an optionalPeerDependency for documentation purposes.)
- REWRITTEN:
packages/i18n/i18n/src/index.ts— root barrel becomes a re-export shim. Every one of the 33 export statements in the current 211-line barrel is preserved; the source of each export changes from local paths to the new packages. See "Back-compat re-export map" below for the exhaustive table. - ADDED:
packages/i18n/i18n/src/parser/format-bridge-default.ts— wires up a defaultFormatBridge(from@suaveplan/i18n-icu-parser/parser/format-bridge) by importing the formatter packages and binding theirformat<Name>functions. This bridge is passed to everyMessageParser.rendercall insideMessageCatalogandI18nInstance. Consumers that constructcreateI18n(...)get formatters auto-wired; advanced consumers can pass{ formatBridge: customBridge }tocreateI18nto override. - MODIFIED:
packages/i18n/i18n/src/core/catalog/catalog.tsandcore/plugin/instance.ts— pass the defaultFormatBridgethrough everyrenderNodescall. No public API change; both classes already accept aRenderOptionsshape; the bridge slot is added to that shape.
Files DELETED from packages/i18n/i18n/src/
The following directories and files are deleted because every symbol they export is now sourced from a new package:
| Deleted path | New home (re-exported from) |
|---|---|
src/constants/ (entire directory) |
@suaveplan/i18n-shared/constants |
src/error-codes.ts |
@suaveplan/i18n-shared/errors |
src/errors/ (entire directory) |
@suaveplan/i18n-shared/errors |
src/parser/ (entire directory) |
@suaveplan/i18n-icu-parser/parser |
src/core/interpolation/ (entire directory) |
@suaveplan/i18n-icu-parser/interpolation |
src/core/pluralization/ (entire directory) |
@suaveplan/i18n-icu-parser/pluralization |
src/formatting/number/ |
@suaveplan/i18n-formatter-number |
src/formatting/relative-time/ |
@suaveplan/i18n-formatter-relative-time |
src/formatting/list/ |
@suaveplan/i18n-formatter-list |
src/formatting/display-name/ |
@suaveplan/i18n-formatter-display-name |
src/formatting/date/ |
@suaveplan/i18n-formatter-date |
src/formatting/currency/ |
@suaveplan/i18n-formatter-currency |
src/formatting/index.ts |
(deleted; root barrel re-exports per-formatter) |
src/detector/ (entire directory) |
@suaveplan/i18n-detector |
src/core/backend/ (entire directory) |
@suaveplan/i18n-backend-* |
Files that stay in packages/i18n/i18n/src/:
| Path | Role |
|---|---|
src/core/catalog/ |
MessageCatalog, deepMerge — stays as the integration layer |
src/core/plugin/ |
createI18n, I18nInstance — stays as the public top-level API |
src/locale/negotiation/ |
negotiateLocale, parseAcceptLanguage — RFC 4647 lookup, foundational locale primitives |
src/locale/resolver/ |
LocaleResolver, extractLanguage, extractRegion, normalizeLocaleCode — strategy-based resolver |
src/middleware/ |
createLocaleMiddleware, detectLocale, getDirection server middleware |
src/observability/ |
detector-metrics state, OTel adapters |
src/schemas/ |
CatalogOptionsSchema, InterpolationConfigSchema, ResolverConfigSchema, TranslationRecordSchema — boundary validation |
src/utils/ |
buildKey, countKeys, flattenRecord, getNestedValue, getTextDirection, hasKey, isRtl, parseKey, sanitize, unflattenRecord — key/translation utilities |
src/conformance.test.ts |
end-to-end spec conformance — REGRESSION GATE |
src/index.ts |
rewritten root barrel (back-compat) |
src/index.test.ts |
exhaustive symbol-existence test |
Subpath exports preserved
Every existing package.json exports subpath continues to resolve. Internally the subpath forwards to the relevant new package. Documented subpaths:
@suaveplan/i18n— root barrel@suaveplan/i18n/detector— forwards to@suaveplan/i18n-detector@suaveplan/i18n/plugin— forwards to localcore/plugin/instance.ts@suaveplan/i18n/parser— forwards to@suaveplan/i18n-icu-parser/parser@suaveplan/i18n/errors— forwards to@suaveplan/i18n-shared/errors
Back-compat re-export map (exhaustive)
Every export in the current src/index.ts (211 lines, 33 export statements). Each row says: pre-split source → new source.
| Symbol | Pre-split source | Post-split source |
|---|---|---|
DefaultLocale (type) |
./constants/index.js |
@suaveplan/i18n-shared/constants/locale-descriptors |
DEFAULT_INTERPOLATION |
./constants/index.js |
@suaveplan/i18n-shared/constants/interpolation-defaults |
DEFAULT_LOCALES |
./constants/index.js |
@suaveplan/i18n-shared/constants/locale-descriptors |
LOCALE_BCP47_MAP |
./constants/index.js |
@suaveplan/i18n-shared/constants/locale-bcp47-map |
LOCALE_DESCRIPTORS |
./constants/index.js |
@suaveplan/i18n-shared/constants/locale-descriptors |
RTL_LOCALE_LIST |
./constants/index.js |
@suaveplan/i18n-shared/constants/rtl-locales |
RTL_LOCALES |
./constants/index.js |
@suaveplan/i18n-shared/constants/rtl-locales |
deepMerge, MessageCatalog |
./core/catalog/catalog.js |
(stays — local) |
CatalogOptions, ExtendedCatalogOptions (types) |
./core/catalog/catalog.types.js |
(stays — local) |
LookupResult, MessageLoader, MissingKeyHandler, NamespacedCatalogMap, TranslationCatalogMap, TranslationRecord (types) |
./core/catalog/catalog.types.js |
@suaveplan/types (Wave 1a) |
escapeHtmlChars, escapeRegex, InterpolationEngine |
./core/interpolation/interpolation.js |
@suaveplan/i18n-icu-parser/interpolation |
InterpolationConfig, InterpolationOptions, InterpolationResult, InterpolationValues, ValueFormatter (types) |
./core/interpolation/interpolation.types.js |
@suaveplan/i18n-icu-parser/interpolation |
I18nInstanceOptions (type) |
./core/plugin/instance.js |
(stays — local) |
createI18n, I18nInstance |
./core/plugin/instance.js |
(stays — local; modified to wire default FormatBridge) |
arabicPlural, chinesePlural, defaultPluralRule, englishPlural, frenchPlural, germanPlural, getBaseLocale, normalizeLocale, PluralizationEngine, polishPlural, russianPlural, spanishPlural, welshPlural |
./core/pluralization/pluralization.js |
@suaveplan/i18n-icu-parser/pluralization |
PluralCategory, PluralMessages, PluralResolveOptions, PluralResolveResult, PluralRuleFunction, PluralRuleRegistry (types) |
./core/pluralization/pluralization.types.js |
@suaveplan/i18n-icu-parser/pluralization |
DetectorChain, DetectorChainResult, DetectorChainUnresolved, DetectorContext, DetectorEnvironment, DetectorStorageLike, LocaleDetector (types) |
./detector/index.js |
@suaveplan/i18n-detector |
createDetectorChain, DETECTOR_CHAIN_UNRESOLVED |
./detector/index.js |
@suaveplan/i18n-detector |
I18nCode (type) |
./error-codes.js |
@suaveplan/i18n-shared/errors (renamed: I18nErrorCode is the new name; I18nCode is re-exported as a type alias for back-compat) |
I18N_CODES |
./error-codes.js |
@suaveplan/i18n-shared/errors |
I18nError |
./errors/i18n-error.js |
@suaveplan/i18n-shared/errors |
formatCurrency, formatCurrencyValue, getCurrencyInfo, getCurrencyName, getCurrencySymbol |
./formatting/currency/currency.js |
@suaveplan/i18n-formatter-currency |
CommonCurrency, CurrencyFormatOptions, CurrencyInfo (types) |
./formatting/currency/currency.types.js |
@suaveplan/i18n-formatter-currency |
formatDate, formatDateOnly, formatDateRange, formatTime, getDayNames, getMonthNames |
./formatting/date/date.js |
@suaveplan/i18n-formatter-date |
DateFormatOptions, DateFormatPreset, ExtendedDateOptions (types) |
./formatting/date/date.types.js |
@suaveplan/i18n-formatter-date |
negotiateLocale, parseAcceptLanguage |
./locale/negotiation/negotiation.js |
(stays — local) |
AcceptLanguageEntry, NegotiationOptions, NegotiationResult (types) |
./locale/negotiation/negotiation.types.js |
(stays — local) |
extractLanguage, extractRegion, LocaleResolver, normalizeLocaleCode |
./locale/resolver/resolver.js |
(stays — local) |
ExtendedResolverConfig, ResolutionStrategy, ResolvedLocale, ResolverConfig (types) |
./locale/resolver/resolver.types.js |
(stays — local) |
createLocaleMiddleware, detectLocale, getDirection |
./middleware/server/server.js |
(stays — local) |
ExtendedMiddlewareConfig, I18nRequest, LocaleLookupSource, MiddlewareConfig, MiddlewareResult, TextDirection (types) |
./middleware/server/server.types.js |
(stays — local) |
DetectorMetricSample, DetectorMetricsState (types) |
./observability/detector-metrics.js |
(stays — local) |
getDetectorMetrics, getDetectorMetricsState, recordDetectorDuration, recordDetectorMatch, resetDetectorMetricsCache, resetDetectorMetricsState |
./observability/detector-metrics.js |
(stays — local) |
extractVariables, MessageParser, renderNodes |
./parser/message/message.js |
@suaveplan/i18n-icu-parser/parser |
MessageNode, ParsedMessage, ParserOptions, PluralNode, SelectNode, TextNode, VariableNode (types) |
./parser/message/message.types.js |
@suaveplan/i18n-icu-parser/parser |
CatalogOptionsInput, InterpolationConfigInput, ResolverConfigInput (types) |
./schemas/i18n.schemas.js |
(stays — local) |
CatalogOptionsSchema, InterpolationConfigSchema, ResolverConfigSchema, TranslationRecordSchema |
./schemas/i18n.schemas.js |
(stays — local) |
buildKey, countKeys, flattenRecord, getNestedValue, getTextDirection, hasKey, isRtl, parseKey, sanitize, unflattenRecord |
./utils/utils.js |
(stays — local) |
KeyPathResult, SanitizeOptions (types) |
./utils/utils.types.js |
(stays — local) |
Backend factories (MemoryBackend, memoryBackend, HttpBackend, httpBackend, StorageBackend, storageBackend, ChainedBackend, chainedBackend) are added to the root barrel even though they are not in the current 211-line index.ts — they were exported via the ./core/backend/* subpaths, and the slim core re-exports them at the root for parity with formatters and detector. MultiFileFsBackend / multiFileFsBackend are NOT added to the universal-tier root because the package is server-tier; consumers must import directly from @suaveplan/i18n-backend-fs-multifile.
Out of scope
- Migrating downstream consumers (
@suaveplan/react-i18n,react-ui,react-forms) to direct per-package imports for tree-shaking — follow-up change. They keep working through the slim root barrel. - Removing the
./detector/./plugin/./parser/./errorssubpaths frompackage.jsonexports— kept indefinitely for back-compat. - Renaming
I18nCode→I18nErrorCodeas a breaking change — the new name is exported from@suaveplan/i18n-shared; the old name is kept as a type alias. - Splitting
locale/into its owni18n-locale-resolverpackage — possible future change; out of scope here. - Splitting
middleware/server/into a separatei18n-middlewarepackage — possible future change; out of scope here.
Source mapping
This change is mostly deletion and re-export. The "source" for the back-compat barrel is the existing packages/i18n/i18n/src/index.ts (lines 1–211). For the bridge wiring:
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| Default FormatBridge wiring | packages/i18n/i18n/src/parser/message/message-render.ts |
full file | Today's direct formatter calls become formatBridge.<name>(...) calls in Wave 2's i18n-icu-parser. The default bridge constructed in Wave 6 calls back into the current monolith's formatter functions (post-move: now in formatter packages). |
| Catalog FormatBridge plumbing | packages/i18n/i18n/src/core/catalog/catalog.ts |
every renderNodes(...) call site |
Add formatBridge to RenderOptions |
| Plugin FormatBridge plumbing | packages/i18n/i18n/src/core/plugin/instance.ts |
every renderNodes(...) call site |
Same |
Requirements with no source line
| Requirement | Justification |
|---|---|
| Slim-core root barrel | New shape; the export symbols match the existing barrel byte-for-byte but the sources change |
Default FormatBridge factory in parser/format-bridge-default.ts |
New file — needed because formatter packages now live outside the parser package |
| Backend re-exports in root barrel | New symbols at root; previously only accessible via ./core/backend/* subpaths. Adding them at root improves discoverability and matches formatter parity |
Verification approach
This is the highest-risk change in the entire split. Verification is layered:
packages/i18n/i18n/src/conformance.test.ts— every existing test must remain green. This file tests the public surface end-to-end against real values; it is the strongest regression gate.packages/i18n/i18n/src/index.test.ts— exhaustive symbol-existence test. Every one of the 33 exports + every backend re-export must resolve. Test enumerates the symbol list; failure means the back-compat barrel missed a symbol.packages/react/react-i18n/test suite — unchanged code, must remain green. 17 distinct import paths from@suaveplan/i18n(root + 4 subpaths) cover the most heavily-used downstream surface.packages/react/react-ui/test suite — must remain green.packages/react/react-forms/test suite — must remain green.- Per-package coverage —
bunx turbo test:coverage --filter=@suaveplan/i18nmust hit 100/100/100/100 on the slim core (the moved-out modules are no longer counted; the slim core's coverage focuses on catalog, plugin, locale, middleware, observability, schemas, utils, and the new bridge wiring). - Subpath resolution — a test that imports from each documented subpath (
@suaveplan/i18n/detector,@suaveplan/i18n/plugin,@suaveplan/i18n/parser,@suaveplan/i18n/errors) and asserts the expected symbols resolve. - Bundle-size sanity — record the slim core's bundle size before and after. Expect a meaningful reduction (formatters / parser / backends were a large fraction of the monolith); document the new size in the README.
Impact
@suaveplan/i18nshrinks from ~121 source files to ~25 (catalog + plugin + locale + middleware + observability + utils + schemas + conformance + bridge + barrel + tests).@suaveplan/i18ngains 15 newworkspace:^deps (Waves 1–5 packages).@suaveplan/i18nadds backend factories to its root barrel (4 universal-tier backends).- Every existing
import { X } from "@suaveplan/i18n"continues to work — zero downstream breakage expected. MANIFEST.mdregenerated;CLAUDE.mdlisting for@suaveplan/i18nupdated to reflect slim scope.- All 14 OpenSpec change folders for the modular split (
i18n-types-extension,i18n-shared,i18n-icu-parser, 8 formatters,i18n-detector, 5 backends, this folder) archive together toopenspec/archive/2026-05-05-i18n-modular-split-*/. - The cross-cutting design doc at
openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.mdmoves to the archive folder for the design (kept as a single design doc for the entire split, archived once on Wave 6 completion). - This wave is the only crash-unsafe step in the entire split. If a session dies between deleting source and rewriting the barrel, the working tree is in a broken state. Mitigation: each deletion in §"Files DELETED" is its own commit (sub-commit within Wave 6); the barrel rewrite happens first, so even at the worst crash point the package still builds (it just has duplicate definitions until the deletes finish).