Guides
Languages
Translated messages with plurals, numbers and dates in the user's way, right-to-left layouts, and trying a language.
A Asuka app says its messages in the language of the people who use it, and is laid out the way that language reads. Its messages live in catalogs, a JSON file per language, from which the CLI writes a Go package with a typed function per message.
{
"unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
}ui.Text(locales.Live.Unread(count)) // "3 unread messages"; "3 ungelesene Nachrichten"What it says follows count, and follows the language when it changes, with no
code of yours. examples/i18n has all of this in a small app.
Starting
asuka i18n init # locales/en.json, and its functions
asuka i18n add de ar # locales/de.json and ar.json, with the messages to translateAn app with a locales directory of catalogs needs no configuration. If they're
elsewhere, or the app is written in another language, say so in asuka.json:
{ "i18n": { "dir": "translations", "source": "ja" } }source (default en, or the only catalog there is) is the language the app is
written in: its catalog has every message, and the others fall back to it. The
languages the app ships are the files of the directory, named after their
BCP 47 tags: de.json, pt-BR.json,
zh-Hans.json.
asuka dev and asuka build write the functions (locales/catalog_gen.go) as they
start and whenever a catalog changes, so a translation shows on save. Commit them
with the catalogs, so that go build and go test work without the CLI.
Import the package from your code. An app that doesn't is told so, as it would then show only its source language.
Catalogs
A catalog is an object of messages, in the ARB
format, whose keys name them. The source language's also describes them, under
the key with an @, for translators and for your editor's hover:
{
"@@locale": "en",
"unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}",
"@unread": {
"description": "Above the list of messages.",
"placeholders": { "count": { "type": "int", "example": "3" } }
},
"greeting": "Hello, {name}!"
}A key is a letter, then letters, digits and _. Keys are flat: name related
messages with a common start, as inboxTitle and inboxEmpty, which sort
together in your editor's completion.
A placeholder's type is String, int, double, num, DateTime or Object,
which is its Go type (string, int, float64, float64, time.Time, any).
Without one, an argument counted by plural, selectordinal or number is an
int, a date or time argument a DateTime, and any other a String.
Messages
Messages are ICU MessageFormat, the format of ARB files, of Android and of Format.JS:
| Message | Says |
|---|---|
Hello, {name}! |
the value of name in its place |
{count, plural, one {# file} other {# files}} |
the case of count's plural category in the message's language; # is the number |
{count, plural, =0 {No files} one {# file} other {# files}} |
=0 is for exactly 0, before the categories |
{n, plural, offset:1 =0 {Nobody} =1 {{host}} one {{host} and # other} other {{host} and # others}} |
with offset:1, the categories and # count n - 1 |
{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} |
the ordinal category: 1st, 22nd, 103rd, 11th |
{gender, select, female {She} male {He} other {They}} replied. |
the case named after the value, else other |
{price, number}, {ratio, number, percent}, {n, number, integer} |
a number in the language's way (1,234.5; 1.234,5; 12,34,567) |
{price, number, ::.00}, {n, number, ::group-off} |
exactly two fraction digits, or no separators of thousands |
{day, date, short}, {day, time, short} |
a date or a time in a style: short, medium, long or full |
{day, date, ::yMMMd} |
a date of a CLDR skeleton's fields, in the language's order (Jan 5, 2026; 5. Jan. 2026) |
Cases nest, and every plural, selectordinal and select has an other case.
The plural categories are those of the Unicode CLDR
for the message's language: one and other in English, only other in Chinese
and Japanese, one, few, many and other in Russian, six in Arabic.
An apostrophe quotes the next brace, or # in a plural case: '{' says {, and
two apostrophes say one. Any other apostrophe is just one, so don't needs no
quoting.
In components
A component runs once, so what follows the language is what's read in an accessor, an effect or a memo, as for any signal. Each message has three forms:
locales.Live.Title() // an accessor, for a prop
locales.Live.Unread(count) // ...whose arguments are accessors too: a signal, a memo, ui.Val(3)
locales.Unread(3) // a string, in the language shown now
locales.In("de").Unread(3) // a string, in another languageMost code wants Live. Every prop that shows text takes it: ui.Text, a button's
Title, a tab's, an action's, a screen's, a field's Placeholder, a view's
Label.
ui.Button(ui.ButtonProps{Title: locales.Live.Save(), Menu: []ui.Action{
{Title: locales.Live.Duplicate(), OnPress: duplicate},
}})
ui.Tab{Title: locales.Live.Inbox(), Icon: ui.IconHome, Content: inbox}The string forms follow the language wherever they're read in an effect, which is what to use with a signal's value in more elaborate text:
ui.Text(signal.Func(func() string {
if n := len(items.Get()); n > 0 {
return locales.Unread(n)
}
return locales.AllRead()
}))
ui.Segmented(ui.SegmentedProps{Segments: signal.Func(func() []string {
return []string{locales.Day(), locales.Week(), locales.Month()}
})})A message said in a component's body, outside any of those, is said once:
ui.StaticText(locales.Title()) // stays what it was when the language changes
ui.Val(locales.Title()) // likewiseThe CLI warns of these two, naming the file and line. What a call shows once and that's gone, an alert or a notification, takes a string:
ui.Alert(ui.AlertProps{Title: locales.DeleteTitle(), Actions: []ui.Action{
{Title: locales.Live.Delete(), Role: ui.ActionDestructive, OnPress: remove},
{Title: locales.Live.Cancel(), Role: ui.ActionCancel},
}})Any goroutine may say a message; it's the same function, safe to call anywhere.
The language the app shows
The app shows one language, which ui.Locale() returns: of the languages it's
written in, the one the user reads best, by the languages they prefer in the order
the system lists them, as zh-Hant for a reader of zh-TW; else the app's source
language. An app written in English alone shows English to a reader of Chinese,
as UIKit does.
The languages the user prefers are the system's:
- iOS and macOS: the preferred languages of Settings' Language & Region.
- Android: the system's languages.
- Windows: the user's display languages (Settings' Time & language).
- Linux: the session's
LANGUAGE, elseLC_ALL,LC_MESSAGESorLANG. - The web: the browser's (
navigator.languages).
ui.SetLocale("de") shows another language at once, without rebuilding anything:
each text sends its new value, the app lays out again, and the state of what's on
screen stays. ui.SetLocale("") follows the user's languages again. A menu of the
app's languages, each named in itself:
ui.List(ui.ListProps{}, ui.Val(ui.Languages()), func(t string) string { return t },
func(tag signal.Accessor[string], _ signal.Accessor[int]) ui.Node {
return ui.View(ui.ViewProps{OnPress: func() { ui.SetLocale(tag.Get()) }},
ui.Text(signal.Func(func() string { return i18n.Autonym(tag.Get()) })))
})The choice is kept across launches, where the system keeps an app's language so that its Settings show it:
- iOS and macOS: the app's
AppleLanguages, which is what Settings' language for the app writes. UIKit and AppKit say their own words (Cancel in a date picker, the Mac's standard menus) in it from the next launch; yours, at once. - Android:
LocaleManageron Android 13 and later, which its settings show, and the system's own words follow at once; Asuka's preferences before that. - Windows and Linux, which have no language for an app: a file in the app's
data directory (
localeon Windows,asuka-localeon Linux). - The web: the page's
localStorage.
When the user changes their language or the app's in the system's settings, the app follows: on iOS the system relaunches it, and on Android, macOS and Windows it changes as it runs. On Linux the languages are the session's, read as the app starts, and a browser's are read as the page loads.
To say something in a language the app chose by itself, read it where it'll be
followed: ui.Locale() is safe anywhere, and an effect that calls it runs again.
ui.PreferredLanguages() returns what the user reads, whatever the app has.
Which message
A message is the one of the catalog's language that suits the language asked for
best: zh-Hant for zh-TW, pt-PT for pt-AO, en-GB for en-AU. When that
language lacks it, it falls back along CLDR's parent locales (en-AU, en-001,
en; zh-Hant-HK, zh-Hant), then to the source language: a missing translation
says the source's message rather than nothing.
Numbers and dates
How dates, times and numbers are written is a setting of its own, the regional
formats. ui.CurrentRegion() returns them: the locale of the formats, which may
be another language's than the one the app shows, the first day of the week and
whether times have 12 or 24 hours, with the changes the user made in the system's
settings. What the system doesn't say comes from CLDR's data for the region.
On a Mac they're Language & Region's, on Windows the user's regional format, on
Linux LC_ALL, LC_TIME or LANG with GNOME's clock format, and in a browser
the first language's, as Intl gives it.
Messages say their numbers and dates this way themselves. To write one of your own,
use package i18n with ui.FormatLocale(), the two put together as Apple's
Locale.current does: en-GB-u-fw-mon-hc-h23 for English read with British
formats. It follows them in an effect, as ui.Locale does:
ui.Text(signal.Func(func() string {
return i18n.FormatDate(ui.FormatLocale(), due.Get(), i18n.DateOptions{Date: i18n.MediumStyle})
}))Package i18n writes them as ICU and browsers' Intl do, with the data of the
Unicode CLDR for the locales of its modern coverage:
i18n.FormatNumber("de", 1234567.891, i18n.NumberOptions{}) // 1.234.567,891
i18n.FormatNumber("hi", 1234567, i18n.NumberOptions{}) // 12,34,567
i18n.FormatNumber("fa", 0.25, i18n.NumberOptions{Percent: true}) // ۲۵٪
i18n.FormatNumber("en", 1.5, i18n.NumberOptions{MinFractionDigits: 2}) // 1.50
i18n.FormatDate("en", t, i18n.DateOptions{Date: i18n.FullStyle}) // Monday, January 5, 2026
i18n.FormatDate("ja", t, i18n.DateOptions{Date: i18n.LongStyle, Time: i18n.ShortStyle}) // 2026年1月5日 13:07
i18n.FormatDate("ru", t, i18n.DateOptions{Skeleton: "yMMMM"}) // январь 2026 г.Months, Weekdays and DayPeriods give their names, Autonym a language's name
in itself ("Deutsch", "简体中文"), FirstWeekday and HourCycle a region's week and
clock. Dates are in the Gregorian calendar. Time zones other than UTC are written as
offsets (GMT+8) where ICU names some.
Right to left
In a language written right to left, the app is laid out from the right, with no
code of yours: a Row runs from the right, Justify and Align follow it, text
starts on the right, and the platform's views mirror (the navigation bar and its
back gesture, the tab bar, push and pop, a slider, a list's swipe actions). A
horizontal Scroll starts at its right end. Layout follows CSS's direction,
checked against Chrome's results.
What you place by hand:
ui.Style{Padding: ui.Edges{Start: 16}} // on the side text starts on
ui.Style{Absolute: true, End: ui.Pt(8)} // from the side text ends on
ui.Style{Margin: ui.Edges{Left: 16}} // on the left, in every languageLeft and Right stay where they say, as in CSS: use Start and End for what
should mirror, as nearly everything should. TextStart is the side text starts on,
and the default.
Symbols named for a direction by forward or backward mirror: ui.IconForward,
chevron.forward, arrow.backward. chevron.left doesn't, for what's left in every
language (a media player's rewind, say).
A line of text reads as its own letters do: an English sentence in an Arabic app sits on the right and keeps its full stop at its end. Its side comes from the app, its order from the Unicode bidirectional algorithm.
Each platform's own views mirror as its apps' do:
- macOS: AppKit's views go right to left, and the toolbar and tabs run from the right. AppKit reads the app's direction once, as it starts, so an app whose language reads right to left is one from its first frame; a direction that changes while it runs leaves the window's buttons and title, a vertical scroller's side and an alert's button order as they were until the next launch.
- Windows: the title bar and the menu bar mirror (the window's buttons on the left), and the host's header bar, tabs and controls go right to left. The date picker, which Windows draws itself, doesn't.
- Linux: GTK's widgets take the direction, the header bar, navigation and popovers included.
- The web: the app's element has
dir="rtl", and the page'slangis the app's language.
What reads the same way in every language, as a phone number, a media player's controls or a code editor, says so:
ui.WithDirection(ui.LeftToRight, func() ui.Node { return playerControls() })ui.IsRTL() says which way the app goes, for what doesn't follow by itself, as a
drawing. Frames, OnLayout, OnScroll and ScrollTo are physical, from the left.
Trying a language
asuka dev --platform ios --lang de . # the app in German, for this run
asuka dev --platform ios --lang en-XA . # [Šéļéçţ ƒîļéš one two]
asuka dev --platform ios --lang ar-XB . # each word right to left, and the layout
asuka dev --platform ios --dir rtl . # any app right to left, untranslated--lang and --dir work with every platform asuka dev runs, the desktops and the
web included:
asuka dev --lang ar . # on this computer: macOS, Windows or Linux
asuka dev --platform web --dir rtl . # in the browserThe two pseudo locales test layouts before any translation exists, as Android's do.
en-XA accents the letters of the source's messages, makes them a third longer and
brackets them: text that stays plain isn't in a catalog, cut text has too short a
space, and a sentence of several brackets is made of pieces a translation can't
reorder. ar-XB shows what doesn't mirror. With go run, ASUKA_LOCALE=de and
ASUKA_DIRECTION=rtl do the same.
Checking the catalogs
asuka dev, asuka build and asuka i18n check check them. Errors stop them:
- a file not named after a canonical tag, or whose
@@localediffers; - a message that doesn't parse;
- a key a translation has and the source doesn't, or a key
asuka.<key>Asuka has no message for; - an argument a translation has and the source's message doesn't, or used in a way
its type can't be (a
Stringcounted byplural); - two keys whose functions would have the same name.
Warnings don't: messages a translation lacks, which say the source language's;
arguments it leaves out; plural cases its language doesn't have (few in English) or
lacks (few in Russian, which says other in its place); placeholders a message
doesn't use.
What the system shows
asuka build declares the catalogs' languages (CFBundleLocalizations and .lprojs on
iOS and macOS, a locales_config on Android): that's what makes iOS and macOS say their
own words in them and Settings offer the app's language. Windows and Linux builds
declare none: they have no language for an app. Keys asuka.<name> in the catalogs
translate what it writes there:
{
"asuka.appName": "Notizen",
"asuka.NSCameraUsageDescription": "Um Belege zu scannen.",
"asuka.widget.steps.name": "Schritte",
"asuka.widget.steps.description": "Deine Schritte heute.",
"asuka.shortcut.newNote": "Neue Notiz"
}Asuka's own words
Asuka says few words itself, since the platforms say theirs: an alert's only button, a
search field's placeholder, a desktop's Back, a Mac's About, Hide and Quit, and a
stepper's labels. They're translated
into the common languages, and your catalogs change them or add others, with keys
asuka.<key>, as "asuka.ok": "Verstanden" in de.json. The keys are
internal/l10n/messages/en.json.
Without catalogs
Package i18n works on its own: NewCatalog reads catalogs from any fs.FS,
ParseMessage and Message.Format format a message, PluralCategory gives a number's
CLDR category, Match picks among languages. An app that translates its text itself
tells Asuka what it's written in, with ui.SetLanguages("en", "de"), and reads
ui.Locale() in an effect.