MyGo
Languages

Building the app

Languages

The app's messages in the languages it ships, with catalogs of ICU messages, typed Go and TypeScript functions, plural forms, MyGo's own strings, and pseudo locales to test layouts.

A MyGo app says its messages in the language of the people who use it. Its messages live in catalogs, a JSON file per language, from which mygo dev and mygo build write a Go package with a typed function per message, which says it in the language the app shows, with the plural forms of that language. The whole app shows one language: its own messages, MyGo's controls and menus, and its pages if it has any.

{
  "filesSelected": "{count, plural, =0 {No files selected} one {# file selected} other {# files selected}}"
}
ui.Text(c, locales.FilesSelected(len(app.selected))) // "3 files selected"

Package i18n underneath formats numbers and dates with the data of the Unicode CLDR, and an app with a frontend gets the same messages as a TypeScript module (In pages).

Catalogs

Turn the catalogs on in the configuration, in mygo.json (or mygo.config.ts):

{
  "i18n": { "dir": "locales", "source": "en" }
}

dir (default locales) holds a catalog per language, named after its BCP 47 tag: en.json, de.json, pt-BR.json, zh-Hans.json, zh-Hant.json. source (default en) 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 dir, which holds nothing else but Go files; an app without catalogs ships English.

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 the translators and the generated code:

{
  "@@locale": "en",
  "filesSelected": "{count, plural, =0 {No files selected} one {# file selected} other {# files selected}}",
  "@filesSelected": {
    "description": "The status bar, while files are selected.",
    "placeholders": {
      "count": { "type": "int", "example": "3" }
    }
  },
  "greeting": "Hello, {name}!"
}
{
  "@@locale": "de",
  "filesSelected": "{count, plural, =0 {Keine Dateien ausgewählt} one {# Datei ausgewählt} other {# Dateien ausgewählt}}",
  "greeting": "Hallo, {name}!"
}

A key is a letter, then letters, digits and _; the functions of a key are named after it. A placeholder's type is String, int, double, num, DateTime or Object, which makes the type of the functions' parameter (string, int, float64, float64, time.Time and any in Go; string, number, Date and unknown in TypeScript). 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. The parameters come in the order of placeholders, then of the message. @@locale is optional, and must name the file's language.

Keys are flat: a dot in a key is for MyGo's own strings (mygo.undo). Name related messages with a common start, as sidebarTitle, sidebarEmpty and sidebarNewNote, which sort together in the catalogs and in your editor's completion (locales.Sidebar…).

Messages

Messages are written in 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 the plural category of count 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 case of 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} a number with exactly two fraction digits, or without 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 the fields of a skeleton of CLDR, in the language's order (Jan 5, 2026; 5. Jan. 2026)

Cases nest, and every plural, selectordinal and select has an other case. A number in {n} is written as {n, number} writes it: up to three fraction digits, grouped, in the language's digits; a number given as a string of a decimal, "1.50", keeps its fraction digits. A plural case is the one of the number as # writes it, as ICU picks it: 1.0004 is written 1, whose case in English is one. A time in {t} is written as {t, date, short} and {t, time, short} together. Numbers and dates follow CLDR's data in Go and Intl in pages, which agree (see Formatting numbers and dates). Of ICU's number skeletons, Go and pages read percent, integer, the precision of fraction digits (.00, .0#) and group-off; pages also read currency/EUR, which Go writes as a plain number. 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. Write the ones the language has; other is required.

An apostrophe quotes the next brace, or # in a plural case: '{' says {, '{name}' says {name}, and two apostrophes say one. Any other apostrophe is just one, so don't needs no quoting.

In Go

mygo dev, mygo build and mygo generate write catalog_gen.go into the catalogs' directory, a Go package of its own (named after the directory, or as its other Go files say), which embeds the catalogs:

import "example.com/notes/locales"

title := locales.FilesSelected(3)                // in the language the app shows
german := locales.In("de").FilesSelected(3)      // in another

Each key has a function with the typed parameters of its placeholders, which says the message in the language the app shows, and a method of locales.In(tag) for another language. A message without arguments is kept once said, so saying it again costs a map lookup and allocates nothing: a view of native UI says its messages every frame.

