# lang Type-safe i18n library for SvelteKit. **Zero external dependencies.** Reactive translation resolution with fallback chain, pluralization via `Intl.PluralRules`, cross-key references, and full JSON serialization. --- ## Architecture ``` lang/ ├── index.ts Barrel exports ├── types.ts LangBase, SupportedLocale, LangRecord, LangNode, ... ├── engine-lang.ts Pure factory: createEngineLang() ├── active-lang.svelte.ts Svelte 5 reactive wrapper: createActiveLang() ├── mono-lang.svelte.ts Single-language passthrough: createActiveMonoLang() ├── plural.ts p() helper, WeakMap-based config storage ├── plural_rules.ts Intl.PluralRules wrapper ├── guards.ts Type guards: isLangRef, isLangRecord, isLangString ├── helpers.ts resolvePath, interpolateTemplate, resolveLocaleInRecord, deepMerge ├── json.ts langNodeToJSON / JSONToLangNode (round-trip) ├── consts.ts ID_PREFIX (#?), separators, limits ├── errors.ts Centralized error messages └── test/ └── lang.test.ts Unit tests (vitest, node env) ``` ### Design principles 1. **Pure engine** — `createEngineLang()` has no reactive state of its own. `t()` and `ts()` **require** an explicit `locale`. No hidden mutation. 2. **Reactive wrapper** — `createActiveLang()` (in `.svelte.ts`) owns the locale via `$state`, injecting it into each engine call. The engine stays framework-agnostic so non-Svelte consumers can build their own adapter. 3. **Mono variant** — `createActiveMonoLang()` for monolingual apps that want the same `t()` / `ts()` call sites without paying for a translation table. Returns paths verbatim (or `|fallback` literals), warns once per unresolved path in DEV. 4. **BCP 47 locales** — `SupportedLocale = LangBase | \`${LangBase}-${string}\``. Resolution per record: exact → region→base → bare→first sibling → fallback chain. 5. **Fallback chain** — `[currentLocale, ...fallbackChain, defaultLocale]`. 6. **Type-safe paths** — `t('common.ok')` autocompletes and validates types. A dynamic `string` overload is available for runtime paths. 7. **Decoupled plural data** — `p()` lives in `plural.ts`, not in the engine; translation data never imports from the engine. --- ## Alias Configured in `svelte.config.js`: ```js alias: { $lang: 'src/arts/lang' } ``` Imports: ```ts import { createEngineLang } from '$lang'; import { createActiveLang } from '$lang/active-lang.svelte'; import { createActiveMonoLang } from '$lang/mono-lang.svelte'; ``` `createActiveLang` and `createActiveMonoLang` live in `.svelte.ts` files — they own `$state` and must be imported from the file directly, not from the barrel (which stays runes-free for SSR/Node consumers). --- ## Schema A schema is a tree of `LangNode` with four leaf types: ### LangRecord — static translations ```ts { es: 'Aceptar', en: 'OK', ar: 'حسناً' } ``` All locale keys are optional — no locale is hardcoded. ### LangFn — parametric interpolation ```ts (params: { name: string }) => ({ es: `Hola, ${params.name}`, en: `Hello, ${params.name}` }); ``` At serialization time (`langNodeToJSON`), a proxy captures the tokens so the JSON contains `"Hola, {{name}}"` regardless of whether the function uses `${...}` or `{{...}}`. ### LangPluralFn — pluralization with `p()` ```ts import { p } from '$lang'; p({ es: { one: '{{count}} mensaje', other: '{{count}} mensajes' }, en: { one: '{{count}} message', other: '{{count}} messages' }, ar: { zero: 'لا توجد رسائل', one: 'رسالة واحدة', two: 'رسالتان', few: '{{count}} رسائل', many: '{{count}} رسالة', other: '{{count}} رسالة' } }); ``` Internally uses `Intl.PluralRules`. Forms: `zero`, `one`, `two`, `few`, `many`, `other`. Only `other` is required; missing forms fall back to `other`. ### LangRef — alias to another key ```ts '#?common.ok'; // resolves the value at `common.ok` '#?common.ok|fallback'; // literal fallback if the key does not exist ``` Prefix `#?`, separator `|` for the fallback literal. Up to 3 levels of indirection; cycles throw. ### Full example ```ts import { p } from '$lang'; import type { LangNode } from '$lang'; export const translations = { common: { ok: { es: 'Aceptar', en: 'OK' }, cancel: { es: 'Cancelar', en: 'Cancel' } }, greet: (params: { name: string }) => ({ es: `Hola, ${params.name}`, en: `Hello, ${params.name}` }), messages: { unread: p({ es: { one: '{{count}} mensaje sin leer', other: '{{count}} mensajes sin leer' }, en: { one: '{{count}} unread message', other: '{{count}} unread messages' } }) }, ref: '#?common.ok' } satisfies LangNode; export type TranslationSchema = typeof translations; ``` --- ## Usage ### Pure engine (non-Svelte) ```ts import { createEngineLang } from '$lang'; import { translations } from './translations'; const lang = createEngineLang(translations, 'es'); // `t()` and `ts()` require an explicit locale on the pure engine. lang.t('common.ok', undefined, 'es'); // 'Aceptar' lang.t('greet', { name: 'Ana' }, 'en'); // 'Hello, Ana' lang.t('messages.unread', { count: 5 }, 'en'); // '5 unread messages' lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello' lang.ts('#?common.ok', 'es'); // 'Aceptar' ``` ### `ts()` and the `LangString` type `t()` resolves translations **by path** from the schema. `ts()` takes a **`LangString` value directly** — useful when the translation is built inline or flows through props/context. The value can be any of the three variants: ```ts type LangString = string | LangRecord | LangRef; ``` This is the idiomatic way to accept "maybe translatable" text through component boundaries: a prop typed `message: LangString` accepts all three shapes transparently, and components call `lang.ts(message)` to render. ```ts // 1) Plain string — passthrough, no lookup lang.ts('Static text', 'en'); // 'Static text' // 2) LangRecord — inline per-locale values lang.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en'); // 'Save' lang.ts({ es: 'Solo español' }, 'en'); // 'Solo español' (single-locale fallback) // 3) LangRef — alias to a schema key (optional literal fallback) lang.ts('#?common.ok', 'es'); // resolves to 'Aceptar' lang.ts('#?missing.key|Default', 'es'); // fallback literal wins ``` Practical pattern — a generic button component that accepts a translatable label: ```svelte ``` Callers pass whichever form fits: ```svelte ``` The three guards help narrow a `LangString` when needed: ```ts import { isLangRef, isLangRecord, isLangString } from '$lang'; if (isLangRef(value)) { /* value is `#?...` */ } if (isLangRecord(value)) { /* value is { es?, en?, ar?, ... } */ } if (isLangString(value)) { /* value is one of the three */ } ``` ### Reactive wrapper for Svelte 5 ```svelte

