# Languages

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.

```json
{
  "unread": "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
}
```

```go
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

```sh
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`:

```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](https://www.rfc-editor.org/info/bcp47) 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](https://github.com/google/app-resource-bundle)
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:

```json
{
  "@@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](https://cldr.unicode.org)
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:

```go
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`.

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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:

```go
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

```sh
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:

```sh
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 `.lproj`s 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:

```json
{
  "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](https://github.com/egoist/asuka/blob/main/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.
