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, 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 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 -->

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 as given. language is matched on its primary subtag, lowercased, 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
spinner.loading the label of a ful-spinner in a submitting ful-form, a loading ful-table, ful-select, ful-dialog and ful-drawer
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. 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