Skip to content

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.


Terminal window
npm install @avenx/i18n

Install the plugin in your application entry file, before anything renders:

src/main.app.js
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.

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.

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.

<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>
<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 loader
locale.fallback // the configured fallback locales
locale.chain // ['de-CH', 'de', 'en'] — what a lookup walks
locale.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 it

All of those are reactive reads, so {{ locale.current }} in a template updates on its own.

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.

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.

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.

A key that the active locale does not define is looked up along a chain:

de-CH → de → en

The 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.

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.

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.

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

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.

A missing key renders as the key itself:

home.missing.title

and 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.

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.

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.

  • Message keys are not type-checked. t() takes a string. 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.PluralRules ordinal selection and Intl.NumberFormat.formatRange are not exposed. Reach for Intl directly.
  • 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() or d() and interpolate the result.
src/main.app.js
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' });
src/components/language-switcher/language-switcher.component.js
<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>
src/pages/storefront.page.js
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.

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