Files
suaveplan/openspec/archive/2026-05-09-i18n-core-slim/proposal.md
2026-05-27 23:29:53 +01:00

187 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@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).