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

97 lines
8.5 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-shared` — runtime atoms for the i18n family
**Package**: `@suaveplan/i18n-shared` (NEW)
**Status**: PROPOSED
**Type**: feature (new universal-tier package)
**Date**: 2026-05-05
**Wave**: 1b (foundation, prerequisite for Waves 2–6 of the i18n modular split)
**Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md`
**Path**: `packages/i18n/i18n-shared/`
## Why
The 14-package i18n modular split needs a single home for runtime atoms that every leaf consumes:
- **Constants** — `LOCALE_BCP47_MAP`, `RTL_LOCALES`, `RTL_LOCALE_LIST`, `LOCALE_DESCRIPTORS`, `DEFAULT_LOCALES`, `DefaultLocale`, `DEFAULT_INTERPOLATION`. Today these live in `packages/i18n/i18n/src/constants/index.ts` and are imported by every formatter (for `LOCALE_BCP47_MAP` BCP-47 normalisation), the detector (for `RTL_LOCALES` direction lookup), the catalog (for `DEFAULT_LOCALES` defaults), and the interpolation engine (for `DEFAULT_INTERPOLATION`).
- **Error code registry** — the full `I18N_CODES` namespace defined via `defineCodes("I18N", [...] as const)`. The split CANNOT spread codes across leaf packages without two failure modes: (a) consumers can't `instanceof I18nError` and discriminate by code without importing every leaf, and (b) the OTel error tagging surface fragments. One central registry is the only sane option.
- **`I18nError` class** — extends `SuaveplanError<I18nCode>`. Every leaf throws it; central definition keeps `instanceof` discrimination working across the family.
These cannot live in `@suaveplan/types` (zero runtime — see CLAUDE.md `types` rule). They cannot stay in `@suaveplan/i18n` core because backend / formatter / detector / parser packages would each need a dep on the core, which causes a cycle the moment the slim core (Wave 6) re-exports any of them. The clean answer is a leaf runtime package: `@suaveplan/i18n-shared`.
## What changes
### New package: `@suaveplan/i18n-shared`
- ADDED: `packages/i18n/i18n-shared/` package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
- ADDED: `src/constants/locale-bcp47-map.ts` — exports `LOCALE_BCP47_MAP` (Record<string, string>), the 22-entry mapping from short / mixed-case codes to canonical BCP-47 tags. Source: `packages/i18n/i18n/src/constants/index.ts` L31-L55.
- ADDED: `src/constants/rtl-locales.ts` — exports `RTL_LOCALES: ReadonlySet<string>` (7 ISO 639-1 codes: `ar`, `he`, `fa`, `ur`, `ps`, `sd`, `yi`) and `RTL_LOCALE_LIST` (same as a tree-shake-friendly tuple).
- ADDED: `src/constants/locale-descriptors.ts` — exports `LOCALE_DESCRIPTORS` (per-locale `{ name, nativeName, direction }` metadata for 16 locales), `DEFAULT_LOCALES` tuple (16 codes), and `DefaultLocale` type alias.
- ADDED: `src/constants/interpolation-defaults.ts` — exports `DEFAULT_INTERPOLATION = { prefix: "{{", suffix: "}}", maxDepth: 10 } as const`.
- ADDED: `src/constants/index.ts` — explicit named re-export barrel.
- ADDED: `src/errors/error-codes.ts` — exports the full `I18N_CODES` registry via `defineCodes("I18N", [...] as const)` and the `I18nErrorCode` (formerly `I18nCode`) discriminant union. Initial registry contains all 14 codes currently in the monolith plus 4 new codes carved out for the per-package split:
- `MISSING_KEY`, `INVALID_LOCALE`, `INVALID_OPTIONS`, `LOADER_FAILED`, `INTERPOLATION_ERROR`, `PLURAL_RULE_NOT_FOUND` (parser/interpolation/pluralization)
- `BACKEND_READ_FAILED`, `BACKEND_WRITE_FAILED`, `BACKEND_NOT_REGISTERED`, `BACKEND_LOCALE_NOT_AVAILABLE` (backends)
- `INVALID_FORMATTER_OPTIONS`, `UNSUPPORTED_LOCALE` (formatters)
- `NESTING_DEPTH_EXCEEDED`, `PLUGIN_ALREADY_INSTALLED` (core / plugin slot)
- NEW: `BACKEND_TIMEOUT`, `DETECTOR_CHAIN_EXHAUSTED`, `CHAINED_BACKEND_ALL_FAILED`, `FORMATTER_CACHE_OVERFLOW` — codes added by the per-package split
- ADDED: `src/errors/i18n-error.ts` — exports `I18nError extends SuaveplanError<I18nErrorCode>`. Constructor widens the protected base constructor to public; suppression-comment-free implementation per CLAUDE.md §9 (uses a no-body class with explicit `declare`d constructor signature, or equivalent biome-clean form).
- ADDED: `src/errors/index.ts` — explicit named re-export barrel.
- ADDED: `src/index.ts` — namespace re-exports both the constants and errors barrels.
### Subpath exports
- ADDED: `package.json` `exports` field with subpaths:
- `.` — root barrel
- `./constants` — constants only (zero error-class dep, useful for tree-shaking)
- `./constants/locale-bcp47-map` — single-constant import for size-sensitive consumers
- `./constants/rtl-locales` — single-constant import
- `./constants/locale-descriptors` — single-constant import
- `./constants/interpolation-defaults` — single-constant import
- `./errors` — `I18N_CODES`, `I18nError`, `I18nErrorCode`
## Out of scope
- **Catalog / parser / formatter / backend / detector implementations** — separate change folders per the design doc.
- **Removing constants/errors from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 2–5, the leaves import from `@suaveplan/i18n-shared` while the monolith retains its copy; both compile.
- **Renaming `I18nCode` → `I18nErrorCode`** in the existing monolith — happens in Wave 6.
- **Adding the `I18nMessageResolver` adapter** that maps codes to localized error messages — that adapter belongs to `@suaveplan/error` (a follow-up change, not this one).
## Source mapping
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `LOCALE_BCP47_MAP` | `packages/i18n/i18n/src/constants/index.ts` | L31-L55 | Verbatim port |
| `RTL_LOCALES`, `RTL_LOCALE_LIST` | `packages/i18n/i18n/src/constants/index.ts` | L57-L75 | Verbatim port; split into two named exports already present |
| `LOCALE_DESCRIPTORS` | `packages/i18n/i18n/src/constants/index.ts` | L77-L104 | Verbatim port |
| `DEFAULT_LOCALES`, `DefaultLocale` | `packages/i18n/i18n/src/constants/index.ts` | L11-L29 | Verbatim port |
| `DEFAULT_INTERPOLATION` | `packages/i18n/i18n/src/constants/index.ts` | L4-L8 | Verbatim port |
| `I18N_CODES`, `I18nErrorCode` | `packages/i18n/i18n/src/error-codes.ts` | full file | Verbatim port + 4 new codes |
| `I18nError` | `packages/i18n/i18n/src/errors/i18n-error.ts` | full file | Port; rewrite to drop `// biome-ignore` per CLAUDE.md §9 |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath exports for individual constants | Tree-shaking — `@suaveplan/i18n-formatter-collator` should not pull `LOCALE_DESCRIPTORS` just to get `LOCALE_BCP47_MAP` |
| Four new error codes (`BACKEND_TIMEOUT`, `DETECTOR_CHAIN_EXHAUSTED`, `CHAINED_BACKEND_ALL_FAILED`, `FORMATTER_CACHE_OVERFLOW`) | The per-package split surfaces failure modes the monolith never modelled — chained-backend exhaustion, detector exhaustion, etc. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files:
- `packages/i18n/i18n-shared/src/constants/locale-bcp47-map.test.ts` — short codes, mixed-case codes, and known canonical tags all map correctly
- `packages/i18n/i18n-shared/src/constants/rtl-locales.test.ts` — `RTL_LOCALES.has("ar")` true, `RTL_LOCALES.has("en")` false, `RTL_LOCALE_LIST` length matches set size
- `packages/i18n/i18n-shared/src/constants/locale-descriptors.test.ts` — every `DEFAULT_LOCALES` entry has a descriptor; `LOCALE_DESCRIPTORS.ar.direction === "rtl"`
- `packages/i18n/i18n-shared/src/constants/interpolation-defaults.test.ts` — `DEFAULT_INTERPOLATION.prefix === "{{"`; type is `as const`
- `packages/i18n/i18n-shared/src/errors/error-codes.test.ts` — every code is exported; `I18N_CODES.MISSING_KEY === "I18N.MISSING_KEY"`; new codes present
- `packages/i18n/i18n-shared/src/errors/i18n-error.test.ts` — `new I18nError(I18N_CODES.MISSING_KEY, "k")` succeeds; `instanceof I18nError`; `instanceof SuaveplanError`; `.code === "I18N.MISSING_KEY"`
- `packages/i18n/i18n-shared/src/index.test.ts` — every documented public export resolves through the root barrel
- `packages/i18n/i18n-shared/src/byte-size.test.ts` — root bundle stays under a documented byte budget (constants + 18 error codes + 1 class ≈ < 4 KiB minified)
## Impact
- New universal-tier package — joins `@suaveplan/i18n` and `@suaveplan/locale-data` under `packages/i18n/`.
- Zero downstream consumers as of this change (the leaves consume it in Waves 2–5, the slim core re-exports it in Wave 6).
- `MANIFEST.md` regenerated via `bun scripts/folder-reorg/generate-manifest.ts` after the package lands.
- `CLAUDE.md` package listing updated with a one-line entry under the i18n cluster.