Internationalization (@avenx/i18n)
An Avenx application that speaks more than one language needs two things: a place to keep translations, and a way for the interface to follow the language when it changes.
@avenx/i18n is the official plugin that provides both. It is an extension of
Avenx’s own reactivity rather than a translation library with an Avenx wrapper
around it. The active locale lives in an Avenx
Bridge, and t() reads that bridge — so calling
t() while a component renders is subscribing to the language, through
exactly the dependency tracking that answers a
counter increment.
That has a consequence worth stating up front: changing the locale does not
re-render your application. It re-renders the components that translate,
because those are the ones that read the locale, and Avenx’s DOM patcher then
touches only the text nodes whose content actually changed. A component that
never calls t() is never a dependent and is never updated.
Installation
Section titled “Installation”npm install @avenx/i18nBasic setup
Section titled “Basic setup”Install the plugin in your application entry file, before anything renders:
import { AvenxApp } from 'avenx-core/runtime';import { avenxI18n } from '@avenx/i18n';
const app = new AvenxApp({ target: '#app' });
app.use(avenxI18n, { locale: 'en', fallbackLocale: 'en', messages: { en: { home: { title: 'Welcome', description: 'Welcome to Avenx' }, navigation: { settings: 'Settings' }, }, de: { home: { title: 'Willkommen', description: 'Willkommen bei Avenx' }, navigation: { settings: 'Einstellungen' }, }, },});Installing publishes seven names into every component’s template scope:
| Name | What it is |
|---|---|
t(key, params?) |
Translate. Returns plain text. |
tHtml(key, params?) |
Translate a message containing markup. Sanitized. |
n(value, options?) |
Format a number in the active locale. |
d(value, options?) |
Format a date in the active locale. |
rel(value, unit, options?) |
Format a relative time in the active locale. |
locale |
The locale handle: current, set(), available, … |
$i18n |
The whole instance, for anything the six above do not cover. |
They arrive through a global mixin, which
Avenx merges behind a component’s own declarations — so a component that
declares its own t or n keeps it. The plugin never shadows your code.
Telling the compiler about them
Section titled “Telling the compiler about them”The Avenx compiler validates template identifiers against what it can read in
your source: state, computed values, actions, resources and imported bridges.
A plugin installed at runtime is invisible to it, so a template calling t()
is reported as an undeclared reference (AVX_W03).
Declare what the plugin publishes in avenx.config.json:
{ "templateGlobals": ["t", "tHtml", "n", "d", "rel", "locale", "$i18n"]}Every other identifier in your templates is still checked, which is why this
exists rather than switching AVX_W03 off. The list is also exported as
TEMPLATE_GLOBALS if you would rather generate it.
Translation resources
Section titled “Translation resources”Messages are nested objects, keyed by locale tag:
{ en: { home: { title: 'Welcome', description: 'Welcome to Avenx', }, errors: { network: { timeout: 'The request timed out' }, }, },}A lookup asks for the dotted path — t('errors.network.timeout') — and nests
as deeply as you like.
Resources are flattened once, when they are registered, into a map keyed by that dotted path. A lookup is then a single map read regardless of how deep the file goes, and the messages themselves are deliberately kept outside Avenx’s reactive state: they are read constantly and written almost never, so making tens of thousands of strings reactive would buy tracking for changes that do not happen. What is reactive is the active locale and a revision counter, which is what your components actually depend on.
Register more at any time:
app.$i18n.addMessages('en', { checkout: { title: 'Checkout' } });Components that translate re-render; nothing else does.
Using t()
Section titled “Using t()”<h1>{{ t('home.title') }}</h1><a href="/settings">{{ t('navigation.settings') }}</a>t() returns a plain string, so Avenx escapes it like any other template
expression. It also works anywhere else in a component — an action, a computed
value, a lifecycle hook:
<action name="notify"> this.$emit('toast', t('errors.network.timeout'));</action>Switching locale
Section titled “Switching locale”<button @click="locale.set('de')">Deutsch</button>That is the whole integration. Every component displaying a translated string updates; nothing else does.
locale.set() returns a promise resolving to the locale that ended up active,
which matters when the locale has to be loaded first.
It never rejects: an invalid tag, or a loader that fails, leaves the
application on the language it had and reports the problem.
The handle also exposes:
locale.current // 'de-CH'locale.available // every locale with messages or a loaderlocale.fallback // the configured fallback localeslocale.chain // ['de-CH', 'de', 'en'] — what a lookup walkslocale.loading // true while a lazy locale is in flight (reactive)locale.is('de') // true for both 'de' and 'de-CH'locale.load('fr') // fetch a locale without switching to itAll of those are reactive reads, so {{ locale.current }} in a template
updates on its own.
Reacting to a change
Section titled “Reacting to a change”app.$i18n.on('change', ({ locale, previous }) => { document.documentElement.lang = locale;});This is the bridge’s own on(), so a subscription opened inside a component
lifecycle hook is released automatically when that component unmounts.
Interpolation
Section titled “Interpolation”A message names its variables inline:
{ welcome: { user: 'Hello, {name}! You have {unread} messages.' } }<p>{{ t('welcome.user', { name: user.firstName, unread: inbox.count }) }}</p>A dotted placeholder reads a nested value, so {user.name} can be filled from
{ user: { name: 'Ada' } }.
Substitution is string concatenation and nothing else — see
Security below. Text between braces that is not a well-formed
placeholder is literal, so a message may say { this } and mean it.
A placeholder with no matching parameter is left standing: {name} renders
as {name}, and one warning is logged. A blank in its place would look like a
finished sentence with a word missing, which is far harder to notice in a
screenshot from a user.
Pluralization
Section titled “Pluralization”Which form a count selects is a property of the language, not of your
application: English has two, Polish has four, Japanese has one. Intl.PluralRules
decides, so nothing in the plugin knows any language’s rules.
Write a message as the categories the language uses:
{ en: { cart: { items: { one: '{count} item', other: '{count} items' } } }, pl: { cart: { items: { one: '{count} plik', few: '{count} pliki', many: '{count} plików', other: '{count} pliku', }, }, },}<p>{{ t('cart.items', { count: cart.length }) }}</p>The categories are zero, one, two, few, many and other. other is
the one every language falls back to, so always write it.
zero is the one category that is not purely CLDR. Most languages never select
it, but “your basket is empty” is a sentence applications want to write, so a
declared zero wins for an exact count of 0:
{ cart: { items: { zero: 'Your basket is empty', one: '{count} item', other: '{count} items' } } }Languages that do have a CLDR zero category — Latvian, Welsh, Arabic — still
get it from the rules for every other count that selects it.
A plural message called without a numeric count falls back to other and
logs a warning. A locale that selects a category the translator did not write
falls back to other and logs which category is missing.
Fallback locales
Section titled “Fallback locales”A key that the active locale does not define is looked up along a chain:
de-CH → de → enThe active locale first, then each of its ancestors, then each configured
fallback locale and its ancestors. fallbackLocale takes one tag or several:
app.use(avenxI18n, { locale: 'de-CH', fallbackLocale: ['de-DE', 'en'], messages,});Pass null to run without a fallback locale. setFallbackLocale() replaces
the chain at runtime.
Fallback is per key, not per locale: a de-CH catalogue that defines one
string gets that string from itself and everything else from de.
Regional locales
Section titled “Regional locales”Tags are canonicalized wherever they arrive — from configuration, from
locale.set(), from storage — through Intl.getCanonicalLocales. de_ch,
DE-ch and de-CH all name one catalogue, so a tag pasted from a translation
management system or a browser’s navigator.language does the right thing.
Expansion walks the language-script-region core: zh-Hant-TW yields
zh-Hant-TW, zh-Hant, zh. Unicode extension subtags are not part of that
hierarchy — the ancestor of de-u-nu-latn is de.
A tag that is not a locale at all is refused: locale.set('nonsense') leaves
the application where it was and logs a warning.
Formatting
Section titled “Formatting”The platform already knows how every locale writes a number, a date and a
duration. n(), d() and rel() add the active locale, named presets and
caching on top of Intl, and no formatting logic of their own:
<p>{{ n(order.total, 'currency') }}</p><p>{{ d(order.placedAt, 'full') }}</p><p>{{ rel(-2, 'day') }}</p>Define presets once, at install:
app.use(avenxI18n, { formats: { number: { currency: { style: 'currency', currency: 'CHF' } }, date: { full: { dateStyle: 'long', timeStyle: 'short' } }, relative: { plain: { numeric: 'always' } }, }, messages,});Or pass Intl options directly: n(1234.5, { minimumFractionDigits: 2 }).
These read the active locale, so a formatted value follows a language switch
exactly as a translated one does. d() accepts a Date, a timestamp or a
date string. A value that cannot be formatted is returned as text and reported
rather than throwing.
Lazy loading locales
Section titled “Lazy loading locales”A locale can be fetched the first time it is needed rather than shipped in the bundle:
app.use(avenxI18n, { locale: 'en', messages: { en: englishMessages }, loaders: { fr: () => import('./translations/fr.js'), it: () => import('./translations/it.js'), },});A loader returns the locale’s messages, or a promise for them. A dynamic
import() works as it stands: the module’s default export is used.
Locales with a loader appear in locale.available before they are downloaded,
so a language switcher lists them from the start. locale.set('fr') loads and
then switches, and locale.loading is reactive while that happens:
<@for tag in locale.available key="tag"> <button data-locale="{{ tag }}" @click="locale.set(event.target.dataset.locale)">{{ tag }}</button></@for><span data-ax-show="locale.loading">…</span>If a load fails, the application stays on the locale it had — dropping a
user into a language with no messages would be worse than not switching — and
locale.set() resolves with the unchanged locale. Loaders can also be
registered later with $i18n.addLoader(tag, loader), and a locale can be
fetched without switching with locale.load(tag).
Remembering the chosen locale
Section titled “Remembering the chosen locale”Pass a storage adapter and the plugin writes the chosen locale when it changes and restores it on the next visit:
app.use(avenxI18n, { storage: window.localStorage, storageKey: 'shop:locale', messages,});An adapter is anything with getItem, setItem and removeItem — the shape
the platform defines for Web Storage. That is deliberately the same interface
@avenx/persistence adapters implement, so the two
plugins interoperate without either importing the other:
import { browserLocalStorage } from '@avenx/persistence';
app.use(avenxI18n, { storage: browserLocalStorage(), messages });A stored tag is only adopted when the application can actually render it: a
locale removed in a later release, or a corrupted value, is ignored with a
warning rather than stranding a returning visitor in a language the bundle no
longer has. A regional variant of a language that is available — de-AT when
you ship de — is adopted, because the fallback chain covers it.
Storage failures never reach the application. A browser that refuses to store anything costs the “remembers your language” feature and nothing else.
Missing translations
Section titled “Missing translations”A missing key renders as the key itself:
home.missing.titleand logs one warning naming the key and the locale — once, not once per render. A gap that renders as an empty string is a gap nobody reports; a dotted key names exactly what to go and add.
Choose your own placeholder with missing:
app.use(avenxI18n, { missing: (key, locale) => (import.meta.env.DEV ? `⟨${key}⟩` : ''), messages,});Use $i18n.has(key) to ask whether a key resolves without rendering it or
reporting a miss.
Handling failures
Section titled “Handling failures”Every runtime failure is reported through the Avenx logger and, when you register one, through your own callback:
app.use(avenxI18n, { messages, onError: ({ phase, key, locale, message, error }) => { telemetry.warn('i18n', { phase, key, locale, message }); },});phase is one of missing, missing-locale, malformed, interpolation,
plural, format, load, locale, key or storage.
Nothing in that list throws into your application. A missing key, an absent interpolation value, a malformed resource, a failed download, an unusable storage backend and an invalid locale all degrade to something renderable and carry on — which is the only acceptable behaviour for a subsystem that runs on every render.
Configuration mistakes are the opposite: createI18n() and app.use() throw
on a bad locale tag, a resource that is not an object, a loader that is not a
function, an unknown format group or an incomplete storage adapter, so a typo
surfaces at startup rather than as a sentence in the wrong language three
navigations later.
Security
Section titled “Security”A translation is text. t() returns a plain string, so Avenx escapes it
like any other template expression. A message containing <script> renders as
characters, whether the markup came from the translator, from a translation
service, or from a value you interpolated.
Interpolation cannot execute anything. A placeholder is a name, not an
expression. There is no eval, no new Function, no generated code and no
scope a message can reach: {1 + 1} renders as {1 + 1}, a function passed as
a parameter is stringified rather than called, and a dotted placeholder walks
own properties only — a message cannot reach constructor or __proto__.
Markup requires asking for it. tHtml() is the only way a translation
reaches the DOM as markup, and it does two things before that happens:
- every interpolated value is HTML-escaped, so a parameter can contribute text to the sentence and never markup;
- the resulting message is passed through Avenx’s
Sanitizer, which strips scripts, event handler attributes and everything else outside its policy.
{ order: { terms: 'By ordering you accept our <a href="/terms">terms of sale</a>.' } }<p>{{ tHtml('order.terms') }}</p>tHtml() returns SafeHtml, which is why ordinary {{ }} inserts it as
markup. Use it only for messages your translators write. It is not a way to
render user-generated content.
Limitations
Section titled “Limitations”- Message keys are not type-checked.
t()takes astring. A catalogue’s keys are application data, and a lazily loaded locale’s keys are not known at build time at all, so there is no literal-union type for them. Use$i18n.has()where you need to check at runtime. - Messages are not extracted from source. The plugin does not scan your
templates for
t()calls to build a key list. Resources are authored as objects. - A catalogue change re-renders everything that translates. Adding messages
or loading a locale bumps one revision counter, so every component that
called
t()updates — not just the ones showing an affected key. This is the right trade for something that happens on a language switch rather than on every frame; a locale change itself is exactly as precise. - No ordinal plurals or ranges.
Intl.PluralRulesordinal selection andIntl.NumberFormat.formatRangeare not exposed. Reach forIntldirectly. - No message-format syntax. Placeholders are names; there is no nested
select, no inline plural syntax and no formatting directives inside a
message. Format the value with
n()ord()and interpolate the result.
A complete example
Section titled “A complete example”import { AvenxApp } from 'avenx-core/runtime';import { avenxI18n } from '@avenx/i18n';import LanguageSwitcher from './components/language-switcher/language-switcher.component.js';
const app = new AvenxApp({ target: '#app' });
app.use(avenxI18n, { locale: navigator.language, fallbackLocale: 'en',
messages: { en: { app: { title: 'Avenx Storefront' }, nav: { language: 'Language' }, order: { greeting: 'Hello, {name}!', items: { zero: 'Your basket is empty.', one: 'You have {count} item in your basket.', other: 'You have {count} items in your basket.', }, total: 'Total', placed: 'Ordered {when}', terms: 'By ordering you accept our <a href="#terms">terms of sale</a>.', }, }, de: { app: { title: 'Avenx Warenhaus' }, nav: { language: 'Sprache' }, order: { greeting: 'Hallo, {name}!', items: { zero: 'Ihr Warenkorb ist leer.', one: 'Sie haben {count} Artikel im Warenkorb.', other: 'Sie haben {count} Artikel im Warenkorb.', }, total: 'Gesamt', placed: 'Bestellt {when}', terms: 'Mit der Bestellung akzeptieren Sie unsere <a href="#terms">Verkaufsbedingungen</a>.', }, }, 'de-CH': { app: { title: 'Avenx Warehuus' }, nav: { language: 'Sproch' } }, },
loaders: { fr: () => import('./translations/fr.js'), },
formats: { number: { currency: { style: 'currency', currency: 'CHF' } }, date: { full: { dateStyle: 'long', timeStyle: 'short' } }, },
storage: window.localStorage, storageKey: 'shop:locale',
onError: ({ phase, key, locale }) => console.warn('[i18n]', phase, key, locale),});
app.register('LanguageSwitcher', LanguageSwitcher);
app.initRouter({ '/': 'Storefront', '#/': 'Storefront' });<div> <span>{{ t('nav.language') }}</span>
<@for tag in locale.available key="tag"> <button type="button" data-locale="{{ tag }}" @click="locale.set(event.target.dataset.locale)" >{{ tag }}</button> </@for>
<span data-ax-show="locale.loading">…</span></div>import basket from '../global/basket.bridge.js';import LanguageSwitcher from '../components/language-switcher/language-switcher.component.js';
<state customer="Ada" />
<div> <h1>{{ t('app.title') }}</h1>
<LanguageSwitcher />
<section> <p>{{ t('order.greeting', { name: customer }) }}</p> <p>{{ t('order.items', { count: basket.items }) }}</p> <p>{{ t('order.total') }}: <strong>{{ n(basket.total, 'currency') }}</strong></p> <p>{{ t('order.placed', { when: rel(basket.placedDaysAgo, 'day') }) }}</p> <p>{{ tHtml('order.terms') }}</p> </section></div>{ "templateGlobals": ["t", "tHtml", "n", "d", "rel", "locale", "$i18n"]}The basket bridge knows nothing about i18n — it counts items and totals prices. Translation is a rendering concern, and the page is where it belongs.
A runnable version of this application, with four languages and two of them
lazily loaded, is in
plugins/avenx-i18n/example.
Without the plugin installer
Section titled “Without the plugin installer”createI18n() builds a translator without touching an application, which is
useful in tests and wherever you want the instance before the app exists:
import { createI18n } from '@avenx/i18n';
const i18n = createI18n({ locale: 'en', messages });i18n.t('home.title'); // 'Welcome'
app.use(avenxI18n, { i18n });The instance also exposes $bridge, the Avenx bridge holding the locale, for
devtools or for app.registerBridge('i18n', i18n.$bridge).