Skip to content

Translation and formatting

Lumen translates an app in four steps: mark the strings you want translated, extract them into a catalogue, translate the catalogue, and run. Catalogues are Fluent .ftl files in a locale/ directory beside your markup. Numbers, dates and money are not in a catalogue; they are formatted from the same locale.

1. Mark the strings

In markup, add translatable with a catalogue key. Keep the source-language text in text; it is what shows until a translation exists:

<label translatable="app-title" text="Weather"/>
<button id="refresh" translatable="action-refresh" text="Refresh"/>

translatable works on any element that displays a string.

One key covers everything an element shows. The text comes from the message itself; a placeholder and an alt come from the Fluent attributes .placeholder and .alt on that same message:

<input id="q" translatable="search" placeholder="Search the catalogue"/>
<image src="logo.png" translatable="logo" alt="The Lumen logo"/>
search =
    .placeholder = Katalog durchsuchen

logo =
    .alt = Das Lumen-Logo

Only a string you wrote in the markup is translated: a .placeholder entry for an element with no placeholder attribute does nothing. A <tooltip> is an element with a string of its own, so it takes its own key rather than an attribute on the element it wraps:

<tooltip translatable="save-tip" text="Save the file">
  <button id="save" translatable="save" text="Save"/>
</tooltip>

In a script, wrap the string in t():

set_text("status", t("status-loading"));

tr() is the same function under the Qt spelling. In candela both live in the lumen namespace: lumen::t("status-loading").

2. Extract

lumenc i18n extract ./myapp

This walks the app directory, reads every .lmn, .rhai, .lua, and .cdl file, and writes the keys it finds to the catalogue for the app's source language: [app] fallback_locale in lumen.toml, else locale/en-US.ftl. Pass --lang to target a different file:

lumenc i18n extract ./myapp --lang de-DE

Each new key is appended with a marker and its own key as the placeholder value:

# TODO: translate
app-title = app-title

An element's other strings arrive as attributes under the same message. An element that shows no text of its own gets a message with no value:

# TODO: translate
search =
    .placeholder = search.placeholder

Extraction is safe to re-run. Existing entries are copied through untouched, so your translations survive; a message that gains a string later gains only the new attribute line. Keys you delete from your source stay in the catalogue until you remove them yourself.

3. Translate

One file per locale, named by its BCP-47 tag, directly inside locale/:

  • locale/en-US.ftl
  • locale/de-DE.ftl
  • locale/ja-JP.ftl

Fill in the values:

app-title = Wetter
action-refresh = Aktualisieren

Fluent's selector syntax handles plurals inside the catalogue:

items = { $count ->
    [one] { $count } item
   *[other] { $count } items
}

Selectors that take a variable, like the one above, are filled in by Rust SDK apps. A markup app's t("items") passes no variables, so the placeable stays unresolved; write separate keys for the cases you need.

4. Run

The locale the app starts in is, in order:

  1. [app] locale in lumen.toml
  2. the desktop's locale
  3. en-US
[app]
locale = "de-DE"
fallback_locale = "en-US"

Both values must be valid BCP-47 tags; a malformed one fails at startup.

When a key is missing from the active locale, Lumen falls back to fallback_locale, then to the string the element was written with, then to the key itself. fallback_locale is the language your source strings are written in, and defaults to en-US; an app authored in German sets it to de-DE so a miss reads German rather than probing an en-US catalogue it never ships. Setting it to the locale the app is running in leaves nothing to fall through to, so a miss goes straight to the authored text.

Fallback is per message, so a partly translated catalogue works: translated messages appear translated, the rest read in the fallback language. The key only stands in for an element's text, and only when the element has no other translated string: an <input> marked for its placeholder shows an empty field rather than the key.

Naming a locale with no catalogue file is not an error. Every message falls back, and the app runs in the text its source was written with.

On the web, translatable is resolved when the site is emitted, so a page arrives in its language with nothing running. A build writes one document tree per locale; under render = "ssr" it writes none and each request is rendered in the locale it asks for. See the web target and rendering on a server.

The tree's catalogue travels with the site, and the browser runtime loads it for the locale the document was emitted in. So everything that appears after the page opens reads in that language too: a row the runtime builds for a list a script filled, an element a component fills, and whatever a script's t() returns. A fallback_locale other than en-US applies to what the build resolved, not to what the runtime builds afterwards: in the browser a page reads its own catalogue and the en-US one, so a key missing from both reads as the text it was authored with.

Changing the locale while the app runs

set_locale(tag) switches the running app to another locale, and locale() answers the tag it is in. A language menu is those two calls:

fn pick_de() { set_locale("de-DE"); }
fn on_start() {
    on("click", "de", "pick_de");
    print(locale());   // "en-US" before anything switched it
}

In candela both live in the lumen namespace, like t: lumen::set_locale("de-DE").

