|
|
|
|
@ -1,4 +1,4 @@
|
|
|
|
|
# lang
|
|
|
|
|
# langs
|
|
|
|
|
|
|
|
|
|
Type-safe i18n library for SvelteKit. **Zero external dependencies.** Reactive
|
|
|
|
|
translation resolution with fallback chain, pluralization via
|
|
|
|
|
@ -6,7 +6,7 @@ translation resolution with fallback chain, pluralization via
|
|
|
|
|
|
|
|
|
|
## Handoff 2026-05-12
|
|
|
|
|
|
|
|
|
|
`Lang` no es `locale` y no debe decidir formatos. En la arquitectura a cerrar,
|
|
|
|
|
`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`.
|
|
|
|
|
@ -16,7 +16,7 @@ en `prefs.locale` y pertenece a `Format`.
|
|
|
|
|
## Architecture
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
lang/
|
|
|
|
|
langs/
|
|
|
|
|
├── index.ts Barrel exports
|
|
|
|
|
├── types.ts LangBase, SupportedLocale, LangRecord, LangNode, ...
|
|
|
|
|
├── engine-langs.ts Pure factory: createEngineLangs()
|
|
|
|
|
@ -172,15 +172,15 @@ export type TranslationSchema = typeof translations;
|
|
|
|
|
import { createEngineLangs } from '$langs';
|
|
|
|
|
import { translations } from './translations';
|
|
|
|
|
|
|
|
|
|
const lang = createEngineLangs(translations, 'es');
|
|
|
|
|
const langs = createEngineLangs(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'
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
lang.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
|
|
|
|
|
lang.ts('#?common.ok', 'es'); // 'Aceptar'
|
|
|
|
|
langs.ts({ es: 'Hola', en: 'Hello' }, 'en'); // 'Hello'
|
|
|
|
|
langs.ts('#?common.ok', 'es'); // 'Aceptar'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### `ts()` and the `LangString` type
|
|
|
|
|
@ -195,19 +195,19 @@ 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.
|
|
|
|
|
shapes transparently, and components call `langs.ts(message)` to render.
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// 1) Plain string — passthrough, no lookup
|
|
|
|
|
lang.ts('Static text', 'en'); // 'Static text'
|
|
|
|
|
langs.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)
|
|
|
|
|
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)
|
|
|
|
|
lang.ts('#?common.ok', 'es'); // resolves to 'Aceptar'
|
|
|
|
|
lang.ts('#?missing.key|Default', 'es'); // fallback literal wins
|
|
|
|
|
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:
|
|
|
|
|
@ -220,10 +220,10 @@ Practical pattern — a generic button component that accepts a translatable lab
|
|
|
|
|
import type { ActiveLangs } from '$langs';
|
|
|
|
|
|
|
|
|
|
let { label, onclick }: { label: LangString; onclick: () => void } = $props();
|
|
|
|
|
const lang = getContext<ActiveLangs>('lang');
|
|
|
|
|
const langs = getContext<ActiveLangs>('langs');
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<button {onclick}>{lang.ts(label)}</button>
|
|
|
|
|
<button {onclick}>{langs.ts(label)}</button>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Callers pass whichever form fits:
|
|
|
|
|
@ -251,12 +251,12 @@ if (isLangString(value)) { /* value is one of the three */ }
|
|
|
|
|
import { createActiveLangs } from '$langs/active-langs.svelte';
|
|
|
|
|
import { translations } from './translations';
|
|
|
|
|
|
|
|
|
|
const lang = createActiveLangs(translations, 'es');
|
|
|
|
|
const langs = createActiveLangs(translations, 'es');
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<h1>{lang.t('common.ok')}</h1>
|
|
|
|
|
<button onclick={() => lang.setLocale('en')}>EN</button>
|
|
|
|
|
<button onclick={() => lang.setLocale('ar')}>AR</button>
|
|
|
|
|
<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
|
|
|
|
|
@ -267,18 +267,18 @@ 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
|
|
|
|
|
langs.t('common.ok'); // uses active locale
|
|
|
|
|
langs.t('common.ok', undefined, 'en'); // explicit locale override
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Fallback chain
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
// Resolution order: currentLocale → en → es (default)
|
|
|
|
|
const lang = createActiveLangs(translations, 'es', ['en']);
|
|
|
|
|
const langs = createActiveLangs(translations, 'es', ['en']);
|
|
|
|
|
|
|
|
|
|
lang.setLocale('fr');
|
|
|
|
|
lang.t('common.ok'); // fr missing → tries en → 'OK'
|
|
|
|
|
langs.setLocale('fr');
|
|
|
|
|
langs.t('common.ok'); // fr missing -> tries en -> 'OK'
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Dynamic modules — `extend()`
|
|
|
|
|
@ -287,19 +287,19 @@ Adds translations to the schema at runtime. Supports dotted namespaces and
|
|
|
|
|
deep-merges existing keys:
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
lang.extend('shop', {
|
|
|
|
|
langs.extend('shop', {
|
|
|
|
|
product: { es: 'Producto', en: 'Product' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
lang.t('shop.product'); // 'Producto'
|
|
|
|
|
langs.t('shop.product'); // 'Producto'
|
|
|
|
|
|
|
|
|
|
// Dotted namespace — creates nested structure
|
|
|
|
|
lang.extend('app.settings', {
|
|
|
|
|
langs.extend('app.settings', {
|
|
|
|
|
title: { es: 'Ajustes', en: 'Settings' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Subscribe to schema changes (triggers re-render in Svelte automatically)
|
|
|
|
|
const unsub = lang.onSchemaChange(() => {
|
|
|
|
|
const unsub = langs.onSchemaChange(() => {
|
|
|
|
|
// schema changed
|
|
|
|
|
});
|
|
|
|
|
```
|
|
|
|
|
@ -319,7 +319,7 @@ const unsub = lang.onSchemaChange(() => {
|
|
|
|
|
registering the same key by mistake.
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
lang.extend('common', { ok: { es: 'Sí', en: 'Yes' } });
|
|
|
|
|
langs.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.
|
|
|
|
|
@ -335,24 +335,24 @@ 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', {
|
|
|
|
|
const child = langs.register('admin', {
|
|
|
|
|
dashboard: { es: 'Panel', en: 'Dashboard' }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.t('admin.dashboard'); // 'Panel'
|
|
|
|
|
child.t('common.ok'); // 'Aceptar' (inherits parent schema)
|
|
|
|
|
|
|
|
|
|
lang.setLocale('en');
|
|
|
|
|
langs.setLocale('en');
|
|
|
|
|
child.getLocale(); // 'en' (synced)
|
|
|
|
|
|
|
|
|
|
child.dispose(); // detach from parent
|
|
|
|
|
lang.setLocale('es');
|
|
|
|
|
langs.setLocale('es');
|
|
|
|
|
child.getLocale(); // 'en' (stopped syncing)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Mono lang — single-language passthrough
|
|
|
|
|
## 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:
|
|
|
|
|
@ -360,48 +360,48 @@ When the app does not need translation, use `createActiveMonoLangs()` to get an
|
|
|
|
|
```ts
|
|
|
|
|
import { createActiveMonoLangs } from '$langs/mono-langs.svelte';
|
|
|
|
|
|
|
|
|
|
const lang = createActiveMonoLangs();
|
|
|
|
|
const langs = createActiveMonoLangs();
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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 lang service
|
|
|
|
|
`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 lang warns once per unresolved path via the supplied logger
|
|
|
|
|
In DEV the mono langs adapter 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
|
|
|
|
|
configure real translations or to add `|fallback` text. Calls that include
|
|
|
|
|
`|fallback` are silent.
|
|
|
|
|
|
|
|
|
|
```ts
|
|
|
|
|
const lang = createActiveMonoLangs({ 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
|
|
|
|
|
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
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Mono lang is consumed automatically by `createActiveApp({ /* no lang */ })`
|
|
|
|
|
`createActiveMonoLangs()` is used automatically by `createActiveApp({ /* no langs */ })`
|
|
|
|
|
so monolingual apps never have to wire it themselves.
|
|
|
|
|
|
|
|
|
|
### Mono lang type contract
|
|
|
|
|
### 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. `createActiveApp<S>()` casts the mono implementation to
|
|
|
|
|
`ActiveLangs<S>` when the app is created without `lang`, only to keep the root
|
|
|
|
|
`ActiveLangs<S>` when the app is created without `langs`, only to keep the root
|
|
|
|
|
surface uniform (`App.langs.t(...)` always exists).
|
|
|
|
|
|
|
|
|
|
Treat that cast as a null-object convenience, not as a type guarantee:
|
|
|
|
|
|
|
|
|
|
- Use `createActiveApp({ langs: { schema, defaultLocale } })` when translation
|
|
|
|
|
key correctness matters.
|
|
|
|
|
- Use mono-lang for monolingual apps, prototypes or apps that deliberately want
|
|
|
|
|
- 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.
|
|
|
|
|
|