Skip to content

06 Localization

Roberto Ferranti edited this page Sep 29, 2026 · 8 revisions

Localization

Localization is a plain template module: one flat translations map per page, named placeholders, { one, other } plural leaves, and locale-aware date/number/bytes formatters. It works in bare ftl pages and, through the ful plugin, in ful pages.

Messages

{
    'pagination.showing': 'Page {current} of {total}',
    'pagination.next': 'Next',
    'app.added': { one: '{count} item added', other: '{count} items added' },
}
  • flat, dotted keys, with no nesting.
  • {name} placeholders interpolate the named arguments; {0}, {1}… interpolate positional ones.
  • a plural leaf is an object keyed by CLDR plural category (zero, one, two, few, many, other); other is required, the rest fall back to it. The category is selected with Intl.PluralRules over a numeric {count} argument.

Failures do not throw: a missing key returns the key itself, a missing placeholder stays literal, a plural without {count} falls back to other. Each is warned once per session on the console.

In templates

ful registers the module for you; a bare ftl page registers it by hand:

ftl.registry
    .defineModule('l10n', ftl.Localization)
    .defineOverlay({ l10n: { 'app.hello': 'Hello {who}' }, locale: 'it' })
    .configure();
<p>{{ #l10n:t('app.hello', { 'who': 'ada' }) }}</p>
<p>{{ #l10n:t('app.added', { 'count': 3 }) }}</p>
<p>{{ #l10n:date(now, { 'dateStyle': 'long' }) }}</p>
<p>{{ #l10n:number(42.5, { 'style': 'currency', 'currency': 'EUR' }) }}</p>
<p>{{ #l10n:bytes(1536) }}</p>   <!-- 1.5KiB, digits in the page locale -->

date, number take the same options as the matching Intl formatter, honoring the page locale; bytes formats with maximumFractionDigits: 2 and B/KiB/MiB/GiB suffixes, a size exactly on a threshold taking the larger unit (1024 is 1KiB).

From javascript

Localization.of(overrides?) resolves the active { l10n, locale } from the registry data (most recent overlay wins) and returns bound { t, date, number, bytes }:

const { t } = ftl.Localization.of();
console.log(t('app.added', { count: 3 }));

An explicit Localization.of({ locale: 'de' }) overrides the locale for the formatters.

The ful plugin

The language is fixed per page and baked once at configure, together with the fallback chain:

ftl.registry
    .plugin(new ful.Plugin({
        language: 'it',                       // full BCP-47 tag or primary subtag; defaults to the browser's language
        translations: {                       // flat, active-language only
            'pagination.next': 'Avanti ancora',  // reword a built-in
            'checkout.total': 'Totale: {amount}', // or add your own keys
        },
    }))
    .configure();

The overlay is built as { ...builtins.en, ...builtins[language], ...translations }, registered as the l10n overlay plus the full-tag locale. Built-ins ship for en, it, es, fr and cover every ful message (pagination, table states, select, files and filters), the full list being in ful fields.

External components ship their translations the same way: the page slices the active language out of the component's dictionary and hands it over next to its own keys.

Remote translations

Remote translations are the app's concern. Fetch them before configure():

const translations = await (await fetch(`/l10n/${language}/overrides.json`)).json();
ftl.registry.plugin(new ful.Plugin({ language, translations })).configure();

Elements are not even defined until configure() runs, so nothing upgrades, or renders, ahead of its translations. Latency-sensitive pages skip the network and server-render the map inline instead: translations: window.APP_L10N.

Bare ftl pages that fetch remotely follow the same rule: await the fetch, then defineOverlay({ l10n, locale }), then configure().

See also

Clone this wiki locally