Messages in your application are never static. They have variables, pluralization, and formatting. To translate them easily, use ICU MessageFormat.
There is a great package for translations called nicksnyder/go-i18n. However, once I had a lot of translations in chatbots, it started to feel cumbersome.
So, I tried to make translations simpler. Now, instead:
localizer.Localize(&i18n.LocalizeConfig{
DefaultMessage: &i18n.Message{
ID: "PersonCats",
One: "{{.Name}} has {{.Count}} cat.",
Other: "{{.Name}} has {{.Count}} cats.",
},
TemplateData: map[string]interface{}{
"Name": "Nick",
"Count": 2,
},
PluralCount: 2,
}) // Nick has 2 cats.I got:
tr.Trans("person.cats", mf.Arg("name", "Nick"), mf.Arg("cats_num", 2))
// Nick has 2 cats.Import package
import "github.com/fullpipe/icu-mf/mf"Locate messages with go:embed
//go:embed var/messages.*.yaml
var messagesDir embed.FS
// or you could load messages dynamically
messagesDir := os.DirFS("var")Create translations bundle
bundle, err := mf.NewBundle(
// If not possible to find a message for the specific language, fallback to English (EN)
mf.WithDefaultLangFallback(language.English),
// We could fine-tune fallbacks for some languages
mf.WithLangFallback(language.BritishEnglish, language.English),
mf.WithLangFallback(language.Portuguese, language.Spanish),
// Load all yaml files in directory as messages
mf.WithYamlProvider(messagesDir),
// or you could use your own custom message provider
// mf.WithProvider(sqlMessageProvider),
// We assume that the translated messages are mostly correct.
// However, if any errors occur during translation,
// they will be directed to the error handler.
mf.WithErrorHandler(func(err error, id string, ctx map[string]any) {
slog.Error(err.Error(), slog.String("id", id), slog.Any("ctx", ctx))
// or
//panic(err)
}),
)
if err != nil {
log.Fatal(err)
}Translate messages by their ID
tr := bundle.Translator("en")
tr.Trans("invitation.status",
mf.Arg("gender_of_host", "female"),
mf.Arg("num_guests", 5),
mf.Arg("guest", "Sionia"),
mf.Arg("host", "Rina"),
) // Rina invites Sionia and 4 other people to her party.
trEs := bundle.Translator("es")
trEs.Trans("say_hello", mf.Arg("name", "Aníbal"))Full example
package main
import (
"embed"
"log"
"log/slog"
"github.com/fullpipe/icu-mf/mf"
"golang.org/x/text/language"
)
//go:embed var/messages.*.yaml
var messagesDir embed.FS
func main() {
bundle, err := mf.NewBundle(
mf.WithDefaultLangFallback(language.English),
mf.WithLangFallback(language.BritishEnglish, language.English),
mf.WithLangFallback(language.Portuguese, language.Spanish),
mf.WithYamlProvider(messagesDir),
mf.WithErrorHandler(func(err error, id string, ctx map[string]any) {
slog.Error(err.Error(), slog.String("id", id), slog.Any("ctx", ctx))
}),
)
if err != nil {
log.Fatal(err)
}
tr := bundle.Translator("es")
slog.Info(tr.Trans("say_hello", mf.Arg("name", "Bob")))
}YAML allows you to organize your translations in a tree-like structure.
user:
profile:
name: My name is {name}
age: I'm {age, plural, one {# year} other {# years}} old
account_form:
username_field: 'Enter your username:'
error: >-
{name, select
required {specify {field}}
min {{field} requires at least 10 chars}
other {some unknown error with {field}}
}
payments: ...
server:
http:
404: Page not found
503: Oops!And you get messages by their "path"
tr.Trans("user.profile.age", mf.Arg("age", 42))
tr.Trans(
"user.account_form.error",
mf.Arg("name", "min"), mf.Arg("field", "description"),
)Sometimes you need to print {, ', or #. You could escape them with ' char.
# translations/messages.en.yaml
escape: "'{foo} is ''{foo}''"tr.Trans("escape", mf.Arg("foo", "bar"))
// {foo} is 'bar'MessageFormat allows to use placeholders in your messages.
# translations/messages.en.yaml
say_hello: 'Hello, {name}!'Everything in {...} will be processed as an argument and will be replaced by the provided context arguments.
tr.Trans("say_hello", mf.Arg("name", "Bob"))
// Hello, Bob!# translations/messages.en.yaml
# the 'other' key is required, and is selected if no other case matches
invitation:
title: >-
{organizer_gender, select,
female {{organizer_name} has invited you to her party!}
male {{organizer_name} has invited you to his party!}
multiple {{organizer_name} have invited you to their party!}
other {{organizer_name} has invited you to their party!}
}
body: ...tr.Trans(
"invitation.title",
mf.Arg("organizer_name", "Ryan"),
mf.Arg("organizer_gender", "male"),
) // Ryan has invited you to his party!
tr.Trans(
"invitation.title",
mf.Arg("organizer_name", "John & Jane"),
mf.Arg("organizer_gender", "multiple"),
) // John & Jane have invited you to their party!
tr.Trans(
"invitation.title",
mf.Arg("organizer_name", "ACME Company"),
mf.Arg("organizer_gender", "not_applicable"),
) // ACME Company has invited you to their party!As you can see, the {...} syntax behaves differently here:
- The first
{organizer_gender, select, ...}block starts "code" mode, meaningorganizer_genderis processed as a variable. - The inner
{... has invited you to her party!}block switches to "literal" mode, meaning the text inside is processed as sub-message. - Inside this block,
{organizer_name}starts "code" mode again, allowingorganizer_nameto be processed as a variable.
There is another function, plural, similar to select. It allows you to handle pluralization in your messages (e.g., There are 3 apples vs. There is one apple).
# translations/messages.en.yaml
num_of_apples: >-
{apples, plural,
=0 {I don't have an apple}
one {I have one apple}
other {I have # apples!}
}Pluralization rules are actually quite complex and differ for each language. For instance, Russian uses different plural forms for numbers ending with 1; numbers ending with 2, 3, or 4; numbers ending with 5, 6, 7, 8, or 9; and even some exceptions to this!
To properly translate plural forms, the possible cases in the plural function
are also different for each language. For instance, Russian has one, few, many,
and other, while English has only one and other.
The full list of possible cases can be found
in Unicode's Language Plural Rules document.
By prefixing with =, you can match exact values (like 0 in the above example).
# translations/messages.ru.yaml
num_of_apples: >-
{apples, plural,
=0 {У меня нет яблок}
=1 {У меня одно яблоко}
one {У меня # яблоко}
few {У меня # яблока}
many {У меня # яблок}
other {У меня # яблок}
}The usage of this string is the same as with select:
// for EN
tr.Trans("num_of_apples", mf.Arg("apples", 5))
// I have 5 apples!
// for RU
trRU.Trans("num_of_apples", mf.Arg("apples", 3))
// У меня 3 яблокаYou can use the # placeholder to display the pluralized number.
You can also set an offset variable to determine whether the pluralization should be adjusted. For example, in sentences like You and # other people / You and # other person.
# translations/messages.en.yaml
party_status: >-
{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to her party.}
=2 {{host} invites {guest} and one other person to her party.}
other {{host} invites {guest} and # other people to her party.}
}tr.Trans(
"party_status",
mf.Arg("num_guests", 1),
mf.Arg("host", "Rogna"),
mf.Arg("guest", "Azog"),
) // Rogna invites Azog to her party.
tr.Trans(
"party_status",
mf.Arg("num_guests", 5),
mf.Arg("host", "Rogna"),
mf.Arg("guest", "Azog"),
) // Rogna invites Azog and 4 other people to her party.First, we compare num_guests with the strict cases =0, =1, and =2.
If nothing matches, we subtract the offset, num_guests = num_guests - offset,
and then determine the plural case based on the result.
You could make pretty complex nested messages if needed.
# translations/messages.en.yaml
invitation_status: >-
{gender_of_host, select,
female {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to her party.}
=2 {{host} invites {guest} and one other person to her party.}
other {{host} invites {guest} and # other people to her party.}
}}
male {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to his party.}
=2 {{host} invites {guest} and one other person to his party.}
other {{host} invites {guest} and # other people to his party.}
}}
other {{num_guests, plural, offset:1
=0 {{host} does not give a party.}
=1 {{host} invites {guest} to their party.}
=2 {{host} invites {guest} and one other person to their party.}
other {{host} invites {guest} and # other people to their party.}
}}
}Cases in plural, select or selectordinal could be inlined
# translations/messages.en.yaml
num_of_apples: 'There {apples, plural, =0 {are no} one {is one} other {are # apples}} apples'Similar to plural, selectordinal allows you to use numbers as ordinal scale:
# translations/messages.en.yaml
finish_place: >-
You finished {place, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
}!tr.Trans("finish_place", mf.Arg("place", 1))
// You finished 1st!
tr.Trans("finish_place", mf.Arg("place", 9))
// You finished 9th!
tr.Trans("finish_place", mf.Arg("place", 43))
// You finished 43rd!The possible cases for this are also shown in Unicode's Language Plural Rules document.
There are some minor functions to work with numbers and dates.
# translations/messages.en.yaml
big_num: big number {num, number, integer}!
# translations/messages.es.yaml
big_num: gran numero {num, number, integer}!tr.Trans("big_num", mf.Arg("num", 123456789))
// big number 123,456,789!
bundle.Translator("es").Trans("big_num", mf.Arg("num", 123456789))
// gran numero 123.456.789!# translations/messages.en.yaml
test_cover: we got {cover, number, percent} test coverage!tr.Trans("test_cover", mf.Arg("cover", 0.42))
// we got 42% test coverage!
tr.Trans("test_cover", mf.Arg("cover", 1))
// we got 100% test coverage!There are date, time, and datetime functions to format time.Time arguments.
Additionally, there are four different formats: short, medium, long, and full.
# translations/messages.en.yaml
vostok:
start: Vostok-1 start {start_date, datetime, long}.
landing: Vostok-1 landing time {land_time, time, medium}.
apollo:
step: First step on the Moon on {step_date, date, long}.start := time.Date(1961, 4, 12, 6, 7, 3, 0, time.UTC)
land := time.Date(1961, 4, 12, 7, 55, 0, 0, time.UTC)
step := time.Date(1969, 7, 21, 2, 56, 0, 0, time.UTC)
tr.Trans("vostok.start", mf.Time("start_date", start))
// Vostok-1 start April 12, 1961 at 6:07:03 AM UTC.
tr.Trans("vostok.landing", mf.Time("land_time", land))
// Vostok-1 landing time 7:55:00 AM.
tr.Trans("apollo.step", mf.Time("step_date", step))
// First step on the Moon on July 21, 1969.