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.
645 lines
26 KiB
645 lines
26 KiB
<script lang="ts">
|
|
import CodeBlock from '../../_components/CodeBlock.svelte';
|
|
import Callout from '../../_components/Callout.svelte';
|
|
import AiAgentsBox from '../../_components/AiAgentsBox.svelte';
|
|
import ModuleHeader from '../../_components/ModuleHeader.svelte';
|
|
import PageNav from '../../_components/PageNav.svelte';
|
|
|
|
const schemaExample = `import { p, type LangNode } from '$langs';
|
|
|
|
export const schema = {
|
|
common: {
|
|
ok: { en: 'OK', es: 'Aceptar' },
|
|
cancel: { en: 'Cancel', es: 'Cancelar' }
|
|
},
|
|
cart: {
|
|
// Plurals must use p(); raw plural objects are not LangRecord leaves.
|
|
items: p({
|
|
en: { one: '1 item', other: '{{count}} items' },
|
|
es: { one: '1 artículo', other: '{{count}} artículos' }
|
|
})
|
|
}
|
|
} satisfies LangNode;`;
|
|
|
|
const usage = `import { App } from '$lib/app';
|
|
|
|
App.langs.t('common.ok'); // 'Aceptar' (when locale = 'es')
|
|
App.langs.t('cart.items', { count: 3 }); // '3 artículos'
|
|
App.prefs.language.set('en'); // core prefs drives lang
|
|
App.langs.t('common.ok'); // 'OK'`;
|
|
|
|
const mentalModel = `// EngineLangs: pure resolver. Locale is passed explicitly.
|
|
const Engine = createEngineLangs(schema, 'es', ['en']);
|
|
Engine.t('common.ok', undefined, 'es-MX');
|
|
|
|
// ActiveLangs: Svelte wrapper. Locale is owned as $state.
|
|
const Lang = createActiveLangs(schema, 'es', ['en']);
|
|
Lang.t('common.ok'); // uses current locale
|
|
Lang.setLocale('en-GB'); // re-renders consumers that read t(), ts() or getLocale()
|
|
|
|
// App.langs: service declared through $active-app/services.
|
|
App.prefs.language.set('ar');
|
|
App.langs.t('home.title');`;
|
|
|
|
const fallback = `createActiveApp({
|
|
prefs: { capabilities, environment },
|
|
services: {
|
|
langs: defineActiveLangs({
|
|
schema,
|
|
defaultLocale: 'es',
|
|
fallbackChain: ['en'] // missing keys in 'es' fall back to 'en'
|
|
})
|
|
}
|
|
});`;
|
|
|
|
const appInjection = `// src/lib/i18n/schema.ts
|
|
import { p, type LangNode } from '$langs';
|
|
|
|
export const schema = {
|
|
common: {
|
|
ok: { es: 'Aceptar', en: 'OK' },
|
|
cancel: { es: 'Cancelar', en: 'Cancel' }
|
|
},
|
|
user: {
|
|
greeting: (params: { name: string }) => ({
|
|
es: 'Hola {{name}}',
|
|
en: 'Hello {{name}}'
|
|
}),
|
|
notifications: p({
|
|
es: { one: '{{count}} aviso', other: '{{count}} avisos' },
|
|
en: { one: '{{count}} notification', other: '{{count}} notifications' }
|
|
})
|
|
}
|
|
} satisfies LangNode;
|
|
|
|
export type AppLangSchema = typeof schema;
|
|
|
|
// src/lib/app.ts
|
|
import { createActiveApp } from '$active-app';
|
|
import { defineActiveLangs } from '$active-app/services';
|
|
import { schema } from '$lib/i18n/schema';
|
|
|
|
export const App = createActiveApp({
|
|
prefs: {
|
|
capabilities,
|
|
environment,
|
|
intent: { language: 'es' }
|
|
},
|
|
services: {
|
|
langs: defineActiveLangs({ schema, defaultLocale: 'es', fallbackChain: ['en'] })
|
|
}
|
|
});`;
|
|
|
|
const directCreation = `// Non-Svelte / server-safe pure engine.
|
|
import { createEngineLangs } from '$langs';
|
|
|
|
const Engine = createEngineLangs(schema, 'es', ['en']);
|
|
Engine.t('common.ok', undefined, 'es-MX');
|
|
|
|
// Svelte 5 active wrapper. Import from the .svelte.ts file, not from $langs.
|
|
import { createActiveLangs } from '$langs/active-langs.svelte';
|
|
|
|
const Lang = createActiveLangs(schema, 'es', ['en']);
|
|
Lang.t('common.ok');
|
|
Lang.setLocale('en');`;
|
|
|
|
const refs = `// LangRef syntax inside a translation: '#?other.key|literal fallback'
|
|
const schema = {
|
|
common: {
|
|
terms: { en: 'terms of use', es: 'condiciones de uso' },
|
|
privacy: { en: 'privacy policy', es: 'política de privacidad' }
|
|
},
|
|
legal: {
|
|
termsLabel: '#?common.terms|terms',
|
|
privacyLabel: '#?common.privacy|privacy',
|
|
missingLabel: '#?common.cookies|cookies'
|
|
}
|
|
} satisfies LangNode;
|
|
|
|
App.langs.t('legal.termsLabel'); // 'condiciones de uso'
|
|
App.langs.t('legal.missingLabel'); // 'cookies'`;
|
|
|
|
const pathFallback = `// t() resolves a schema path. The "|fallback" suffix belongs to the path string.
|
|
App.langs.t('actions.save|Save'); // schema hit → translated value
|
|
App.langs.t('actions.archive|Archive'); // missing key → 'Archive'
|
|
|
|
// Fallback text is interpolated with the same params object.
|
|
App.langs.t('user.greeting|Hello {{name}}', { name: 'Ada' });
|
|
// → 'Hello Ada' when user.greeting is missing`;
|
|
|
|
const tsRefs = `// ts() resolves a LangString directly.
|
|
App.langs.ts('Plain literal'); // 'Plain literal'
|
|
App.langs.ts({ es: 'Guardar', en: 'Save' }); // 'Guardar' when locale = es
|
|
|
|
// LangRef form: prefix "#?", then the schema path, then optional "|fallback".
|
|
App.langs.ts('#?actions.save|Save'); // resolves actions.save
|
|
App.langs.ts('#?actions.archive|Archive'); // missing ref → 'Archive'
|
|
|
|
// The real prefix is "#?", not "?#".
|
|
parseLangRef('#?terra.calendar.month|month');
|
|
// → { path: 'terra.calendar.month', fallback: 'month' }`;
|
|
|
|
const resolutionFlow = `App.langs.t('legal.termsLabel|Terms', { count: 2 });
|
|
|
|
// 1. parsePathFallback('legal.termsLabel|Terms')
|
|
// cleanPath = 'legal.termsLabel'
|
|
// pathFallback = 'Terms'
|
|
//
|
|
// 2. resolvePath(schema, 'legal.termsLabel')
|
|
// If missing: return interpolated pathFallback, or 'legal.termsLabel'.
|
|
//
|
|
// 3. If the value is a LangRef ('#?common.terms|terms'):
|
|
// parseLangRef() and resolve that target path recursively.
|
|
// Circular refs are capped by LANG_MAX_RESOLVE_DEPTH.
|
|
//
|
|
// 4. If the value is a function:
|
|
// call it with params and expect a LangRecord.
|
|
//
|
|
// 5. If the value is a LangRecord:
|
|
// resolve locale through exact -> base -> sibling -> fallbackChain -> defaultLocale.
|
|
//
|
|
// 6. Interpolate {{params}} in the final string.`;
|
|
|
|
const featureModule = `// src/routes/checkout/checkout.lang.ts
|
|
import { p, type LangNode } from '$langs';
|
|
|
|
export const checkoutLang = {
|
|
title: { es: 'Pago', en: 'Checkout' },
|
|
submit: { es: 'Pagar', en: 'Pay' },
|
|
items: p({
|
|
es: { one: '{{count}} producto', other: '{{count}} productos' },
|
|
en: { one: '{{count}} item', other: '{{count}} items' }
|
|
})
|
|
} satisfies LangNode;
|
|
|
|
export type CheckoutLangSchema = typeof checkoutLang;`;
|
|
|
|
const extendExample = `import { checkoutLang } from './checkout.lang';
|
|
|
|
// extend() mutates App.langs at runtime.
|
|
// Namespace "checkout" becomes "checkout.title", "checkout.submit", ...
|
|
App.langs.extend('checkout', checkoutLang);
|
|
|
|
App.langs.t('checkout.title'); // runtime OK through the string overload
|
|
|
|
// Existing consumers re-render because ActiveLangs tracks schema version.
|
|
const detach = App.langs.onSchemaChange(() => {
|
|
console.log('translations changed');
|
|
});`;
|
|
|
|
const registerExample = `import { checkoutLang } from './checkout.lang';
|
|
|
|
// register() returns a new typed child instance.
|
|
const CheckoutLang = App.langs.register('checkout', checkoutLang);
|
|
|
|
CheckoutLang.t('checkout.title'); // typed path on the child
|
|
CheckoutLang.t('checkout.items', { count: 3 });
|
|
|
|
// The child follows the parent locale until disposed.
|
|
App.prefs.language.set('en');
|
|
CheckoutLang.t('checkout.title'); // 'Checkout'
|
|
|
|
CheckoutLang.dispose();`;
|
|
|
|
const collisionExample = `App.langs.extend('common', {
|
|
ok: { es: 'Sí', en: 'Yes' }
|
|
});
|
|
|
|
// Runtime result: common.ok is replaced.
|
|
// DEV diagnostic: lang.extend_leaf_overwrite for "common.ok".
|
|
|
|
App.langs.extend('common.ok.extra', {
|
|
label: { es: 'Extra', en: 'Extra' }
|
|
});
|
|
|
|
// Throws: cannot extend below an existing translation leaf.`;
|
|
|
|
const lazyRoutePattern = `// +page.svelte
|
|
<script lang="ts">
|
|
import { App } from '$lib/app';
|
|
import { checkoutLang } from './checkout.lang';
|
|
|
|
// One-way lazy registration. There is no unload API.
|
|
App.langs.extend('checkout', checkoutLang);
|
|
<\/script>
|
|
|
|
<h1>{App.langs.t('checkout.title|Checkout')}</h1>`;
|
|
|
|
const typedFeaturePattern = `// Feature-local typed API.
|
|
import { checkoutLang } from './checkout.lang';
|
|
|
|
export function createCheckoutText(lang = App.langs) {
|
|
const CheckoutLang = lang.register('checkout', checkoutLang);
|
|
|
|
return {
|
|
title: () => CheckoutLang.t('checkout.title'),
|
|
submit: () => CheckoutLang.t('checkout.submit'),
|
|
items: (count: number) => CheckoutLang.t('checkout.items', { count }),
|
|
dispose: () => CheckoutLang.dispose()
|
|
};
|
|
}`;
|
|
|
|
const reactive = `<script lang="ts">
|
|
import { App } from '$lib/app';
|
|
// Reads track automatically inside templates and $derived.
|
|
<\/script>
|
|
|
|
<button onclick={() => App.prefs.language.set('en')}>EN</button>
|
|
<button onclick={() => App.prefs.language.set('es')}>ES</button>
|
|
|
|
<h1>{App.langs.t('home.title')}</h1>
|
|
<p>Locale: {App.langs.getLocale()}</p>`;
|
|
|
|
const monoLang = `// Explicit mono-lang fallback. Keys pass through verbatim.
|
|
import { createActiveMonoLangs } from '$langs/mono-langs.svelte';
|
|
|
|
const Lang = createActiveMonoLangs();
|
|
|
|
Lang.t('home.title'); // 'home.title'`;
|
|
|
|
const commonMistakes = [
|
|
{
|
|
name: 'Using ?# instead of #?',
|
|
why: 'The parser only recognizes LANG_ID_PREFIX = #?. ?# is just text and will never resolve as a reference.',
|
|
fix: 'Write #?schema.path|fallback and use parseLangRef() when documenting helpers.'
|
|
},
|
|
{
|
|
name: 'Calling t() for raw literals',
|
|
why: 't() treats the first argument as a schema path and may emit key_not_found diagnostics.',
|
|
fix: 'Use ts() for LangString values: plain strings, locale records or #? refs.'
|
|
},
|
|
{
|
|
name: 'Relying on regional sibling fallback',
|
|
why: 'A bare locale can match the first sibling in insertion order when no bare key exists; that is fragile and emits dev diagnostics when ambiguous.',
|
|
fix: 'Add a canonical bare locale entry such as es/en, then override only regional differences.'
|
|
},
|
|
{
|
|
name: 'Putting HTML in translations',
|
|
why: 'lang resolves strings; it does not sanitize HTML.',
|
|
fix: 'Keep translations as text. If HTML is unavoidable, sanitize upstream and render only trusted content.'
|
|
},
|
|
{
|
|
name: 'Using extend() as uncontrolled runtime overwrite',
|
|
why: 'Deep merges can replace leaves and change labels globally while the app is running.',
|
|
fix: 'Reserve extend() for lazy modules, namespace them clearly and watch extend_leaf_overwrite diagnostics in development.'
|
|
},
|
|
{
|
|
name: 'Expecting extend() to widen TypeScript',
|
|
why: 'extend() mutates the runtime schema but cannot change the static type of an existing variable.',
|
|
fix: 'Use register() for typed feature-local instances, or use dynamic paths with fallbacks after extend().'
|
|
},
|
|
{
|
|
name: 'Writing raw plural objects',
|
|
why: '{ en: { one, other } } is not a LangRecord because LangRecord values must be strings.',
|
|
fix: 'Use p({ en: { one, other }, es: { one, other } }) for plural leaves.'
|
|
},
|
|
{
|
|
name: 'Assuming route translations unload',
|
|
why: 'extend() has no removal API; injected namespaces remain until the Lang root is disposed.',
|
|
fix: 'Use unique stable namespaces for lazy modules, or create/dispose a register() child for feature-local text.'
|
|
},
|
|
{
|
|
name: 'Expecting mono-lang to validate keys',
|
|
why: 'Mono-lang has no schema, so it cannot type-check or warn about missing translation paths in the same way.',
|
|
fix: 'Use a real schema for applications that depend on i18n correctness.'
|
|
},
|
|
{
|
|
name: 'Expecting App.langs without declaring lang',
|
|
why: 'The current App only exposes services that are declared in the service schema.',
|
|
fix: 'Add langs: defineActiveLangs({ schema }) under services; configure core prefs with createActiveApp({ prefs }) when language intent needs app-specific capabilities.'
|
|
}
|
|
] as const;
|
|
|
|
const aiAgentRows = [
|
|
{
|
|
step: 'Public exports',
|
|
where: 'src/arts/langs/index.ts',
|
|
rule: 'Keep the barrel runes-free. Active factories live in active-langs.svelte.ts and mono-langs.svelte.ts.'
|
|
},
|
|
{
|
|
step: 'Reference syntax',
|
|
where: 'parseLangRef() and parsePathFallback()',
|
|
rule: 'The real LangRef prefix is #?. Do not document or implement ?#.'
|
|
},
|
|
{
|
|
step: 'Resolution rules',
|
|
where: 'resolveLocaleInRecord(), resolvePath(), interpolation helpers',
|
|
rule: 'Preserve BCP 47 fallback order, path fallback behavior and plural resolution.'
|
|
},
|
|
{
|
|
step: 'Diagnostics',
|
|
where: 'src/arts/langs/consts.ts and $libs/logger.Logger',
|
|
rule: 'Use the shared Logger contract and constants for diagnostics; no local logger shapes or hard-coded messages.'
|
|
},
|
|
{
|
|
step: 'Tests',
|
|
where: 'src/arts/langs/test/langs.test.ts and /test/lang',
|
|
rule: 'Cover schema lookup, locale fallbacks, plurals, LangRefs and mono-lang behavior.'
|
|
}
|
|
] as const;
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Lang ($langs) — Active</title>
|
|
</svelte:head>
|
|
|
|
<article class="article">
|
|
<ModuleHeader
|
|
section="I18n & Format"
|
|
title="Lang"
|
|
alias="$langs"
|
|
summary="Type-safe translations: BCP 47 resolution, plurals via CLDR rules, references between keys, and JSON round-trip for tooling."
|
|
factories={['createActiveLangs', 'createActiveMonoLangs', 'createEngineLangs']}
|
|
layer="ActiveLangs / EngineLangs"
|
|
/>
|
|
|
|
<h2>Overview</h2>
|
|
<p>
|
|
<code>lang</code> is the translation runtime. It exposes a typed <code>t()</code>
|
|
function over a tree of translations, resolves <strong>BCP 47</strong> tags through a
|
|
fallback chain, supports <strong>CLDR plural rules</strong> per locale, and lets
|
|
translation strings reference each other through a small <code>#?key|fallback</code>
|
|
syntax. Inside <code>$active-app</code>, it is an opt-in service declared with
|
|
<code>defineActiveLangs()</code>.
|
|
</p>
|
|
|
|
<h2>Mental model</h2>
|
|
<p>
|
|
<code>lang</code> has two layers. <code>EngineLangs</code> is the pure resolver: every
|
|
call receives the locale explicitly. <code>ActiveLangs</code> wraps that engine with a
|
|
Svelte <code>$state</code> locale, so reads inside templates, <code>$derived</code> and
|
|
<code>$effect</code> update when <code>setLocale()</code> changes the current locale.
|
|
<code>App.langs</code> is the application instance wired by <code>$active-app/services</code>.
|
|
The factory follows core <code>App.prefs.language.get()</code>; direct
|
|
<code>setLocale()</code> calls still work, but the next <code>App.prefs</code> change
|
|
becomes authoritative again.
|
|
</p>
|
|
<CodeBlock code={mentalModel} lang="ts" title="Engine vs Active vs App" />
|
|
|
|
<h2>Define the schema</h2>
|
|
<p>
|
|
Translations live in a literal object that the type system reads to derive the typed
|
|
<code>t()</code>:
|
|
</p>
|
|
<CodeBlock code={schemaExample} lang="ts" title="src/lib/i18n/schema.ts" />
|
|
|
|
<h2>Creation and injection</h2>
|
|
<p>
|
|
The normal application path is to declare <code>lang</code> in
|
|
<code>createActiveApp()</code> with <code>defineActiveLangs()</code>. User language intent
|
|
flows through core <code>App.prefs</code> into
|
|
<code>App.langs</code>. If you are outside App, create the pure engine with
|
|
<code>createEngineLangs()</code> or the Svelte wrapper with <code>createActiveLangs()</code>.
|
|
</p>
|
|
<CodeBlock code={appInjection} lang="ts" title="Initial schema through App" />
|
|
<CodeBlock code={directCreation} lang="ts" title="Direct creation" />
|
|
|
|
<h2>Use it</h2>
|
|
<CodeBlock code={usage} lang="ts" />
|
|
<p>
|
|
Reading <code>App.langs.t(...)</code> inside a template or <code>$derived</code> tracks
|
|
the current locale; switching locales triggers a re-render of every consumer.
|
|
</p>
|
|
<CodeBlock code={reactive} lang="svelte" />
|
|
|
|
<h2>Resolution dynamics</h2>
|
|
<p>
|
|
Most mistakes come from not knowing what <code>t()</code> actually does. It first parses
|
|
a path-level fallback, then looks up the schema path, then resolves references/functions/
|
|
records, then resolves locale, and only at the end interpolates params. Missing keys,
|
|
ambiguous locale siblings and reference fallbacks are diagnostic events in development.
|
|
</p>
|
|
<CodeBlock code={resolutionFlow} lang="ts" title="What t() does internally" />
|
|
|
|
<h2>API reference</h2>
|
|
<p>
|
|
The <code>$langs</code> barrel exports the engine and pure helpers. The active factories
|
|
live in their runes files: <code>$langs/active-langs.svelte</code> and
|
|
<code>$langs/mono-langs.svelte</code>, so the main barrel stays runes-free.
|
|
</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Member</th>
|
|
<th>Layer</th>
|
|
<th>Purpose</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>createEngineLangs(schema, defaultLocale?, fallbackChain?)</code></td><td>engine</td><td>Creates the pure translation engine. Calls require an explicit locale.</td></tr>
|
|
<tr><td><code>createActiveLangs(schema, defaultLocale?, fallbackChain?)</code></td><td>active</td><td>Creates a reactive language root with internal locale state.</td></tr>
|
|
<tr><td><code>createActiveMonoLangs(options?)</code></td><td>active</td><td>Creates the mono-lang fallback used by App when no schema is configured.</td></tr>
|
|
<tr><td><code>t(path, params?, locale?)</code></td><td>both</td><td>Resolve a schema path. Engine requires locale; Active defaults to current locale.</td></tr>
|
|
<tr><td><code>ts(value, locale?)</code></td><td>both</td><td>Resolve a <code>LangString</code>: literal, record or <code>#?path|fallback</code> reference.</td></tr>
|
|
<tr><td><code>getLocale()</code></td><td>active</td><td>Read current locale.</td></tr>
|
|
<tr><td><code>setLocale(locale)</code></td><td>active</td><td>Change locale and notify active consumers.</td></tr>
|
|
<tr><td><code>onLocaleChange(fn)</code></td><td>active</td><td>Subscribe to locale changes.</td></tr>
|
|
<tr><td><code>extend(namespace, module)</code></td><td>both</td><td>Merge a module into the current schema namespace.</td></tr>
|
|
<tr><td><code>register(namespace, module)</code></td><td>both</td><td>Return a type-widened engine after registering a namespace.</td></tr>
|
|
<tr><td><code>onSchemaChange(fn)</code></td><td>both</td><td>Subscribe to schema extension/registration changes.</td></tr>
|
|
<tr><td><code>setLogger(logger)</code></td><td>both</td><td>Inject or replace the shared Logger contract.</td></tr>
|
|
<tr><td><code>getDefaultLocale()</code></td><td>both</td><td>Read configured default locale.</td></tr>
|
|
<tr><td><code>getFallbackChain()</code></td><td>both</td><td>Read configured fallback chain.</td></tr>
|
|
<tr><td><code>dispose()</code></td><td>active</td><td>Release active listeners.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Helper</th>
|
|
<th>Purpose</th>
|
|
<th>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>parseLangRef('#?path|fallback')</code></td><td>Parse a LangRef.</td><td>Prefix is <code>#?</code>.</td></tr>
|
|
<tr><td><code>parsePathFallback('path|fallback')</code></td><td>Parse path-level fallback for <code>t()</code>.</td><td>No <code>#?</code> prefix.</td></tr>
|
|
<tr><td><code>resolvePath(schema, path)</code></td><td>Read a raw node from a schema path.</td><td>Used internally and in tests.</td></tr>
|
|
<tr><td><code>resolveLocaleInRecord(record, locale)</code></td><td>Resolve BCP 47 fallback in one record.</td><td>Exact, base, regional sibling, fallback chain.</td></tr>
|
|
<tr><td><code>interpolateTemplate(text, params)</code></td><td>Apply template params.</td><td>Used by fallback literals too.</td></tr>
|
|
<tr><td><code>langNodeToJSON() / JSONToLangNode()</code></td><td>Translation tooling round-trip.</td><td>Useful for external translation pipelines.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Locale resolution</h2>
|
|
<p>
|
|
<code>setLocale('es-MX')</code> picks the most specific match available in the schema,
|
|
walking <code>es-MX → es → fallbackChain → defaultLocale</code>. Unknown tags resolve to
|
|
<code>defaultLocale</code> with a logger warning.
|
|
</p>
|
|
<CodeBlock code={fallback} lang="ts" />
|
|
|
|
<Callout variant="tip" title="Ambiguous siblings">
|
|
<p>
|
|
In dev, if <code>setLocale('en')</code> matches multiple sibling translations
|
|
(e.g. you accidentally have both <code>en</code> and <code>en-US</code> at the same
|
|
level), <code>lang</code> emits an <code>AMBIGUOUS_SIBLING_MATCH</code> diagnostic.
|
|
Production builds short-circuit silently to keep the path hot.
|
|
</p>
|
|
</Callout>
|
|
|
|
<h2>Plurals</h2>
|
|
<p>
|
|
Use a plural object instead of a string. <code>lang</code> selects the CLDR category for
|
|
the active locale (<code>zero</code>, <code>one</code>, <code>two</code>, <code>few</code>,
|
|
<code>many</code>, <code>other</code>):
|
|
</p>
|
|
<CodeBlock
|
|
lang="ts"
|
|
code={`schema.cart.items = {
|
|
en: { one: '1 item', other: '#? items' },
|
|
ar: {
|
|
zero: 'لا توجد عناصر',
|
|
one: 'عنصر واحد',
|
|
two: 'عنصران',
|
|
few: '#? عناصر',
|
|
many: '#? عنصراً',
|
|
other: '#? عنصر'
|
|
}
|
|
};
|
|
|
|
App.langs.t('cart.items', { count: 0 }); // 'لا توجد عناصر' (ar)`}
|
|
/>
|
|
|
|
<h2>References</h2>
|
|
<p>
|
|
The real reference prefix is <code>#?</code>. A <code>LangRef</code> looks like
|
|
<code>#?path.to.key|literal fallback</code>: first the schema path, then an optional
|
|
<code>|</code> fallback used only when the referenced key is missing.
|
|
</p>
|
|
<CodeBlock code={refs} lang="ts" />
|
|
|
|
<h2>Path fallback vs LangRef fallback</h2>
|
|
<p>
|
|
There are two related but different forms. <code>t()</code> receives a schema path, so
|
|
<code>path|fallback</code> means “try this path, otherwise render this literal”.
|
|
<code>ts()</code> receives a <code>LangString</code>, so <code>#?path|fallback</code>
|
|
means “resolve this reference, otherwise render this literal”.
|
|
</p>
|
|
<CodeBlock code={pathFallback} lang="ts" title="t(path|fallback)" />
|
|
<CodeBlock code={tsRefs} lang="ts" title="ts('#?path|fallback')" />
|
|
|
|
<h2>Translation modules</h2>
|
|
<p>
|
|
Large applications should not keep every route translation in one huge object. Create
|
|
feature-local modules that satisfy <code>LangNode</code>, then inject them into
|
|
<code>App.langs</code> with a namespace. A namespace is part of the final path:
|
|
<code>extend('checkout', checkoutLang)</code> creates <code>checkout.title</code>,
|
|
<code>checkout.submit</code> and so on.
|
|
</p>
|
|
<CodeBlock code={featureModule} lang="ts" title="Feature translation module" />
|
|
|
|
<h2>Injecting translations with extend()</h2>
|
|
<p>
|
|
<code>extend()</code> mutates the current engine schema and notifies active consumers.
|
|
Use it for lazy route/module translations when you want the existing
|
|
<code>App.langs</code> instance to start resolving new paths. Because it mutates an
|
|
existing value, TypeScript cannot widen the type of <code>App.langs</code> in place; use
|
|
path fallbacks or the dynamic string overload at call sites, or use <code>register()</code>
|
|
when you need a typed child instance.
|
|
</p>
|
|
<CodeBlock code={extendExample} lang="ts" title="extend() vs register()" />
|
|
|
|
<Callout variant="warn" title="extend() is one-way">
|
|
<p>
|
|
There is no unload/remove API today. Once a module is extended, its translations stay in
|
|
the engine until the Lang root is disposed. That is fine for route-level lazy loading in
|
|
a single-page app, but it means <code>extend()</code> should use unique namespaces and
|
|
should not be used for temporary per-component text.
|
|
</p>
|
|
</Callout>
|
|
|
|
<h2>Typed child instances with register()</h2>
|
|
<p>
|
|
<code>register()</code> returns a new Lang instance whose type includes the registered
|
|
namespace. In <code>ActiveLangs</code>, that child follows the parent locale until the child
|
|
is disposed. This is the best pattern for feature packages that want typed local paths
|
|
without mutating the static type of the global <code>App.langs</code> variable.
|
|
</p>
|
|
<CodeBlock code={registerExample} lang="ts" title="Typed feature lang" />
|
|
<CodeBlock code={typedFeaturePattern} lang="ts" title="Feature-local wrapper" />
|
|
|
|
<h2>Lazy route pattern</h2>
|
|
<p>
|
|
For a route or feature module, call <code>extend()</code> before rendering text from that
|
|
namespace. Existing template reads re-run because <code>ActiveLangs</code> increments an
|
|
internal schema version on every schema change.
|
|
</p>
|
|
<CodeBlock code={lazyRoutePattern} lang="svelte" />
|
|
|
|
<h2>Collisions and overwrite diagnostics</h2>
|
|
<p>
|
|
<code>extend()</code> deep-merges branches. Adding a new branch is silent. Replacing an
|
|
existing leaf is allowed but emits <code>lang.extend_leaf_overwrite</code> in development.
|
|
Extending below an existing leaf throws, because a translation leaf cannot also become a
|
|
namespace.
|
|
</p>
|
|
<CodeBlock code={collisionExample} lang="ts" />
|
|
|
|
<h2>Mono-lang fallback</h2>
|
|
<p>
|
|
<code>createActiveMonoLangs()</code> is still available as an explicit passthrough runtime
|
|
for apps or tests that want readable keys without a translation schema. App no longer
|
|
creates it implicitly: if <code>lang</code> is not declared in <code>services</code>,
|
|
<code>App.langs</code> is not part of the typed App surface.
|
|
</p>
|
|
<CodeBlock code={monoLang} lang="ts" />
|
|
|
|
<Callout variant="warn" title="Mono-lang is type-loose">
|
|
<p>
|
|
Do not rely on auto-completion for translation keys when you use mono-lang - there is
|
|
no schema to derive types from. For typed apps, declare <code>defineActiveLangs()</code>
|
|
with a real schema.
|
|
</p>
|
|
</Callout>
|
|
|
|
<h2>JSON round-trip</h2>
|
|
<p>
|
|
<code>$langs/json</code> exposes <code>toJson(schema)</code> and <code>fromJson(json)</code>
|
|
so you can extract / reimport translations into external tooling (Phrase, Locize, glossary
|
|
repos) without losing structure or plural objects.
|
|
</p>
|
|
|
|
<h2>Limits</h2>
|
|
<ul>
|
|
<li>No runtime parser for free-form MessageFormat 2 patterns yet. For 1.0, model text with plural objects + refs and keep any MF2 parser as a dedicated adapter above the core resolver.</li>
|
|
<li>No async loading by default — translations are part of the bundle. For lazy locales, build a custom <code>EngineLangs</code> with on-demand <code>setSchema()</code>.</li>
|
|
<li>HTML inside translations is not sanitised. Render with <code>{`{@html …}`}</code> only when you trust the source.</li>
|
|
</ul>
|
|
|
|
<h2>Common mistakes</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Mistake</th>
|
|
<th>Why it hurts</th>
|
|
<th>Correct pattern</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each commonMistakes as mistake (mistake.name)}
|
|
<tr>
|
|
<td><code>{mistake.name}</code></td>
|
|
<td>{mistake.why}</td>
|
|
<td>{mistake.fix}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Testing</h2>
|
|
<p>
|
|
Suite at <code>src/arts/langs/test/langs.test.ts</code> covers schema lookup, fallback chain,
|
|
plural categories per locale, refs and mono-lang. Try the interactive page at
|
|
<a href="/test/lang">/test/lang</a> for live locale switching.
|
|
</p>
|
|
|
|
<AiAgentsBox
|
|
intro="Before editing $langs, verify the real parser and resolver helpers instead of inferring syntax from examples."
|
|
rows={aiAgentRows}
|
|
/>
|
|
|
|
<PageNav />
|
|
</article>
|