The package registers its catalog with package i18n as it initializes, which makes the catalogs' languages the app's: import it, with import _ "example.com/notes/locales" if the Go code says no message itself. mygo dev and mygo build warn when the app does not.

mygo dev writes the functions as it starts and again whenever a catalog changes, and mygo build before it compiles the app; mygo generate writes them alone. They are generated code: commit them with the catalogs, so that go build and go test work without the CLI.

When the language changes

A view of native UI is built again for every frame, so what it says follows the language as it changes:

func (app *notes) view(c *ui.Context) {
	ui.Column(c).Gap(8).Children(func() {
		ui.Text(c, locales.FilesSelected(len(app.selected)))
		if ui.Button(c, locales.NewNote()).Clicked() {
			app.add()
		}
	})
}

Menu items labelled by their role ("Undo", "Quit Notes") take the new language's labels themselves. What the app set once, as a window's title or the labels of its own menu items, it sets again when it hears the change:

win := mygo.NewWindow(mygo.WindowOptions{Title: locales.Title(), Content: ui.View(app.view)})
mygo.App.OnLocaleChanged(func(string) {
	win.SetTitle(locales.Title())
	mygo.App.SetMenu(app.menu()) // the menu, made again with locales' labels
})

The language the app shows

The app shows one language, which mygo.App.Locale() returns: of the languages it is written in, the one the user reads best, by the languages they prefer in the order the system lists them (App.PreferredLanguages()), 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, MyGo's controls and menus too, as AppKit does. The languages the app is written in are its catalogs', which App.SetLanguages replaces for an app without them.

App.SetLocale("de") shows another language at once, and keeps it for the next launches; App.SetLocale("") follows the user's languages again. A language menu, each language named in itself ("Deutsch", "简体中文"):

for _, tag := range mygo.App.Languages() {
	items = append(items, &mygo.MenuItem{Label: i18n.Autonym(tag), Type: mygo.MenuItemRadio,
		Checked: tag == mygo.App.Locale(),
		Click:   func(*mygo.MenuItem, *mygo.Window) { mygo.App.SetLocale(tag) }})
}

macOS keeps the language as the app's language in System Settings (AppleLanguages of the app's defaults), which AppKit and WebKit say their own texts in from the next launch; browsers keep it in localStorage; Windows and Linux in the file locale of App.Path(mygo.PathUserData). App.OnLocaleChanged hears the language change, as SetLocale changes it or the user's languages do (macOS, Windows and browsers report theirs; Linux reads the environment, LANGUAGE, LC_ALL, LC_MESSAGES and LANG, once).

When the language changes, windows of native UI draw anew, menu items labelled by their role say the new language's labels (see When the language changes), and pages hear it (In pages). The app is laid out in the language's direction, right to left in Arabic and Hebrew, unless MYGO_DIRECTION (rtl or ltr) says otherwise: native UI, pages (mygo.dir) and, on Linux, GTK's own widgets, as menus, agree.

Native UI

ui.Context.Locale() and ui.Context.Region() return the app's language and formats; a frame follows their changes. Text is drawn in the app's language, which picks the glyphs the system's text stack draws for it, as the Han characters Chinese and Japanese draw apart. Element.Lang sets the language of an element's text and of the elements inside it, as HTML's lang does, Span.Lang of a span of a RichText, Font.Lang of text a Painter shapes:

ui.Text(c, "「直」").Lang("ja") // a Japanese quote in a Chinese app

Han characters in a language that does not write them, as a Chinese name in English text, keep the glyphs the system picks for the user, as browsers do. Browsers draw the text of native UI in the page's language, which MyGo makes the app's.

examples/gallery has a Languages page: a menu of languages, numbers and dates in each, the controls in Chinese, and the pseudo locales.

Regional formats

How dates, times and numbers are written is a setting of its own: App.Region() returns the locale of the user's 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. App.OnRegionChanged hears them change. What the system does not say, as a libc without the data or a browser that does not tell, comes from CLDR's week and time data for the region (i18n.FirstWeekday, i18n.HourCycle).

Region.FormatLocale(language) puts the two together as Apple's Locale.current does: English read with British formats is en-GB-u-fw-mon-hc-h23, with German ones en-DE-u-fw-mon-hc-h23; format with it to write dates in the app's language the way the user set them:

