Skip to content

Languages ​

An app's messages can be shown in the language of the people who use it, in native UI and in pages, with myzig.i18n (after mygo's package i18n). Its messages live in catalogs, a file per language, embedded at build time and checked at compile time: a key a catalog lacks, an argument a call leaves out or gives the wrong type, a translation with keys or arguments its source does not have, or a message that does not parse, is a compile error, not a surprise in production.

zig
const i18n = myzig.i18n;

const L = i18n.Catalog(.{
    .source = "en",
    .files = .{
        .en = @embedFile("locales/en.json"),
        .de = @embedFile("locales/de.json"),
    },
});

fn view(s: *App, c: *ui.Context) void {
    ui.heading(c, L.text(.title));
    ui.text(c, L.t(c, .filesSelected, .{ .count = s.selected.len }), .{});
}

Nothing of it is linked into an app that does not use it, and date data only into one that formats dates.

Catalogs ​

A catalog is an ARB file (JSON, as Flutter's and Chrome's) per language, named by the language's canonical BCP 47 tag: en.json, de.json, zh-Hant.json, pt-BR.json. Each key names a message; keys starting with @ describe the message of the key after them, and @@locale, if given, must be the file's language:

json
{
  "@@locale": "en",
  "title": "Notes",
  "@title": { "description": "The window's title." },
  "greeting": "Hello, {name}!",
  "@greeting": { "placeholders": { "name": { "type": "String", "example": "Ada" } } },
  "filesSelected": "{count, plural, =0 {No files selected} one {# file selected} other {# files selected}}",
  "@filesSelected": { "placeholders": { "count": { "type": "int" } } },
  "price": "Total: {amount, number, ::.00}",
  "@price": { "placeholders": { "amount": { "type": "double" } } },
  "due": "Due {day, date, medium}"
}

A key is a letter, then letters, digits and _; a key <namespace>.<key> changes a library's message (myzig.undo, below). The source catalog (the source option, English by default) has every key; translations may lack some, which then fall back.

.files is a struct of a field per language, as above, or a slice of pairs .{ "de", @embedFile("locales/de.json") }. .pseudo = true adds the pseudo locales (below).

Argument types ​

A placeholder's type is String, int, double or num (a number), DateTime (an i18n.DateTime) or Object. An argument without one gets its type from how the message uses it: counted by plural it is a number, written by date a date. A call giving an argument of another type does not compile:

zig
L.text(.titel)                                  // no member 'titel' in Key
L.text(.greeting)                               // greeting has arguments
try L.printIn(w, "de", .greeting, .{})          // needs the argument name
try L.printIn(w, "de", .filesSelected, .{ .count = "3" })   // takes an integer

Messages ​

Messages are ICU MessageFormat:

