Asuka
Languages

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 translate

An 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 language

Most 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())             // likewise

The 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, else LC_ALL, LC_MESSAGES or LANG.
  • 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: LocaleManager on 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 (locale on Windows, asuka-locale on 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 language

Left 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's lang is 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 browser

The 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 @@locale differs;
  • 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 String counted by plural);
  • 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.