{lang.t('common.ok')}

``` `createActiveLang` holds `locale` in `$state`. When you call `setLocale(x)`, every `t()` / `ts()` / `getLocale()` consumed in a component (or inside `$derived` / `$effect`) re-evaluates. Signatures on the reactive wrapper make `locale` **optional** — it defaults to the active reactive locale: ```ts lang.t('common.ok'); // uses active locale lang.t('common.ok', undefined, 'en'); // explicit locale override ``` ### Fallback chain ```ts // Resolution order: currentLocale → en → es (default) const lang = createActiveLang(translations, 'es', ['en']); lang.setLocale('fr'); lang.t('common.ok'); // fr missing → tries en → 'OK' ``` ### Dynamic modules — `extend()` Adds translations to the schema at runtime. Supports dotted namespaces and deep-merges existing keys: ```ts lang.extend('shop', { product: { es: 'Producto', en: 'Product' } }); lang.t('shop.product'); // 'Producto' // Dotted namespace — creates nested structure lang.extend('app.settings', { title: { es: 'Ajustes', en: 'Settings' } }); // Subscribe to schema changes (triggers re-render in Svelte automatically) const unsub = lang.onSchemaChange(() => { // schema changed }); ``` #### Collision behavior `extend()` is a "last write wins" operation. Two distinct collision modes: - **Branch ↔ leaf** (incompatible shape): throws `TypeError`. Trying to extend below an existing translation leaf, or replace a branch with a leaf, is rejected loudly because the resulting shape is broken. - **Leaf ↔ leaf** (silent overwrite): the new value replaces the existing one and a DEV warning is emitted under category `lang` — `extend(): leaf at "" was overwritten silently. Last write wins`. The intended use of `extend()` is route-level lazy loading where each module owns a unique namespace; collisions usually indicate two modules registering the same key by mistake. ```ts lang.extend('common', { ok: { es: 'Sí', en: 'Yes' } }); // DEV: [lang] extend(): leaf at "common.ok" was overwritten silently. // Last write wins — register namespaces uniquely or check for // collisions before extending. ``` If your build pipeline can prove namespaces are unique (for example, every route module declares its prefix and a build step verifies disjointness), you can ignore the warning. Otherwise treat it as the bug it usually is. ### Child instances — `register()` Creates a new instance with the module merged. In the reactive wrapper the child shares locale state with the parent: ```ts const child = lang.register('admin', { dashboard: { es: 'Panel', en: 'Dashboard' } }); child.t('admin.dashboard'); // 'Panel' child.t('common.ok'); // 'Aceptar' (inherits parent schema) lang.setLocale('en'); child.getLocale(); // 'en' (synced) child.dispose(); // detach from parent lang.setLocale('es'); child.getLocale(); // 'en' (stopped syncing) ``` --- ## Mono lang — single-language passthrough When the app does not need translation, use `createActiveMonoLang()` to get an `ActiveLang`-shaped instance whose `t()` / `ts()` methods are passthrough: ```ts import { createActiveMonoLang } from '$lang/mono-lang.svelte'; const lang = createActiveMonoLang(); lang.t('common.ok'); // 'common.ok' lang.t('common.ok|Aceptar'); // 'Aceptar' lang.t('greet|Hello {{name}}', { name: 'Ada' }); // 'Hello Ada' lang.ts('plain text'); // 'plain text' lang.ts({ es: 'Hola', en: 'Hello' }); // 'Hola' (first non-empty entry) ``` The locale is held as `$state` so consumers (Formats, Frontend) that subscribe via `onLocaleChange` still react to `setLocale`. In DEV the mono lang warns once per unresolved path via the supplied logger under category `lang.mono` — strong signal that the caller forgot to configure a real lang or to add `|fallback` text. Calls that include `|fallback` are silent. ```ts const lang = createActiveMonoLang({ logger: app.Logger }); lang.t('users.profile.name'); // warns once lang.t('users.profile.name'); // dedup, no second warn lang.t('users.profile.email'); // different path → warns once ``` Mono lang is consumed automatically by `createActiveApp({ /* no lang */ })` so monolingual apps never have to wire it themselves. ### Mono lang type contract `createActiveMonoLang()` is public, but intentionally **type-loose**: there is no schema, so it cannot provide typed path autocomplete or static missing-key checks. `createActiveApp()` casts the mono implementation to `ActiveLang` when the app is created without `lang`, only to keep the root surface uniform (`App.Lang.t(...)` always exists). Treat that cast as a null-object convenience, not as a type guarantee: - Use `createActiveApp({ lang: { schema, defaultLocale } })` when translation key correctness matters. - Use mono-lang for monolingual apps, prototypes or apps that deliberately want literal/fallback strings. - Add `|fallback` text for user-facing strings so missing i18n configuration does not paint internal paths in the UI. --- ## API — EngineLang | Method | Description | | ------------------------------------ | ----------------------------------------------------------- | | `t(path, params, locale)` | Translate by schema path. Params for interpolation/plural. | | `ts(value, locale)` | Translate a direct `LangString` (record, string or ref). | | `onSchemaChange(fn)` | Subscribe to schema mutations (`extend`). Returns unsub. | | `extend(namespace, module)` | Add a module at runtime via deep-merge. Dotted namespaces. | | `register(namespace, module)` | Return a child instance with the merged module. | | `setLogger(logger)` | Inject a custom logger. Set-once. | | `getDefaultLocale()` | Return the default locale of the instance. | | `getFallbackChain()` | Return the configured fallback chain (or `undefined`). | ## API — ActiveLang (Svelte) In addition to the above (with `locale` made optional on `t` / `ts`): | Method | Description | | ------------------------------------ | ----------------------------------------------------------- | | `getLocale()` | Return the active locale (reactive). | | `setLocale(locale)` | Change the active locale. | | `onLocaleChange(fn)` | Subscribe to locale changes. Returns unsub. | | `dispose()` | Detach a child from its parent locale stream. | --- ## Types | Type | Description | | ----------------------- | ------------------------------------------------------------------------------------------------- | | `LangBase` | Closed set of base languages: `es \| en \| ar \| de \| fr \| it \| pt \| ca \| eu \| gl` | | `SupportedLocale` | BCP 47: `LangBase \| ${LangBase}-${string}` — accepts `'es'`, `'es-MX'`, `'pt-BR'`, ... | | `LangRecord` | `{ [K in SupportedLocale]?: string }` — all optional, accepts both bare and regional keys | | `LangString` | `string \| LangRecord \| LangRef` | | `LangNode` | Recursive: `LangValue \| LangBranch` | | `LangValue

