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

18 KiB
Raw Permalink Blame History

@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.json dependencies:
    • @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 default FormatBridge (from @suaveplan/i18n-icu-parser/parser/format-bridge) by importing the formatter packages and binding their format<Name> functions. This bridge is passed to every MessageParser.render call inside MessageCatalog and I18nInstance. Consumers that construct createI18n(...) get formatters auto-wired; advanced consumers can pass { formatBridge: customBridge } to createI18n to override.
  • MODIFIED: packages/i18n/i18n/src/core/catalog/catalog.ts and core/plugin/instance.ts — pass the default FormatBridge through every renderNodes call. No public API change; both classes already accept a RenderOptions shape; 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 local core/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 / ./errors subpaths from package.json exports — kept indefinitely for back-compat.
  • Renaming I18nCode → I18nErrorCode as 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 own i18n-locale-resolver package — possible future change; out of scope here.
  • Splitting middleware/server/ into a separate i18n-middleware package — 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:

  1. 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.
  2. 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.
  3. 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.
  4. packages/react/react-ui/ test suite — must remain green.
  5. packages/react/react-forms/ test suite — must remain green.
  6. Per-package coverage — bunx turbo test:coverage --filter=@suaveplan/i18n must 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).
  7. 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.
  8. 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/i18n shrinks from ~121 source files to ~25 (catalog + plugin + locale + middleware + observability + utils + schemas + conformance + bridge + barrel + tests).
  • @suaveplan/i18n gains 15 new workspace:^ deps (Waves 1–5 packages).
  • @suaveplan/i18n adds 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.md regenerated; CLAUDE.md listing for @suaveplan/i18n updated 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 to openspec/archive/2026-05-05-i18n-modular-split-*/.
  • The cross-cutting design doc at openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md moves 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).