SyntaxWhat it writes
{name}an argument
{n, plural, =0 {...} one {# file} other {# files}}a case by CLDR's plural rules of the message's language; # is the number
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}a case by the ordinal rules
{gender, select, female {She} male {He} other {They}}a case by a string
{amount, number}, {amount, number, ::.00}, {p, number, percent}, integer, ::group-offa number in the locale, with a style or a skeleton
{day, date, short|medium|long|full}, {t, time, short}a date or a time in the locale
'{name}', ''quoted text, an apostrophe

Plural cases follow CLDR's rules for every language CLDR has (i18n.cardinalCategory, i18n.ordinalCategory, CLDR i18n.cldr_version): Russian's few and many, Arabic's six, and so on. i18n.exact("1.50") passes a decimal that keeps its fraction digits, whose plural category is that of two fraction digits.

In Zig ​

CallWhat it does
L.text(.key)the message of a key without arguments, in the language the app shows
L.textIn(locale, .key)the same in locale
L.t(c, .key, args)the message for native UI, in the memory of the frame of c
L.print(w, .key, args), L.printIn(w, locale, .key, args)writes it to a std.Io.Writer
L.alloc(gpa, .key, args), L.allocIn(gpa, locale, .key, args)allocates it
L.bufPrintIn(buf, locale, .key, args)writes it into a buffer

L.languages lists the catalog's languages, the source first, and L.missing the keys each language lacks, for a test that wants every translation whole:

zig
test "every translation is whole" {
    inline for (L.missing, 0..) |m, i| {
        if (m.len > 0) std.debug.print("{s} lacks {s}\n", .{ L.languages[i], m[0] });
        try std.testing.expectEqual(@as(usize, 0), m.len);
    }
}

i18n.formatMessage(w, locale, "message", args) writes a message given in the code (parsed at compile time) rather than in a catalog.

The language the app shows ​

The whole app shows one language, i18n.locale() ("en" until the app sets one). The app sets it with i18n.setLocale, on the main thread, from the user's languages and its catalog's, the one that suits the user best:

zig
const langs = try myzig.system.languages(gpa);
defer myzig.system.freeList(gpa, langs);
i18n.setLocale(i18n.matchedLanguage(langs, L.languages));

i18n.locale() is the language the app shows, not the user's: an app that translates its texts itself, without catalogs, gets "en" from it until it calls setLocale, and reads the user's languages from myzig.system.languages. (mygo's App.Locale() changed so in its 0.5: before, it returned the user's first preferred language whatever the app is written in.)

i18n.match(preferred, supported) returns the index of the best match, or null: the language and region first (en-GB for en-AU, both of en-001), then the language alone, then its usual region, then any other. A language the reader prefers more wins over a closer match of one they prefer less: fr for fr-CA wins over de for de. matchedLanguage falls back to the first supported language, the app's source.

A message resolves in the language shown, its fallbacks by CLDR's parent locales (i18n.parent, i18n.chain: en-AU, en-001, en), then the source language. Other threads name a locale themselves (printIn, textIn).

When the language changes ​

setLocale counts a generation, i18n.localeGeneration(), which views that keep text compare to know it changed. Native UI builds every frame from the state, so a view that says its messages with L.text and L.t shows the new language in the next frame; ask for one with the window's redraw after setting it.

Right to left ​

i18n.direction(tag) tells whether a language is written right to left (Arabic, Hebrew, Persian, Urdu, by their scripts), and ui.direction.fromLocale(c, i18n.locale()) lays a window of native UI out so (see Right to left).

Regional formats ​

myzig.system.locale(gpa) is the user's locale ("en-US") and myzig.system.region(gpa) their region ("US"). i18n.regionFor(locale) gives an i18n.Region: its locale, the day calendars start their weeks on (first_weekday, 0 Sunday to 6 Saturday) and its hour cycle (12 or 24). i18n.firstWeekday(locale), i18n.hourCycle(locale) and the -u-fw and -u-hc extensions of a tag say the same; i18n.autonym(locale) names a language in itself ("Deutsch", "简体中文"), for a menu of languages.

Numbers and dates ​

Numbers and dates are written as ICU and Intl write them, with the data of CLDR for 62 locales and those that fall back to them: separators, grouping, digits, month and weekday names, patterns by style and skeleton, 12 or 24 hours.

zig
var buf: [64]u8 = undefined;
_ = try i18n.bufFormatNumber(&buf, "de", 1234.5, .{});                  // "1.234,5"
_ = try i18n.bufFormatNumber(&buf, "en", 0.25, .{ .percent = true });   // "25%"
_ = try i18n.bufFormatNumber(&buf, "en", 1.5, .{ .min_fraction = 2 });  // "1.50"

const t = i18n.DateTime.utc(2026, 1, 5, 13, 7, 9);
_ = try i18n.bufFormatDate(&buf, "en", t, .{ .date = .medium });        // "Jan 5, 2026"
_ = try i18n.bufFormatDate(&buf, "en", t, .{ .time = .short });         // "1:07 PM"
_ = try i18n.bufFormatDate(&buf, "en", t, .{ .skeleton = "MMMd" });     // "Jan 5"

i18n.NumberOptions has percent, integer, min_fraction, max_fraction, min_integer, grouping and half_even (rounding half to even, as ICU's MessageFormat does, rather than away from zero, as Intl does). i18n.DateOptions has date and time styles (.short, .medium, .long, .full), a CLDR skeleton ("yMMMd", "MMMd", "Ed", "jm", ... of i18n.date.skeletons) and hour_cycle (12 or 24 to follow the user's settings). An i18n.DateTime is seconds since 1970 in UTC with an optional offset_minutes of its zone. formatNumber and formatDate write to a std.Io.Writer.

i18n.months(locale, width, stand_alone), i18n.weekdays(...) and i18n.dayPeriods(locale, width) give the names, by i18n.Width (.wide, .abbreviated, .short, .narrow), as calendars want them.

myzig's own strings ​

What myzig's controls and menus say ("Undo", "Next month", "Quit Notes", the names assistive technology reads) comes from myzig.l10n, in the language the app shows: English, and translations to Simplified and Traditional Chinese and Japanese (src/l10n/en.json, zh-Hans.json, zh-Hant.json, ja.json, from mygo's catalog).