The switch takes effect on the click. Every string the markup marked is resolved again from the new catalogue: an element's text, a text entry's placeholder, a tooltip's body. Everything a format writes is rendered again for the new locale, so a price reads 1,234.50 in English and 1.234,50 in German. The base writing direction moves too, so picking Arabic mirrors the tree that is already on screen and picking English unmirrors it.

Two things do not move. A caption a composed widget builds for itself is shown as written, the same limit as at startup. Anything built once outside the tree stays as it was built: a native menu, a tray tooltip, a window title a script set.

An element marked translatable shows the catalogue's string, including one a script had replaced with set_text. The element declared that its text comes from the catalogue, and a switch is the catalogue speaking again. Set text on an element that names no key when you want a script to own it.

Naming a locale with no catalogue is not an error here either: every message falls back to the text the author wrote.

That last rule is what a browser page runs into. A page carries the catalogue it was built for and the fallback one, so set_locale moves between those two and any other tag leaves every message reading as authored. Nothing is fetched. In a browser format still leaves a value as it stands, before a switch and after it.

Formatting numbers, dates, and money

A translated string is only half of speaking a locale. 12,345.67 in English is 12.345,67 in German, Jun 15, 2024 is 15.06.2024, and a euro amount leads with the symbol in one and trails it in the other. Lumen writes all of those from the app's locale, so one source file reads correctly in every language you ship.

In markup, add format with a spec:

<label format="currency:EUR" text="1234.5"/>
<label format="date" text="2024-06-15"/>
<label format="number" bind-text="count"/>

The spec applies to whatever text the element ends up showing: an authored text, or every value a bind-text signal writes.

The specs, and what each expects its text to be:

Spec Text de-DE en-US
number a decimal number 12.345,678 12,345.678
currency:<code> a decimal amount, <code> an ISO-4217 code 1.234,50 then the euro sign the euro sign then 1,234.50
date YYYY-MM-DD, time optional 15.06.2024 Jun 15, 2024
time YYYY-MM-DDTHH:MM[:SS] 09:30:00 9:30:00 AM
datetime YYYY-MM-DDTHH:MM[:SS] 15.06.2024, 09:30:00 Jun 15, 2024, 9:30:00 AM
relative whole seconds from now, past negative vor 2 Stunden 2 hours ago

The currency symbol and the exact wording come from CLDR, so every locale Unicode publishes data for is covered, and each currency rounds to its own number of fraction digits: two for EUR and USD, none for JPY.

A date may be written as an RFC-3339 timestamp, which is what an API usually returns. A fractional-seconds part and a trailing zone (Z, +02:00) are read and dropped; the date and time are shown exactly as written, and Lumen converts nothing between time zones.

In a script, one call per kind:

set_text("price", format_currency(1234.5, "EUR"));
set_text("when", format_date("2024-06-15"));
set_text("count", format_number(12345.678));
set_text("ago", format_relative(-7200));

format_time and format_datetime complete the set. In candela they live in the lumen namespace, like t: lumen::format_number(12345.678).

A spec Lumen does not know, or text that is not what the spec expects, leaves the text as it was. format="number" on not a number shows not a number, the way an untranslated key shows itself: a typo in a spec, or data that is briefly the wrong shape, does not blank an element. That also means format on a translatable element does nothing useful, since a translated sentence is not a number or a date.

While developing

lumenc run watches the locale/ directory. Editing a .ftl file, adding one, or deleting one reloads the app with the new strings.

Right-to-left text

The base direction follows the app's locale. An app running in Arabic, Hebrew, Persian, or another right-to-left language starts mirrored with nothing in its markup saying so, on the desktop and on the web alike.

Set dir where you want something else:

<root dir="rtl">

dir accepts ltr, rtl, and auto, and it overrides the locale for the element it is on and everything under it, so you can pin the whole page or flip a single subtree.

lang tags a subtree's language for text shaping:

<label lang="ja" text="..."/>

A value filled into a translated string is wrapped in Unicode isolation marks before it lands in the sentence, so an Arabic sentence holding a Latin name, or an English one holding an Arabic name, keeps its parts in the order the translator wrote them. The marks are invisible and apply to every locale and every target; there is nothing to turn on.

Limits today

  • A caption a composed widget builds for itself is shown as written: a <dropdown> placeholder, an <option> label, the placeholder a <date-picker> or <time-picker> fills in, and the labels of <menu> and <menuitem> carry no key of their own.
  • Formatting is reachable from markup and scripts. The Rust SDK has no formatting call.
  • A script's t() and format_* calls return what they were given when a page is rendered on a server or prerendered by a build. Markup translatable and format are resolved in both.
  • In a browser, format and the format_* builtins leave a value as it stands. The catalogue travels with the site; the locale formatters do not. A value written into the document while the site was built is formatted.

The lumenc i18n flags are listed in the CLI reference, and [app] locale in the lumen.toml reference.