187 lines
18 KiB
Markdown
187 lines
18 KiB
Markdown
# `@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).
|