` | `LangRecord \| LangFn

\| LangPluralFn

\| LangRef` | | `LangBranch` | `{ [key: string]: LangNode }` — named branch | | `LangRef` | Template literal: `` `#?${string}` `` | | `LangFn

` | `(params: P) => LangRecord` | | `LangPluralFn

` | `(params: { count: number } & P) => LangRecord` | | `LangParams` | `Record` — canonical params type | | `PluralConfig` | `{ [K in SupportedLocale]?: PluralForms }` | | `PluralForms` | `{ other: string } & Partial>` | | `EngineLang` | Pure engine public interface | | `ActiveLang` | Reactive wrapper public interface | | `Logger` | Shared logger contract from `$libs/logr`; `setLogger(logger)` is set-once and diagnostics flow through `LangDiagnostics` | --- ## Fallback behavior When a key is translated, locales are tried in this order: ``` 1. currentLocale 2. fallbackChain[0] 3. fallbackChain[1] 4. ... 5. defaultLocale ``` Example with `createEngineLang(schema, 'es', ['en'])` and `setLocale('fr')`: ``` key: common.ok → { es: 'Aceptar', en: 'OK' } 1. fr → missing 2. en → 'OK' ✓ ``` If every locale is missing, `t()` returns the path (or the `|fallback` literal if present) and `ts()` returns the empty string. In DEV a warning is emitted when a fallback is used. ### BCP 47 resolution per record Each link of the fallback chain is matched against the record using these rules, in order: 1. **Exact match.** `'es-MX'` finds `record['es-MX']` if present. 2. **Region → base.** `'es-MX'` falls back to `record['es']` when the regional entry is missing. 3. **Bare → first sibling.** `'es'` finds the first `record['es-XX']` in insertion order when no bare `'es'` entry exists. With more than one sibling regional variant a DEV warning is emitted asking the author to define the canonical `'es'` entry to remove the ambiguity. 4. Otherwise the next chain link is tried. Note that **regional locales never sibling-search**: requesting `'es-CL'` on a record that only has `'es-MX'` does not match — it falls through to the chain. Two regional variants of the same base are considered distinct editorial decisions. ```ts const record = { en: 'Hello', 'es-MX': 'Qué onda', 'pt-BR': 'Oi' }; resolve(record, 'es-MX'); // 'Qué onda' — exact resolve(record, 'en-GB'); // 'Hello' — region → base resolve(record, 'es-AR'); // missing — no exact, no base, no sibling search resolve(record, 'pt'); // 'Oi' — bare → only sibling resolve(record, 'es'); // 'Qué onda' — bare → first sibling (single match, no warning) ``` With multiple siblings the warning fires: ```ts const onlySiblings = { 'es-MX': 'Qué onda', 'es-AR': 'Hola che' }; resolve(onlySiblings, 'es'); // 'Qué onda' — picked by insertion order // DEV warn: requested base "es" but only 2 regional variants exist; // define a canonical "es" entry to remove the ambiguity. ``` --- ## JSON round-trip ```ts import { langNodeToJSON, JSONToLangNode } from '$lang'; const json = langNodeToJSON(translations); // LangFn → { es: 'Hola {{name}}', en: 'Hello {{name}}' } // LangPluralFn → { __type: 'plural', config: { ... } } // LangRef → '#?common.ok' (plain string) // LangRecord → { es: 'Aceptar', en: 'OK' } (unchanged) const restored = JSONToLangNode(json); // Interpolation and plural functions are reconstructed. // References stay as strings (isLangRef() recognises them). ``` --- ## Tests ```bash npx vitest run src/arts/lang/test/lang.test.ts ``` Covers: guards, helpers, plural rules, `p()` with WeakMap, engine (t/ts, locale param, fallback chain, extend, register, setLogger), JSON round-trip, and confirmation that no locale is hardcoded.