formats := mygo.App.Region().FormatLocale(mygo.App.Locale())
label := i18n.FormatDate(formats, due, i18n.DateOptions{Date: i18n.MediumStyle})

MyGo's date and time controls do so: the calendar starts its weeks on the region's first day, the date input shows the region's numeric date, the time input its hour cycle, with AM and PM in the window's language.

Formatting numbers and dates

Package i18n writes numbers, dates and times as ICU and the browsers' Intl write them, with the data of the Unicode CLDR for the locales of its modern coverage (430 locales of 104 languages: en, en-GB, de-CH, pt-BR, zh-Hant-HK, ar-EG...). A locale it has no data of formats as the nearest it has (zh-TW as zh-Hant, en-AU as en-001), else as English.

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 г.
i18n.FormatDate("en", t, i18n.DateOptions{Skeleton: "jm", HourCycle: 24}) // 13:07
  • Numbers: the language's separators and grouping (the Indian 12,34,567, and none in four digits where it groups from five, as Spanish and Polish do), its minus and percent signs, and the digits of its numbering system (Arabic-Indic in Persian and Egyptian Arabic, Bengali in Bengali; -u-nu-latn asks for Latin ones). Fraction digits are rounded half away from zero, as Intl rounds them, or half to even (HalfEven). NumberFormatter keeps a formatter.
  • Dates and times by style (FullStyle to ShortStyle, as dateStyle and timeStyle) or by skeleton, the fields to show in any order (yMMMd, MMMd, Ed, jm): the locale's pattern of CLDR that suits it best is found as ICU's DateTimePatternGenerator finds it, so {year: "numeric", month: "short", day: "numeric"} in Intl and yMMMd here write the same. j is the hour in the locale's cycle; HourCycle (or -u-hc-h23 in the locale) makes it 12 or 24. Dates are in the Gregorian calendar, times in their time.Location. DateFormatter keeps a formatter, and Pattern says the pattern.
  • Names: Months, Weekdays and DayPeriods in a Width, standing alone (a calendar's title, "январь") or inside a date ("января"); Autonym is a language's name in itself, for a menu of languages ("Deutsch", "简体中文", "British English").
  • Week and clock: FirstWeekday and HourCycle of a locale's region (or its language's likely one), RegionFor both.

Formatting allocates its result only; formatters and the locales' data are made once and kept. The data is compressed and inflated on first use in under a millisecond. Every app links the formatting of numbers, as MyGo's own strings format them, with their data, about 3 KB; the formatting of dates and their data, with the names of languages, are linked into an app only when it formats them: with a catalog of its own (NewCatalog, which mygo generate writes), ParseMessage, the formatters or the functions that read the data. It is one package, not one per language: the user's regional formats may be any locale's, whatever languages the app ships.

What differs from ICU (i18n/format_icu_test.go lists each case the tests skip): time zones other than UTC are written as offsets from GMT (GMT+8, GMT+05:30) where ICU names some ("China Standard Time"); the calendar is always the Gregorian one, where ICU's default for Thai or Persian is another; times write spaces where CLDR has narrow no-break ones, as V8 does; and the few locales whose data ICU 78 (CLDR 48.0) lacks or has older (Haitian Creole, polytonic Greek) follow CLDR 48.2.

MyGo's own strings

MyGo's controls, its code editor, diff viewer, rich text editor and Markdown view, the terminal plugin, its document dialogs, the buttons of pages' alert, confirm and prompt, the titles of file dialogs, and its menus say their own words: "Undo", "Find", "Next month", "Do you want to save the changes made to the document “Notes”?", "Quit Notes", and the names assistive technology reads ("hours", "Saturation and brightness"). They say the language the app shows: in native UI, the window's (ui.Context.Locale). An app that ships only English shows them in English to a Chinese reader, as AppKit does. Saying one again in the same language costs a map lookup and allocates nothing, so controls say theirs every frame.

English is always linked. The translations to other languages, Simplified Chinese (zh-Hans) and Traditional Chinese (zh-Hant) so far, are packages of their own, so that an app carries only those of the languages it ships. The code mygo generate writes for an app's catalogs imports them for the app's languages: an app with a zh-TW.json gets MyGo's zh-Hant. An app without catalogs, which sets its languages with App.SetLanguages, imports them itself:

