Skip to content

06 Localization

Roberto Ferranti edited this page Nov 10, 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, plus failure, which turns what went wrong into text. 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 single plain object argument (an object literal, or one with a null prototype) is the named arguments; any other single argument, a Date or an Error among them, fills {0}. A placeholder name is letters, digits and underscores: {amount} interpolates, {user-name} stays literal.
  • 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 page 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 -->
<p>{{ #l10n:failure(error) }}</p>   <!-- the reasons of a Failure, one per line -->

The module reads l10n and locale from the data the template is bound to, the registry's overlays here (the most recent one carrying the name wins), or the data handed to a Template.* factory. It never reads them from what a render pushes on top: a row of a data-tpl-each, a response, a withOverlay(...). A row that happens to carry a field named l10n or locale still shows as data ({{ locale }}), and changes neither the messages nor the formatting. Translations are page source; rows are what arrives later.

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). failure reads a value carrying a non-empty problems array (an httpc Failure) as their reasons, one per line, a blank or missing reason reading as the failure.no-reason message; any other value with a message reads as that message, anything else as its string form, and null or undefined as the empty string. It is what the dialog, the drawer, the table and the async sections paint for a rejection, and what a toast shows for a failure.

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, failure }:

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 together with locale, the language in its canonical form: IT-it and the underscore form a server prints, it_IT, both publish it-IT. An empty language, which is what document.documentElement.lang answers on a page without the attribute, counts as none given and falls back to the browser's language, then to en. A language that is no language tag at all throws a RangeError from the Plugin constructor, where the page configures, rather than from the first number formatted in it. language is matched on its primary subtag, so it-CH reads the it built-ins, and a language with no built-ins of its own reads in english. Built-ins ship for en, it, es, fr and cover every ful message: the keys are listed below.

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.

Built-in message keys

The keys of the l10n overlay the ful plugin bakes, each overridable through translations:

Key Used by
pagination.showing, pagination.previous, pagination.next, pagination.reload, pagination.navigation ful-pagination
table.initial, table.error, table.no-data ful-table states
dropdown.empty, select.remove ful-select
files.dropzone-label, files.remove, files.unacceptable-file-type, files.max-file-size-exceeded, files.max-total-size-exceeded, files.max-files-exceeded ful-input-file
filters.op.* (one per operator), filters.sensitivity.*, filters.boolean.* ful-filter-*
info.tooltip ful-tooltip
dialog.acknowledge, dialog.close, dialog.confirm, dialog.cancel ful-dialog (dialog.confirm and dialog.cancel label the buttons of Dialog.confirm)
drawer.close ful-drawer
dialog.discard.header, dialog.discard.body, dialog.discard.confirm, dialog.discard.cancel the question guard-dismiss asks on a ful-dialog or a ful-drawer before a dismissal drops unsaved edits
spinner.loading the label of a ful-spinner in a submitting ful-form, a loading ful-table, ful-select, ful-dialog and ful-drawer
tabs.previous, tabs.next the scroll buttons of a ful-tabs whose tabs do not fit
toast.region, toast.dismiss ful-toasts
wizard.progress ful-wizard
failure.no-reason Localization.failure, for a problem with no reason

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, and ftl:ready waits with them: it fires once the elements a late configure() defines have upgraded. 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