You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/arts/langs/README.md

20 KiB

langs

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.

Handoff 2026-05-12

Langs no es locale y no debe decidir formatos. En la arquitectura a cerrar, ActiveLangs consume la preferencia de idioma (prefs.language) que le pasa la composicion (ActiveApp o ActiveUix standalone). El locale de formato vive en prefs.locale y pertenece a Format.


Architecture

langs/
├── index.ts               Barrel exports
├── types.ts               LangBase, SupportedLocale, LangRecord, LangNode, ...
├── engine-langs.ts         Pure factory: createEngineLangs()
├── active-langs.svelte.ts  Svelte 5 reactive wrapper: createActiveLangs()
├── mono-langs.svelte.ts    Single-language passthrough: createActiveMonoLangs()
├── 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/
    └── langs.test.ts       Unit tests (vitest, node env)

Design principles

  1. Pure engine — createEngineLangs() has no reactive state of its own. t() and ts() require an explicit locale. No hidden mutation.
  2. Reactive wrapper — createActiveLangs() (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 — createActiveMonoLangs() 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:

alias: { $langs: 'src/arts/langs' }

Imports:

import { createEngineLangs } from '$langs';
import { createActiveLangs } from '$langs/active-langs.svelte';
import { createActiveMonoLangs } from '$langs/mono-langs.svelte';

createActiveLangs and createActiveMonoLangs 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

{ es: 'Aceptar', en: 'OK', ar: 'حسناً' }

All locale keys are optional — no locale is hardcoded.

LangFn — parametric interpolation

(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()

import { p } from '$langs';

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

'#?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

import { p } from '$langs';
import type { LangNode } from '$langs';

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)

import { createEngineLangs } from '$langs';
import { translations } from './translations';

const langs = createEngineLangs(translations, 'es');

// `t()` and `ts()` require an explicit locale on the pure engine.
langs.t('common.ok', undefined, 'es');              // 'Aceptar'
langs.t('greet', { name: 'Ana' }, 'en');            // 'Hello, Ana'
langs.t('messages.unread', { count: 5 }, 'en');     // '5 unread messages'

langs.ts({ es: 'Hola', en: 'Hello' }, 'en');        // 'Hello'
langs.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:

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 langs.ts(message) to render.

// 1) Plain string — passthrough, no lookup
langs.ts('Static text', 'en');                       // 'Static text'

// 2) LangRecord — inline per-locale values
langs.ts({ es: 'Guardar', en: 'Save', ar: 'حفظ' }, 'en');  // 'Save'
langs.ts({ es: 'Solo español' }, 'en');              // 'Solo español' (single-locale fallback)

// 3) LangRef — alias to a schema key (optional literal fallback)
langs.ts('#?common.ok', 'es');                       // resolves to 'Aceptar'
langs.ts('#?missing.key|Default', 'es');             // fallback literal wins

Practical pattern — a generic button component that accepts a translatable label:

<!-- MyButton.svelte -->
<script lang="ts">
  import type { LangString } from '$langs';
  import { getContext } from 'svelte';
  import type { ActiveLangs } from '$langs';

  let { label, onclick }: { label: LangString; onclick: () => void } = $props();
  const langs = getContext<ActiveLangs>('langs');
</script>

<button {onclick}>{langs.ts(label)}</button>

Callers pass whichever form fits:

<MyButton label="Plain text" onclick={...} />
<MyButton label={{ es: 'Guardar', en: 'Save' }} onclick={...} />
<MyButton label="#?actions.save" onclick={...} />

The three guards help narrow a LangString when needed:

import { isLangRef, isLangRecord, isLangString } from '$langs';

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

<script lang="ts">
  import { createActiveLangs } from '$langs/active-langs.svelte';
  import { translations } from './translations';

  const langs = createActiveLangs(translations, 'es');
</script>

<h1>{langs.t('common.ok')}</h1>
<button onclick={() => langs.setLocale('en')}>EN</button>
<button onclick={() => langs.setLocale('ar')}>AR</button>

createActiveLangs 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:

langs.t('common.ok');                   // uses active locale
langs.t('common.ok', undefined, 'en');  // explicit locale override

Fallback chain

// Resolution order: currentLocale → en → es (default)
const langs = createActiveLangs(translations, 'es', ['en']);

langs.setLocale('fr');
langs.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:

langs.extend('shop', {
    product: { es: 'Producto', en: 'Product' }
});

langs.t('shop.product'); // 'Producto'

// Dotted namespace — creates nested structure
langs.extend('app.settings', {
    title: { es: 'Ajustes', en: 'Settings' }
});

// Subscribe to schema changes (triggers re-render in Svelte automatically)
const unsub = langs.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 langs — extend(): leaf at "<path>" 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.
langs.extend('common', { ok: { es: 'Sí', en: 'Yes' } });
// DEV: [langs] 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:

const child = langs.register('admin', {
    dashboard: { es: 'Panel', en: 'Dashboard' }
});

child.t('admin.dashboard'); // 'Panel'
child.t('common.ok');       // 'Aceptar' (inherits parent schema)

langs.setLocale('en');
child.getLocale();          // 'en' (synced)

child.dispose();            // detach from parent
langs.setLocale('es');
child.getLocale();          // 'en' (stopped syncing)

Mono langs — single-language passthrough

When the app does not need translation, use createActiveMonoLangs() to get an ActiveLangs-shaped instance whose t() / ts() methods are passthrough:

import { createActiveMonoLangs } from '$langs/mono-langs.svelte';

const langs = createActiveMonoLangs();

langs.t('common.ok');                       // 'common.ok'
langs.t('common.ok|Aceptar');               // 'Aceptar'
langs.t('greet|Hello {{name}}', { name: 'Ada' });  // 'Hello Ada'
langs.ts('plain text');                     // 'plain text'
langs.ts({ es: 'Hola', en: 'Hello' });      // 'Hola' (first non-empty entry)

The locale is held as $state so consumers that subscribe via onLocaleChange still react to setLocale. In ActiveApp, the langs service is normally synchronized from prefs.language; Format uses prefs.locale instead.

In DEV the mono langs adapter warns once per unresolved path via the supplied logger under category langs.mono — strong signal that the caller forgot to configure real translations or to add |fallback text. Calls that include |fallback are silent.

const langs = createActiveMonoLangs({ logger: app.logger });
langs.t('users.profile.name');              // warns once
langs.t('users.profile.name');              // dedup, no second warn
langs.t('users.profile.email');             // different path -> warns once

createActiveMonoLangs() is available as an explicit null-object translator for tests, prototypes or standalone code. active-app no longer exposes a legacy always-present App.langs; declare langs: defineActiveLangs(...) in services when an app needs translations.

Mono langs type contract

createActiveMonoLangs() is public, but intentionally type-loose: there is no schema, so it cannot provide typed path autocomplete or static missing-key checks. Do not treat mono langs as proof that translation keys are valid.

Treat that cast as a null-object convenience, not as a type guarantee:

  • Use createActiveApp({ services: { langs: defineActiveLangs({ schema, defaultLocale }) } }) when translation key correctness matters.
  • Use mono langs 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 — EngineLangs

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 — ActiveLangs (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<P> LangRecord | LangFn<P> | LangPluralFn<P> | LangRef
LangBranch { [key: string]: LangNode } — named branch
LangRef Template literal: `#?${string}`
LangFn<P> (params: P) => LangRecord
LangPluralFn<P> (params: { count: number } & P) => LangRecord
LangParams Record<string, unknown> — canonical params type
PluralConfig { [K in SupportedLocale]?: PluralForms }
PluralForms { other: string } & Partial<Record<PluralCategory, string>>
EngineLangs<S> Pure engine public interface
ActiveLangs<S> Reactive wrapper public interface
Logger Shared logger contract from $libs/logger; 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 createEngineLangs(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.

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:

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

import { langNodeToJSON, JSONToLangNode } from '$langs';

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

npx vitest run src/arts/langs/test/langs.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.

Powered by TurnKey Linux.