589 lines
32 KiB
Markdown
589 lines
32 KiB
Markdown
# i18n Modular Split — `@suaveplan/i18n` → 14 Sibling Packages
|
||
|
||
- **Date:** 2026-05-05
|
||
- **Status:** DESIGN APPROVED — pending spec-review loop, then transitions to OpenSpec proposals + implementation plan
|
||
- **Scope:** Split the current `@suaveplan/i18n` monolith into 14 sibling packages under `packages/i18n/` matching the granularity of the rest of the genesis monorepo (cf. `crypto-*`, `drive-*`, `network-plugin-*`)
|
||
- **Branch:** `genesis`
|
||
- **Supersedes / amends (existing OpenSpec changes):** Folds the four already-shipped i18n changes (`i18n-storage-backends`, `i18n-formatters`, `i18n-spec-conformance`, `i18n-completion` — currently misplaced under `openspec/changes/archive/2026-05-04-*/`) into a coherent multi-package layout. Existing capability spec at `openspec/specs/i18n/spec.md` survives but is split across new capability files.
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
`@suaveplan/i18n` today is a single ~121-source-file package that bundles eight unrelated concerns (catalog, parser, interpolation, pluralization, formatters, backends, detector, observability) behind one entry point. Every other domain in the monorepo with comparable surface area splits into many fine-grained sibling packages — `crypto-hash`, `crypto-curve`, `crypto-cipher`, `crypto-utils`, `crypto-vault`, `crypto-cli`, `crypto-bin`, `crypto-sodium`, `crypto-browser`, `crypto-core`, `crypto`; `drive`, `drive-template`, `drive-mesh-protocol`, `drive-plugin-chunks`, `drive-sharing`, `drive-transport-https`; `network` plus a dozen `network-plugin-*` siblings. The four OpenSpec changes that built the monolith were even **named** like sub-packages (`i18n-storage-backends`, `i18n-formatters`) — strongly implying split intent — but the implementation collapsed them into one package.
|
||
|
||
This change brings i18n in line with the monorepo pattern: 13 new sibling packages plus a slimmed-down core, in dependency order so each layer commits before the next, and the core rewrite is the very last step.
|
||
|
||
### 1.1. Decision summary
|
||
|
||
| Topic | Decision | Section |
|
||
|---|---|---|
|
||
| Number of packages | 14 (1 shared runtime + 1 ICU parser + 8 formatters + 1 detector + 5 backends + 1 slim core; plus types-only additions to `@suaveplan/types`) | §3 |
|
||
| Granularity for backends | Per-backend (matches monorepo "split when external deps differ" rule — `storage` pulls `@suaveplan/storage`, `fs-multifile` is server-only) | §6 |
|
||
| Granularity for formatters | Per-formatter (matches user-stated lean toward fine-grained; trivial overhead since each is a thin `Intl.*` wrapper) | §5 |
|
||
| Where shared atoms live | New `@suaveplan/i18n-shared` (runtime constants + `I18nError` + `I18N_CODES`) plus type-only additions to `@suaveplan/types` | §4 |
|
||
| Backend interface location | `@suaveplan/types/i18n` (zero runtime — same pattern as the cross-package crypto contract) so backend packages do not depend on `@suaveplan/i18n` core | §4.1, §6 |
|
||
| Core slim scope | Catalog + plugin slot (`createI18n` / `I18nInstance`) + middleware + observability + key utils + missing-key flow + schemas + conformance harness | §7 |
|
||
| Back-compat | Slim `@suaveplan/i18n` root barrel re-exports the entire pre-split surface from the new packages — every existing `import { X } from "@suaveplan/i18n"` keeps working | §8 |
|
||
| Wave order | 1 foundation → 2 parser → 3 formatters → 4 detector → 5 backends → 6 core (LAST) | §9 |
|
||
| Crash-safety | Waves 1–5 are pure additions (zero edits to `packages/i18n/i18n/src/`). Each wave commits before the next. Only Wave 6 is destructive. | §9 |
|
||
|
||
### 1.2. Final package layout
|
||
|
||
```text
|
||
packages/i18n/
|
||
i18n/ ← Wave 6: slim core (catalog + plugin + middleware + observability)
|
||
i18n-shared/ ← Wave 1: constants + I18N_CODES + I18nError class
|
||
i18n-icu-parser/ ← Wave 2: parser + interpolation + pluralization
|
||
i18n-formatter-number/ ← Wave 3: Intl.NumberFormat wrapper
|
||
i18n-formatter-relative-time/ ← Wave 3: Intl.RelativeTimeFormat wrapper
|
||
i18n-formatter-list/ ← Wave 3: Intl.ListFormat wrapper
|
||
i18n-formatter-display-name/ ← Wave 3: Intl.DisplayNames wrapper
|
||
i18n-formatter-collator/ ← Wave 3: Intl.Collator wrapper (NEW — not in monolith)
|
||
i18n-formatter-segmenter/ ← Wave 3: Intl.Segmenter wrapper (NEW — not in monolith)
|
||
i18n-formatter-date/ ← Wave 3: Intl.DateTimeFormat wrapper
|
||
i18n-formatter-currency/ ← Wave 3: Intl.NumberFormat (currency) wrapper + ISO 4217 metadata
|
||
i18n-detector/ ← Wave 4: locale detection chain
|
||
i18n-backend-memory/ ← Wave 5: in-memory backend
|
||
i18n-backend-http/ ← Wave 5: fetch-based backend
|
||
i18n-backend-storage/ ← Wave 5: AbstractStorage backend (depends on @suaveplan/storage)
|
||
i18n-backend-fs-multifile/ ← Wave 5: server-only Node fs backend
|
||
i18n-backend-chained/ ← Wave 5: chained-fallback backend orchestrator
|
||
locale-data/ ← UNCHANGED: ISO 3166 / ISO 639 static data
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Why now
|
||
|
||
1. **Architectural drift.** The monolith violates the monorepo's own splitting pattern. Every package audit and parity check has to special-case i18n.
|
||
2. **Tier leakage.** `i18n-backend-fs-multifile` uses `node:fs`, but the monolith is a universal-tier package — consumers in browser-tier code can accidentally tree-shake-ineligible imports. Split eliminates this.
|
||
3. **Optional-deps awkwardness.** Today `@suaveplan/storage` is a hard dep of `@suaveplan/i18n` because of one backend. Consumers who never touch the storage backend still pay the install cost.
|
||
4. **The original change names already implied this layout.** `i18n-storage-backends`, `i18n-formatters`, `i18n-completion`, `i18n-spec-conformance` — every prior change was named per-feature. The implementation simply forgot to split.
|
||
5. **Crash-safety for in-flight work.** With the monolith, any session that crashes mid-edit can leave the only i18n package in a half-state. With the split, leaf packages commit independently — a crashed Wave 6 leaves Waves 1–5 fully-shipped on disk.
|
||
6. **Downstream React stack.** `@suaveplan/react-i18n` is the binding layer — splitting i18n now (before more downstream React packages land) means React consumers can pull only the formatters / backends they need.
|
||
|
||
---
|
||
|
||
## 3. The 14 packages — role and dependency map
|
||
|
||
| # | Package | Tier | Role | Direct deps (within i18n family) |
|
||
|---|---|---|---|---|
|
||
| 1 | `@suaveplan/i18n-shared` | universal | Constants, error codes, error class | none |
|
||
| 2 | `@suaveplan/i18n-icu-parser` | universal | ICU parser, interpolation engine, pluralization rules, ICU render with `FormatBridge` DI seam | `@suaveplan/i18n-shared` |
|
||
| 3 | `@suaveplan/i18n-formatter-number` | universal | `Intl.NumberFormat` wrapper | `@suaveplan/i18n-shared` |
|
||
| 4 | `@suaveplan/i18n-formatter-relative-time` | universal | `Intl.RelativeTimeFormat` wrapper | `@suaveplan/i18n-shared` |
|
||
| 5 | `@suaveplan/i18n-formatter-list` | universal | `Intl.ListFormat` wrapper | `@suaveplan/i18n-shared` |
|
||
| 6 | `@suaveplan/i18n-formatter-display-name` | universal | `Intl.DisplayNames` wrapper | `@suaveplan/i18n-shared` |
|
||
| 7 | `@suaveplan/i18n-formatter-collator` | universal | `Intl.Collator` wrapper | `@suaveplan/i18n-shared` |
|
||
| 8 | `@suaveplan/i18n-formatter-segmenter` | universal | `Intl.Segmenter` wrapper | `@suaveplan/i18n-shared` |
|
||
| 9 | `@suaveplan/i18n-formatter-date` | universal | `Intl.DateTimeFormat` wrapper | `@suaveplan/i18n-shared` |
|
||
| 10 | `@suaveplan/i18n-formatter-currency` | universal | Currency wrapper + ISO 4217 metadata | `@suaveplan/i18n-shared`, `@suaveplan/i18n-formatter-number` |
|
||
| 11 | `@suaveplan/i18n-detector` | universal | Locale detection chain | `@suaveplan/i18n-shared`, `@suaveplan/locale-data` |
|
||
| 12 | `@suaveplan/i18n-backend-memory` | universal | In-memory `Backend` impl | `@suaveplan/i18n-shared` |
|
||
| 13 | `@suaveplan/i18n-backend-http` | universal | Fetch-based `Backend` impl | `@suaveplan/i18n-shared` |
|
||
| 14 | `@suaveplan/i18n-backend-storage` | universal | `AbstractStorage`-backed `Backend` impl | `@suaveplan/i18n-shared`, `@suaveplan/storage` |
|
||
| 15 | `@suaveplan/i18n-backend-fs-multifile` | server | Node `fs` multi-file `Backend` impl | `@suaveplan/i18n-shared` |
|
||
| 16 | `@suaveplan/i18n-backend-chained` | universal | Chained-fallback `Backend` orchestrator | `@suaveplan/i18n-shared` |
|
||
| 17 | `@suaveplan/i18n` | universal | Slim core: catalog + plugin slot + middleware + observability + key utils + missing-key flow | All of 1–16 (re-exported by root barrel) |
|
||
|
||
Plus, types-only additions to `@suaveplan/types` (Wave 1a — not a new package):
|
||
|
||
```text
|
||
packages/primitives/types/src/i18n/
|
||
i18n.types.ts ← Backend, BackendCapabilities, TranslationRecord,
|
||
LocaleNamespace, MissingKeyHandler, MessageLoader,
|
||
NamespacedCatalogMap, TranslationCatalogMap, LookupResult
|
||
index.ts
|
||
```
|
||
|
||
### 3.1. Dependency graph (ASCII)
|
||
|
||
```text
|
||
@suaveplan/types ──────────┬─── (every package — types-only)
|
||
│
|
||
@suaveplan/i18n-shared ────┼─── i18n-icu-parser
|
||
├─── i18n-formatter-{number,relative-time,list,display-name,
|
||
│ collator,segmenter,date}
|
||
├─── i18n-formatter-currency ── i18n-formatter-number
|
||
├─── i18n-detector ── @suaveplan/locale-data
|
||
├─── i18n-backend-{memory,http,fs-multifile,chained}
|
||
├─── i18n-backend-storage ── @suaveplan/storage
|
||
│
|
||
▼
|
||
@suaveplan/i18n (slim core, re-exports everything)
|
||
```
|
||
|
||
No cycles. Every leaf depends only on `@suaveplan/i18n-shared` plus (optionally) one external for tier-specific work. The slim core depends on all 16 leaves.
|
||
|
||
---
|
||
|
||
## 4. Wave 1a — `@suaveplan/types/i18n` (types-only)
|
||
|
||
The `Backend` interface and its peers move into `@suaveplan/types` so backend packages can implement it without depending on `@suaveplan/i18n`. This mirrors the cross-package crypto contract already in `@suaveplan/types` (`Authority`, `KeySlotPublic/Private`, `SlotId`, `DomainTag`, `AuthorityVerifier`, `AeadOps`).
|
||
|
||
Add `packages/primitives/types/src/i18n/i18n.types.ts`:
|
||
|
||
```typescript
|
||
export interface TranslationRecord {
|
||
readonly [key: string]: string | TranslationRecord;
|
||
}
|
||
|
||
export interface LocaleNamespace {
|
||
readonly locale: string;
|
||
readonly namespace: string;
|
||
}
|
||
|
||
export interface BackendCapabilities {
|
||
readonly supportsBulkRead: boolean;
|
||
readonly supportsWrite: boolean;
|
||
readonly supportsLastModified: boolean;
|
||
}
|
||
|
||
export interface Backend {
|
||
readonly name: string;
|
||
readonly capabilities: BackendCapabilities;
|
||
read(locale: string, namespace: string): Promise<TranslationRecord>;
|
||
readMulti?(
|
||
locale: string,
|
||
namespaces: readonly string[],
|
||
): Promise<Record<string, TranslationRecord>>;
|
||
save?(
|
||
locale: string,
|
||
namespace: string,
|
||
record: TranslationRecord,
|
||
): Promise<void>;
|
||
lastModified?(locale: string, namespace: string): Promise<number | undefined>;
|
||
init?(): Promise<void>;
|
||
}
|
||
|
||
export type MessageLoader = (
|
||
locale: string,
|
||
namespace?: string,
|
||
) => Promise<TranslationRecord>;
|
||
|
||
export type MissingKeyHandler = (info: {
|
||
readonly locale: string;
|
||
readonly namespace: string;
|
||
readonly key: string;
|
||
}) => string | undefined;
|
||
|
||
export interface LookupResult {
|
||
readonly value: string | undefined;
|
||
readonly fellBackTo: string | undefined;
|
||
}
|
||
|
||
export type NamespacedCatalogMap = Map<string, TranslationRecord>;
|
||
export type TranslationCatalogMap = Map<string, NamespacedCatalogMap>;
|
||
```
|
||
|
||
`packages/primitives/types/src/index.ts` adds: `export type * from "./i18n/i18n.types.js";`
|
||
|
||
This is a zero-runtime, additive change — every existing consumer continues to work unchanged.
|
||
|
||
---
|
||
|
||
## 5. Wave 1b — `@suaveplan/i18n-shared` (runtime atoms)
|
||
|
||
`@suaveplan/types` cannot hold runtime — constants and the `I18nError` class need a runtime home. New leaf package:
|
||
|
||
```text
|
||
packages/i18n/i18n-shared/src/
|
||
constants/
|
||
locale-bcp47-map.ts ← LOCALE_BCP47_MAP
|
||
rtl-locales.ts ← RTL_LOCALES, RTL_LOCALE_LIST
|
||
locale-descriptors.ts ← LOCALE_DESCRIPTORS, DEFAULT_LOCALES, DefaultLocale
|
||
interpolation-defaults.ts ← DEFAULT_INTERPOLATION
|
||
index.ts ← explicit re-exports
|
||
errors/
|
||
i18n-error.ts ← I18nError extends SuaveplanError<I18nErrorCode>
|
||
error-codes.ts ← I18N_CODES via defineCodes (full registry, ~14 codes)
|
||
index.ts
|
||
index.ts ← namespace exports
|
||
index.test.ts
|
||
```
|
||
|
||
Dependencies: `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod` (for any boundary schemas the shared layer needs). Zero deps on other `@suaveplan/i18n*` packages.
|
||
|
||
The full `I18N_CODES` registry moves here in one shot — every leaf package throws codes that already exist before any leaf is implemented. This avoids the chicken-and-egg of "which package owns which code."
|
||
|
||
---
|
||
|
||
## 5b. ICU render `FormatBridge` (small architectural wedge)
|
||
|
||
Today `packages/i18n/i18n/src/parser/message/message-render.ts` imports `formatNumber`, `formatCurrency`, `formatDate`, `formatTime` directly to render ICU `{value, number}`, `{amount, number, currency=USD}`, `{date, date, short}` placeholders. That makes the parser depend on the formatters, which would invert Waves 2 and 3.
|
||
|
||
The split introduces a tiny DI seam: `i18n-icu-parser` declares a `FormatBridge` interface and the renderer accepts one; if a bridge is not supplied, the renderer returns the raw value coerced to string (no Intl call). Concretely:
|
||
|
||
```typescript
|
||
export interface FormatBridge {
|
||
number?(value: number, locale: string, style?: NumberFormatStyle): string;
|
||
currency?(value: number, currency: string, locale: string): string;
|
||
date?(value: Date | number, locale: string, style?: DateStyle): string;
|
||
time?(value: Date | number, locale: string, longForm?: boolean): string;
|
||
relativeTime?(value: number, locale: string, style?: RelativeTimeStyle): string;
|
||
}
|
||
|
||
export function renderNodes(
|
||
nodes: readonly MessageNode[],
|
||
values: InterpolationValues,
|
||
locale: string,
|
||
options: RenderOptions & { formatBridge?: FormatBridge },
|
||
): string;
|
||
```
|
||
|
||
The slim core (Wave 6) constructs a default `FormatBridge` by importing the formatter packages and passing it to every `renderNodes` call. Consumers who wire `createI18n({...})` get formatters automatically. Consumers who construct a `MessageParser` directly can pass their own bridge or accept the no-Intl fallback.
|
||
|
||
This is the only API surface change in the entire split; every other module moves byte-identical.
|
||
|
||
Every backend package has the same shape (only `<name>` and the implementation differ):
|
||
|
||
```text
|
||
packages/i18n/i18n-backend-<name>/
|
||
package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types,
|
||
@suaveplan/error, @opentelemetry/api,
|
||
(one external if needed: @suaveplan/storage / node:fs)
|
||
src/
|
||
backend.ts ← implements Backend from @suaveplan/types/i18n
|
||
backend.types.ts
|
||
backend.schemas.ts ← Zod for options
|
||
backend.test.ts
|
||
backend.spec-conformance.test.ts
|
||
backend.md
|
||
factory.ts ← <name>Backend(options): Backend factory function
|
||
factory.test.ts
|
||
factory.md
|
||
index.ts ← explicit named re-exports
|
||
README.md
|
||
tsconfig.json ← extends @suaveplan/typescript-config/library.json
|
||
vite.config.ts ← createTestConfig() from @suaveplan/testing/config
|
||
```
|
||
|
||
Each backend's `factory.ts` exports a lower-case factory (`memoryBackend`, `httpBackend`, etc.) that returns a `Backend`. The backend class itself (`MemoryBackend`, `HttpBackend`, etc.) is also exported for consumers who want subclassing.
|
||
|
||
### 6.1. Tier split rationale
|
||
|
||
Four backends are universal tier (`memory`, `http`, `storage`, `chained`). One is server tier (`fs-multifile`) because it uses `node:fs/promises`. The split prevents the universal package surface from accidentally pulling Node-only deps when a browser-tier consumer imports `@suaveplan/i18n-backend-storage`.
|
||
|
||
---
|
||
|
||
## 7. Formatter package shape (Wave 3 template)
|
||
|
||
```text
|
||
packages/i18n/i18n-formatter-<name>/
|
||
package.json ← deps: @suaveplan/i18n-shared, @suaveplan/types,
|
||
@suaveplan/error, @opentelemetry/api, zod
|
||
src/
|
||
formatter.ts ← <Name>Formatter class wrapping Intl.<Name>
|
||
formatter.types.ts
|
||
formatter.schemas.ts ← Zod for options
|
||
formatter.test.ts
|
||
formatter.spec-conformance.test.ts
|
||
formatter.md
|
||
helpers.ts ← format<Name>(value, locale, options?) standalone
|
||
helpers.test.ts
|
||
helpers.md
|
||
index.ts
|
||
README.md
|
||
tsconfig.json
|
||
vite.config.ts
|
||
```
|
||
|
||
Each formatter caches its underlying `Intl.<Name>` per `(locale, options-hash)` pair (already proven pattern from current `NumberFormatter`). Each emits an OTel histogram `i18n.format.<name>.duration_ms` and a counter `i18n.format.cache_hit{formatter="<name>"}`.
|
||
|
||
### 7.1. Two new formatters
|
||
|
||
`Collator` and `Segmenter` are listed in the original `i18n-formatters` change scope but not implemented in the current monolith. The split adds them as their own packages — no monolith bloat, just two new sibling packages.
|
||
|
||
---
|
||
|
||
## 8. Wave 6 — slim `@suaveplan/i18n` core
|
||
|
||
After Waves 1–5 ship, the core package shrinks dramatically. Final shape:
|
||
|
||
```text
|
||
packages/i18n/i18n/src/
|
||
catalog/
|
||
catalog.ts ← MessageCatalog, deepMerge
|
||
catalog.types.ts
|
||
catalog.schemas.ts
|
||
catalog.test.ts
|
||
catalog.md
|
||
index.ts
|
||
plugin/
|
||
instance.ts ← createI18n, I18nInstance
|
||
instance.types.ts
|
||
instance.test.ts
|
||
instance.md
|
||
index.ts
|
||
middleware/ ← unchanged from current monolith
|
||
observability/ ← unchanged from current monolith
|
||
utils/
|
||
keys.ts ← flattenRecord, buildKey
|
||
...
|
||
schemas/ ← package-level boundary schemas
|
||
conformance.test.ts ← end-to-end spec conformance (KEEPS PASSING)
|
||
index.ts ← root barrel: re-exports the entire pre-split surface
|
||
```
|
||
|
||
Modules that move OUT (and are deleted from `packages/i18n/i18n/src/`):
|
||
|
||
| Source path (deleted) | New home |
|
||
|---|---|
|
||
| `src/constants/` | `@suaveplan/i18n-shared` |
|
||
| `src/errors/` | `@suaveplan/i18n-shared` |
|
||
| `src/error-codes.ts` | `@suaveplan/i18n-shared` |
|
||
| `src/parser/` | `@suaveplan/i18n-icu-parser` |
|
||
| `src/core/interpolation/` | `@suaveplan/i18n-icu-parser` |
|
||
| `src/core/pluralization/` | `@suaveplan/i18n-icu-parser` |
|
||
| `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/detector/` | `@suaveplan/i18n-detector` |
|
||
| `src/core/backend/memory/` | `@suaveplan/i18n-backend-memory` |
|
||
| `src/core/backend/http/` | `@suaveplan/i18n-backend-http` |
|
||
| `src/core/backend/storage/` | `@suaveplan/i18n-backend-storage` |
|
||
| `src/core/backend/multi-file-fs/` | `@suaveplan/i18n-backend-fs-multifile` |
|
||
| `src/core/backend/chained/` | `@suaveplan/i18n-backend-chained` |
|
||
| `src/core/backend/backend.types.ts` | `@suaveplan/types/i18n` (interface) + `@suaveplan/i18n-shared` (any runtime helpers) |
|
||
|
||
Modules that STAY in core: `catalog/`, `plugin/`, `middleware/`, `observability/`, `utils/`, `schemas/`, `conformance.test.ts`, root `index.ts`.
|
||
|
||
### 8.1. Back-compat — root barrel re-exports
|
||
|
||
The root `packages/i18n/i18n/src/index.ts` becomes a re-export shim that preserves every pre-split import. Concretely:
|
||
|
||
```typescript
|
||
// Constants and errors — re-exported from i18n-shared
|
||
export {
|
||
DEFAULT_INTERPOLATION,
|
||
DEFAULT_LOCALES,
|
||
LOCALE_BCP47_MAP,
|
||
LOCALE_DESCRIPTORS,
|
||
RTL_LOCALE_LIST,
|
||
RTL_LOCALES,
|
||
I18N_CODES,
|
||
I18nError,
|
||
} from "@suaveplan/i18n-shared";
|
||
export type { DefaultLocale, I18nErrorCode } from "@suaveplan/i18n-shared";
|
||
|
||
// Catalog — stays in core
|
||
export { MessageCatalog, deepMerge } from "./catalog/catalog.js";
|
||
export type {
|
||
CatalogOptions,
|
||
ExtendedCatalogOptions,
|
||
LookupResult,
|
||
MessageLoader,
|
||
MissingKeyHandler,
|
||
NamespacedCatalogMap,
|
||
TranslationCatalogMap,
|
||
TranslationRecord,
|
||
} from "@suaveplan/types/i18n";
|
||
|
||
// Plugin — stays in core
|
||
export { createI18n, I18nInstance } from "./plugin/instance.js";
|
||
export type { I18nInstanceOptions } from "./plugin/instance.js";
|
||
|
||
// Parser/interpolation/pluralization — re-exported from i18n-icu-parser
|
||
export {
|
||
InterpolationEngine,
|
||
escapeHtmlChars,
|
||
escapeRegex,
|
||
MessageParser,
|
||
PluralizationEngine,
|
||
arabicPlural,
|
||
/* ...full plural rule set... */
|
||
} from "@suaveplan/i18n-icu-parser";
|
||
|
||
// Formatters — re-exported from each i18n-formatter-* package
|
||
export { NumberFormatter, formatNumber } from "@suaveplan/i18n-formatter-number";
|
||
export { RelativeTimeFormatter, formatRelativeTime } from "@suaveplan/i18n-formatter-relative-time";
|
||
export { ListFormatter, formatList } from "@suaveplan/i18n-formatter-list";
|
||
export { DisplayNameFormatter, formatDisplayName } from "@suaveplan/i18n-formatter-display-name";
|
||
export { CollatorFormatter, formatCollation } from "@suaveplan/i18n-formatter-collator";
|
||
export { SegmenterFormatter, formatSegments } from "@suaveplan/i18n-formatter-segmenter";
|
||
export { DateFormatter, formatDate } from "@suaveplan/i18n-formatter-date";
|
||
export { CurrencyFormatter, formatCurrency } from "@suaveplan/i18n-formatter-currency";
|
||
|
||
// Detector — re-exported from i18n-detector
|
||
export { createDetectorChain, getTextDirection } from "@suaveplan/i18n-detector";
|
||
export type { DetectorContext, LocaleDetector } from "@suaveplan/i18n-detector";
|
||
|
||
// Backends — re-exported from i18n-backend-* packages
|
||
export { MemoryBackend, memoryBackend } from "@suaveplan/i18n-backend-memory";
|
||
export { HttpBackend, httpBackend } from "@suaveplan/i18n-backend-http";
|
||
export { StorageBackend, storageBackend } from "@suaveplan/i18n-backend-storage";
|
||
export { ChainedBackend, chainedBackend } from "@suaveplan/i18n-backend-chained";
|
||
// (fs-multifile NOT re-exported from universal root — server-tier consumers import directly)
|
||
```
|
||
|
||
Subpath exports also preserved — `@suaveplan/i18n/detector`, `@suaveplan/i18n/plugin`, `@suaveplan/i18n/parser`, `@suaveplan/i18n/errors` continue to resolve, internally forwarding to the relevant new package.
|
||
|
||
### 8.2. Verification
|
||
|
||
The existing `packages/i18n/i18n/src/conformance.test.ts` is the contract gate. After the slim, **every assertion in that file MUST still pass** — that is the regression bar for the back-compat layer.
|
||
|
||
Downstream consumers verified:
|
||
|
||
- `packages/react/react-i18n/` — 17 distinct imports from `@suaveplan/i18n` (root + subpaths). Re-export bridge covers all of them.
|
||
- `packages/react/react-ui/` — uses `@suaveplan/i18n` for translation key types and missing-key flow. Bridge covers.
|
||
- `packages/react/react-forms/` — uses `@suaveplan/i18n` for validation message catalog. Bridge covers.
|
||
|
||
---
|
||
|
||
## 9. Wave-by-wave execution plan
|
||
|
||
Crash-safety principle: **every wave commits before the next starts.** Waves 1–5 are pure additions to `packages/i18n/` — they never touch `packages/i18n/i18n/src/`. Only Wave 6 deletes from the monolith. If a session dies mid-Wave-6, the leaf packages (Waves 1–5) are already on disk and the monolith is recoverable from `git`.
|
||
|
||
### Wave 1 — Foundation
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 1.1 | `@suaveplan/types/i18n/i18n.types.ts` (zero runtime additions) | `bunx turbo typecheck --filter=@suaveplan/types` green |
|
||
| 1.2 | `@suaveplan/i18n-shared` package scaffold + constants + errors + error codes | 5-gate triple green; 100/100/100/100 cov |
|
||
| 1.3 | Commit: `feat(i18n): foundation — types/i18n + i18n-shared (Wave 1)` | — |
|
||
|
||
### Wave 2 — ICU parser
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 2.1 | `@suaveplan/i18n-icu-parser` scaffold | — |
|
||
| 2.2 | Move `parser/`, `core/interpolation/`, `core/pluralization/` source + tests + docs into the new package; rewrite imports to `@suaveplan/i18n-shared` | 5-gate triple green; 100/100/100/100 cov |
|
||
| 2.3 | Commit: `feat(i18n): icu-parser package (Wave 2)` | — |
|
||
|
||
Note: the source is *copied*, not *moved*, in Wave 2. The monolith retains its copy until Wave 6 deletes it. This keeps the monolith green throughout Waves 2–5.
|
||
|
||
### Wave 3 — Formatters (8 packages, fully parallel)
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 3.1 | 8 formatter package scaffolds (parallel agent dispatch) | — |
|
||
| 3.2 | Copy each `formatting/<name>/` → its package; add Collator + Segmenter from scratch | 5-gate triple green per package; 100/100/100/100 cov each |
|
||
| 3.3 | Commit: `feat(i18n): 8 formatter packages (Wave 3)` | — |
|
||
|
||
### Wave 4 — Detector
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 4.1 | `@suaveplan/i18n-detector` scaffold | — |
|
||
| 4.2 | Copy `detector/` → new package; rewire deps to `@suaveplan/i18n-shared` + `@suaveplan/locale-data` | 5-gate triple green; 100/100/100/100 cov |
|
||
| 4.3 | Commit: `feat(i18n): detector package (Wave 4)` | — |
|
||
|
||
### Wave 5 — Backends (5 packages, fully parallel)
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 5.1 | 5 backend package scaffolds (parallel agent dispatch) | — |
|
||
| 5.2 | Copy each `core/backend/<name>/` → its package; rewire to `Backend` from `@suaveplan/types/i18n` | 5-gate triple green per package; 100/100/100/100 cov each |
|
||
| 5.3 | Commit: `feat(i18n): 5 backend packages (Wave 5)` | — |
|
||
|
||
### Wave 6 — Slim core (RISKY)
|
||
|
||
| Step | Artifact | Gate |
|
||
|---|---|---|
|
||
| 6.1 | Add Wave 1–5 packages as `workspace:^` deps in `packages/i18n/i18n/package.json` | — |
|
||
| 6.2 | Rewrite `packages/i18n/i18n/src/index.ts` to re-export from new packages (per §8.1) | `bunx turbo typecheck --filter=@suaveplan/i18n` green |
|
||
| 6.3 | Verify downstream consumers (`react-i18n`, `react-ui`, `react-forms`) still typecheck and test green | `bunx turbo lint typecheck test --filter='@suaveplan/react-*'` green |
|
||
| 6.4 | Verify `conformance.test.ts` still passes against re-exported surface | `bunx turbo test --filter=@suaveplan/i18n` — all 745 tests green |
|
||
| 6.5 | Delete moved-out source from monolith (per §8 table) | `bunx turbo lint typecheck test --filter=@suaveplan/i18n` green; coverage 100/100/100/100 on remaining slim core |
|
||
| 6.6 | Refresh `MANIFEST.md` (`bun scripts/folder-reorg/generate-manifest.ts`) | — |
|
||
| 6.7 | Update `CLAUDE.md` package listing | — |
|
||
| 6.8 | Archive all 14 change folders + this design doc to `openspec/archive/2026-05-05-i18n-modular-split-*/` | — |
|
||
| 6.9 | Commit: `feat(i18n): slim core + complete modular split (Wave 6)` | — |
|
||
|
||
---
|
||
|
||
## 10. Risk register
|
||
|
||
| Risk | Likelihood | Impact | Mitigation |
|
||
|---|---|---|---|
|
||
| Re-export bridge misses an import path | Medium | Downstream consumers break | §8.1 lists every observed downstream import; conformance test + downstream typecheck in Wave 6.3 catches gaps |
|
||
| Subpath export forwarding loses tree-shaking | Low | Bundle size regression in `react-i18n` consumers | Keep per-package subpath exports (`@suaveplan/i18n-formatter-number/formatter`) so direct importers bypass the bridge |
|
||
| Plural-rule tests in icu-parser drift from monolith copy | Low | Spec conformance fails | Copy verbatim; plural rules are pure functions; same `it(...)` titles |
|
||
| Storage backend tests need real `@suaveplan/storage` MemoryStorage | Low | Test infra | Already working in monolith; same pattern |
|
||
| Wave 6 deletion takes monolith below 100% cov | Medium | Pre-publish gate fails | Delete in surgical commits; run `bunx turbo test:coverage` after each delete batch; restore via `git checkout` if cov drops |
|
||
| Session crash during Wave 6 leaves monolith in half-deleted state | Low (but the user explicitly flagged it) | Branch needs `git restore` | Waves 1–5 already committed = leaf packages survive; `git restore packages/i18n/i18n/src/` brings the monolith back |
|
||
| OpenSpec change folder authoring takes longer than implementation | Medium | Pre-implementation effort sunk | Acceptable — folders are durable artifacts; even a crashed session leaves them on disk for the next session to pick up |
|
||
|
||
---
|
||
|
||
## 11. Out of scope
|
||
|
||
- **No API changes.** Every public symbol keeps its name and signature. The only observable change for consumers is they *can* now import from the per-package paths if they want narrower trees.
|
||
- **No new functionality** beyond `Collator` and `Segmenter` formatters (which were in the original `i18n-formatters` change scope).
|
||
- **No `@suaveplan/react-i18n` changes.** It stays on the slim core's re-export surface; a follow-up change can migrate it to direct per-package imports for tree-shaking.
|
||
- **No `@suaveplan/locale-data` split.** It is already a separate package and has no need to grow further.
|
||
- **No registry/distribution changes.** Verdaccio publication is unchanged; each new package publishes independently.
|
||
|
||
---
|
||
|
||
## 12. Verification matrix
|
||
|
||
For every wave, the following gates are mandatory before commit:
|
||
|
||
```bash
|
||
# Per-package gates (run for each new/touched package)
|
||
bun run check # Biome 0/0/0
|
||
bunx turbo lint typecheck test --filter=@suaveplan/<pkg> # green
|
||
bunx turbo test:coverage --filter=@suaveplan/<pkg> # 100/100/100/100
|
||
bun scripts/openspec-conformance.ts @suaveplan/<pkg> # exit 0
|
||
bunx turbo build --filter=@suaveplan/<pkg> # green
|
||
|
||
# Wave 6 additional cross-package gate
|
||
bunx turbo lint typecheck test --filter='@suaveplan/react-*' # downstream stays green
|
||
bunx turbo build --filter='@suaveplan/i18n*' # entire i18n family builds
|
||
```
|
||
|
||
The OpenSpec conformance gate runs against each new package's spec delta; the `**Verified by:**` lines are checked for existence of the named test cases.
|
||
|
||
---
|
||
|
||
## 13. Authoring sequence for OpenSpec change folders
|
||
|
||
The following 14 change folders will be created in `openspec/changes/`:
|
||
|
||
```text
|
||
openspec/changes/
|
||
i18n-types-extension/ ← Wave 1a (zero-runtime types in @suaveplan/types/i18n)
|
||
i18n-shared/ ← Wave 1b
|
||
i18n-icu-parser/ ← Wave 2
|
||
i18n-formatter-number/ ← Wave 3
|
||
i18n-formatter-relative-time/ ← Wave 3
|
||
i18n-formatter-list/ ← Wave 3
|
||
i18n-formatter-display-name/ ← Wave 3
|
||
i18n-formatter-collator/ ← Wave 3
|
||
i18n-formatter-segmenter/ ← Wave 3
|
||
i18n-formatter-date/ ← Wave 3
|
||
i18n-formatter-currency/ ← Wave 3
|
||
i18n-detector/ ← Wave 4
|
||
i18n-backend-memory/ ← Wave 5
|
||
i18n-backend-http/ ← Wave 5
|
||
i18n-backend-storage/ ← Wave 5
|
||
i18n-backend-fs-multifile/ ← Wave 5
|
||
i18n-backend-chained/ ← Wave 5
|
||
i18n-core-slim/ ← Wave 6
|
||
```
|
||
|
||
(That is actually 18 folders, not 14 — the design doc text in §1.2 lists 14 *new* packages plus the slim core; the change-folder count includes the types extension and the core slim as separate OpenSpec changes.)
|
||
|
||
Each folder contains:
|
||
|
||
- `proposal.md` — Why / What changes / Out of scope / Source mapping / Verification approach / Impact
|
||
- `tasks.md` — numbered checklist following the Wave 1–5 template (spec authoring → scaffold → implementation → tests → documentation → 5-gate triple → 7-row checklist → archive)
|
||
- `specs/<capability>/spec.md` — ADDED Requirements with `**Verified by:**` lines per AGENTS.md §13
|
||
|
||
Each folder is a durable artifact: even if a future session crashes mid-implementation, the next session can pick up from the tasks.md checklist with full context.
|
||
|
||
---
|
||
|
||
## 14. References
|
||
|
||
- `openspec/AGENTS.md` — gate compliance checklist
|
||
- `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` — pattern reference for cross-cutting designs
|
||
- `openspec/changes/archive/2026-05-04-i18n-formatters/` — pattern reference for proposal/tasks/spec-delta shape
|
||
- `packages/i18n/i18n/CLAUDE.md` (root) — current monolith inventory
|
||
- `packages/primitives/types/src/index.ts` — current `@suaveplan/types` surface (Wave 1a extends this)
|