import _ "github.com/egoist/mygo/locales/zh-Hans"

An app that ships a language MyGo is translated to without importing the translation is told so as it starts, and shows MyGo's strings in English there. An app changes MyGo's strings, or gives them its other languages, with keys mygo.<key> in its catalogs:

{
  "mygo.undo": "Rückgängig",
  "mygo.menuQuitApp": "{app} beenden"
}

The keys, their messages and their arguments are MyGo's catalog, internal/l10n/en.json. A string resolves as the app's own do: in the app's language, then the languages it falls back to, then the app's source language, then English. The inspector of native UI, a tool of developers, stays in English.

Adding a language to MyGo

MyGo's own strings are internal/l10n/en.json, and each translation is locales/<tag>/<tag>.json. To translate MyGo to a language, add locales/<tag>/<tag>.json with every key of en.json (the descriptions say where each shows), run go generate ./internal/l10n, which writes the translation's package and adds it to the languages mygo generate links for apps, and go test ./internal/l10n, which checks it as mygo generate checks an app's catalogs: every message in every language, with the source's placeholders and the language's plural cases. mygo.appName and mygo.appDescription are the app's name and description in a language, which packaging writes where systems list the app (see building and distributing).

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. Its plural cases follow the rules of the language the message is written in; numbers and dates, the conventions of the language asked for.

Checking the catalogs

mygo generate and mygo build check the catalogs, and so does mygo i18n check. Errors stop them:

  • a file not named after a canonical tag, or whose @@locale differs;
  • a message that does not parse;
  • a key a translation has and the source does not, or a key mygo.<key> MyGo has no message for;
  • an argument a translation has and the source's message does not, or used in a way its type cannot be (a String counted by plural);
  • two keys whose functions would have the same name.

Warnings do not: messages a translation lacks, which say the source language's; arguments it leaves out; plural cases its language does not have (few in English) or lacks (few in Russian, which says other in its place); placeholders a message does not use.

Trying a language

mygo dev -lang de runs the app in German without changing the language it keeps. Two pseudo locales test layouts before any translation exists, as Android's do:

  • en-XA accents the letters of the source language's messages, makes them a third longer and brackets them, [Šéļéçţ ƒîļéš one two]: text that stays plain is not in a catalog, cut text is too short a space, and a sentence of several brackets is made of pieces a translation cannot reorder.
  • ar-XB shows each word right to left, and lays the app out right to left: what does not mirror shows.

In pages

An app with a frontend says the same messages in its pages. Next to the TypeScript client, the CLI writes a module named after the catalogs' directory, such as src/locales.ts, with a typed function per key; an app without a frontend gets none.

import { languages, messages, messagesIn } from "./locales";

heading.textContent = messages.filesSelected(3); // in the language the page shows
messagesIn("de").greeting("Ada");                 // "Hallo, Ada!"

The module holds the messages of every language, and mygo-runtime/i18n formats them with the browser's Intl, so the pages and Go say the same words in the same plural forms. The messages are bundled with the frontend rather than fetched: the first paint is in the right language, without waiting for a request, also in a page of the dev server opened in a browser. messages says each message in the language of the moment: the app's, which MyGo gives the page before its scripts run, else the browser's.

mygo.locale and mygo.dir are there before the page's scripts run. When the language changes, pages hear the mygo:locale event (onLocaleChange of mygo-runtime): draw the page again. Pages loaded later start in the new language. Web views take the language too, for their own texts: WebView2 as its environment is made, which keeps it (a change shows from the next launch), WebKitGTK at once, WKWebView from the next launch.

formatMessage(locale, message, args) from mygo-runtime/i18n formats a message of your own.

Package i18n

The generated code is built on package i18n, which apps can use directly: NewCatalog reads catalogs from any fs.FS, ParseMessage and Message.Format format a message, PluralCategory and OrdinalCategory give the CLDR plural category of a number, written as an integer, a float or a decimal string whose fraction digits count ("1.50" has two), Parent the locale a locale falls back to, and the formatting of numbers and dates. Its data comes from CLDR i18n.CLDRVersion.