A translation is compiled in only for an app that ships its language, so an app carries none it does not show: those of the app's catalog, which its root file names, and any it names itself:

zig
// in main.zig
pub const myzig_catalog = L;              // an app with zh-TW.json gets zh-Hant
pub const myzig_languages = .{"zh-Hans"}; // and these, besides

An app changes myzig's strings, or translates them to its other languages, with keys myzig.<key> in its catalog:

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

A string resolves in the app's language, its fallbacks and the app's source language among the app's overrides, then among myzig's translations, then in English. Saying one costs a lookup and allocates nothing (myzig.l10n.text(.undo), textIn(locale, key), printIn(w, locale, key, args)).

Trying a language ​

With .pseudo = true, a catalog has two pseudo locales, made from the source's messages, to see an interface's layout before it is translated:

  • en-XA (i18n.pseudo_accents) accents the letters, makes the text about 40% longer and brackets it ("[Šéļéçţ ƒîļéš one two]"), to find strings that are not in a catalog, are cut, or are put together from pieces.
  • ar-XB (i18n.pseudo_bidi) shows each word right to left, and the app is laid out right to left, to find what does not mirror.
zig
i18n.setLocale(i18n.pseudo_accents);

i18n.isPseudo(tag) tells them apart. They use English's plural rules.

In pages ​

A catalog's messages reach pages through the generated client (myzig.js, myzig.d.ts; see Bindings), with a typed function per key that says the message in the page's language with the browser's Intl, by the same messages, cases and fallbacks as in Zig. The messages are in the client, so the first paint is in the right language. Register the catalog as the app registers events:

zig
try app.registerEvent(i18n.Page(L)); // adds `messages` to the client
ts
import { messages, messagesIn, languages, onLocaleChange } from "./myzig.js";

heading.textContent = messages.filesSelected(3);
messagesIn("de").greeting("Ada"); // "Hallo, Ada!"
onLocaleChange((locale) => render());

messages says each message in the page's language: window.myzig.locale when set, else the document's lang, else navigator.language. onLocaleChange listens to the page event myzig:locale; myzig does not send it by itself yet, so an app that changes the language of a page sends that event, or the page renders again on its own.

Package reference ​

src/i18n/i18n.zig exports: Catalog, Message, formatMessage, bufFormatMessage, Exact, exact; locale, setLocale, localeGeneration; match, matchOne, matchedLanguage, fallbacks, Fallbacks; Tag, normalize, parent, chain, base, maximize, likelyScript, likelyRegion, Direction, direction, isPseudo, pseudo_accents, pseudo_bidi; Plural, Operands, Categories, cardinalCategory, ordinalCategory, cardinalCategories, ordinalCategories; NumberOptions, formatNumber, bufFormatNumber; date, DateTime, DateOptions, Style, Width, formatDate, bufFormatDate, months, weekdays, dayPeriods; firstWeekday, hourCycle, autonym, Region, regionFor; Page; and message, the parser, for tools that read messages at compile time.

The tables of plural rules, numbers and dates are generated from CLDR by the scripts in src/i18n/gen/, and tested against ICU's own outputs (src/i18n/tests_*).

Released under the MIT License.