# `@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`. 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), 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` (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`. 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.