# 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
` | `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` | 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.