Software localization
A Simple Way to Internationalize in Go with go-i18n
In the past, we dealt with i18n in Go using the golang.org/x/text package. Although extensive, the library isn't that easy to use in practice, and its documentation lacks clarity. With this shortcoming in mind, let's have a look at another, much easier way to localize Go apps: go-i18n.
The go-i18n library supports:
- Pluralized strings for all 200+ languages
- Strings with named variables
- Message files of any format (e.g. JSON, TOML, YAML, etc.).
- Well documented
However, for the time being, it does not support gender rules or complex template variables, but for a lot of cases, it should be enough to localize existing apps. In this tutorial, we will see some practical examples and also try to integrate Phrase's In-context Editor in the process. All the code examples are hosted also on GitHub. Let's get started.
Defining and Translating Messages
Before we use this library we need to download and install it to our $GOPATH. Let's do that now:
$ go get -u github.com/nicksnyder/go-i18n/v2/i18n
Now create a new file to test some translations:
$ touch example.go
File: example.go
package main import ( "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" ) func main() { }
The first step is to create a Locale Bundle that will contain the list of supported locales and the default locale. Let's create one with default as English
// Step 1: Create bundle func main() { bundle := &i18n.Bundle{DefaultLanguage: language.English} }
Now in order to perform translations, we need to create an instance of a Localizer passing a list of locales we want to translate. If we have a list of translated locales it will pick the right locale based on the language tags
// Step 2: Create localizer for that bundle using one or more language tags loc := i18n.NewLocalizer(bundle, language.English.String())
As we haven't got any messages we can add them now.
// Step 3: Define messages messages := &i18n.Message{ ID: "Emails", Description: "The number of unread emails a user has", One: "{{.Name}} has {{.Count}} email.", Other: "{{.Name}} has {{.Count}} emails.", }
We can see the usage of Plural rules here and the usage of template variables.
In the final step we need to perform a translation:
// Step 3: Localize Messages messagesCount := 2 translation := loc.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: messages, TemplateData: map[string]interface{}{ "Name": "Theo", "Count": messagesCount, }, PluralCount: messagesCount, }) fmt.Println(translation)
The MustLocalize method will panic if there is an error. There is an associated Localize method that will return an error instead.
In the code above its crucial that we pass the messagesCount in both the TemplateData and in the PluralCount property to properly translate the plural rule.
Defining delimiters
We have an option to define different delimiter characters just in case we dislike the double brackets. We only need to define the LeftDelim and RightDelim properties and change the message strings to include them.
// Define different delimiters messages = &i18n.Message{ ID: "Notifications", Description: "The number of unread notifications a user has", One: "<<.Name>> has <<.Count>> notification.", Other: "<<.Name>> has <<.Count>> notifications.", LeftDelim: "<<", RightDelim: ">>", } notificationsCount := 1 translation = loc.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: messages, TemplateData: map[string]interface{}{ "Name": "Theo", "Count": notificationsCount, }, PluralCount: notificationsCount, }) fmt.Println(translation)
Loading messages from files
We also have the option to load translations from files. To do that we need to first register an Unmarshal Function in our bundle and load the messages from a file.
// Unmarshaling from files bundle.RegisterUnmarshalFunc("json", json.Unmarshal) bundle.MustLoadMessageFile("en.json") bundle.MustLoadMessageFile("el.json") loc = i18n.NewLocalizer(bundle, "el") messagesCount = 10 translation = loc.MustLocalize(&i18n.LocalizeConfig{ MessageID: "messages", TemplateData: map[string]interface{}{ "Name": "Alex", "Count": messagesCount, }, PluralCount: messagesCount, }) fmt.Println(translation)
The contents of the JSON files are:
File: el.json
{ "hello_world": "Για σου Κόσμε", "messages": { "description": "The number of messages a person has", "one": "Ο {{.Name}} έχει {{.Count}} μύνημα.", "other": "Ο {{.Name}} έχει {{.Count}} μύνηματα." } }
File: en.json
{ "hello_world": "Hello World", "messages": { "description": "The number of messages a person has", "one": "{{.Name}} has {{.Count}} message.", "other": "{{.Name}} has {{.Count}} messages." } }
With the complete program try to run it and see the translations happening.
$ go run example.go Theo has 2 emails. Nick has 1 notification. Ο Alex έχει 10 μύνηματα.
Using the command line tool
This library also comes with a command line tool to help to automate the process of extracting and merging translation files.
First, we need to install it
$ go get -u github.com/nicksnyder/go-i18n/v2/goi18n
Currently, there are 2 commands provided:
- extract: Extracts messages from sources and outputs to a file with a specific format
- merge: Merges messages from 2 or more files with a specific format
Let's see some examples of both
Create a file named messages.go
$ touch messages.go
File: messages.go
package main import "github.com/nicksnyder/go-i18n/v2/i18n" var messages = i18n.Message{ ID: "invoices", Description: "The number of invoices a person has", One: "You can {{.Count}} invoice", Other: "You have {{.Count}} invoices", }
Use the extract command to export the messages in JSON format.
$ mkdir out $ goi18n extract -outdir=out -format=json newMessages.go
File: out/active.en.json
{ "invoices": { "description": "The number of invoices a person has", "one": "You can {{.Count}} invoice", "other": "You have {{.Count}} invoices" } }
Now using the existing translation files lets merge them together:
$ goi18n merge -outdir=out -format=json en.json out/active.en.json
File: out/active.en.json
{ "hello_world": "Hello World", "invoices": { "description": "The number of invoices a person has", "one": "You can {{.Count}} invoice", "other": "You have {{.Count}} invoices" }, "messages": { "description": "The number of messages a person has", "one": "{{.Name}} has {{.Count}} message.", "other": "{{.Name}} has {{.Count}} messages." } }
As you can see we have all the messages conveniently in a single file.
Integrating Phrase In-context Editor
Phrase's in-context editor is a translation tool that helps the process by providing useful contextual information which improves overall translation quality. You simply browse your website and edit text along the way.
Although there is no integration for go-i18n, you can follow this guide:
https://support.phrase.com/hc/en-us/articles/6111346177820-JSON-go-i18n-Strings-
and we can register our own template filter and integrate it into our app.
Let's see how we can do that in simple steps.
Create a new file named inContext.go and add the following code.
$ touch inContext.go
File: inContext.go
package main import ( "html/template" "log" "net/http" "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" "encoding/json" "flag" "fmt" ) var page = template.Must(template.New("").Parse(` <!DOCTYPE html> <html> <body> <h1>{{ .Title }}</h1> {{range .Paragraphs}}<p>{{ . }}</p>{{end}} </body> </html> `)) func main() { bundle := &i18n.Bundle{DefaultLanguage: language.English} bundle.RegisterUnmarshalFunc("json", json.Unmarshal) for _,lang := range []string{"en" ,"el"} { bundle.MustLoadMessageFile(fmt.Sprintf("active.%v.json", lang)) } http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { lang := r.FormValue("lang") accept := r.Header.Get("Accept-Language") localizer := i18n.NewLocalizer(bundle, lang, accept) name := r.FormValue("name") if name == "" { name = "Alex" } myInvoicesCount := 10 helloPerson := localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ ID: "HelloPerson", }, TemplateData: map[string]interface{}{ "Name": name, }, }) myInvoices := localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ ID: "invoices", }, TemplateData: map[string]interface{}{ "Count": myInvoicesCount, }, PluralCount: myInvoicesCount, }) err := page.Execute(w, map[string]interface{}{ "Title": helloPerson, "Paragraphs": []string{ myInvoices, }, }) if err != nil { panic(err) } }) log.Fatal(http.ListenAndServe(":8080", nil)) }
This will create a web server and it will serve a page with a default language. If you open the browser and navigate to localhost:8080/?lang=el you will see the Greek translations.
Now in order to integrate Phrase's In-context Editor we need to wrap the Template variables within the {{__phrase_ and __}} delimiters and load the javascript agent.
We can utilize the https://golang.org/pkg/text/template/#Template.Funcs functionality to register our own translation filter and wrap that parameter once we configure it. Let's do that now.
File: inContext.go
// get from config var isPhraseAppEnabled bool func init() { flag.BoolVar(&isPhraseAppEnabled,"phraseApp", false, "Enable PhraseApp mode") flag.Parse() } var apiToken = os.Getenv("PHRASE_APP_TOKEN") func translate(s string) string { if isPhraseAppEnabled { return "{{__phrase_" + s + "__}}" } else { return s } } var funcs = template.FuncMap{ "translate": translate, }
Here we add the translate function to be configured based on the phraseApp parameter config.
Now we only need to add this filter to each template parameter and add the Phrase script.
var page = template.Must(template.New("").Funcs(funcs).Parse(` <!DOCTYPE html> <html lang= {{ .CurrentLocale }}> <body> <h1>{{ translate .Title }}</h1> {{range .Paragraphs}}<p>{{ translate . }}</p>{{end}} </body> window.PHRASEAPP_CONFIG = { projectId: {{ .apiToken }} }; (function() { var phraseapp = document.createElement('script'); phraseapp.type = 'text/javascript'; phraseapp.async = true; phraseapp.src = ['https://', 'phraseapp.com/assets/in-context-editor/2.0/app.js?', new Date().getTime()].join(''); var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(phraseapp, s); })(); </html> `))
Update the template to include the apiToken parameter.
err := page.Execute(w, map[string]interface{}{ "apiToken": apiToken, "Title": helloPerson, "CurrentLocale": language.Greek.String(), "Paragraphs": []string{ myInvoices, }, })
If you haven't done that already, navigate to https://phrase.com/ signup to get a trial version.
Once you set your account up, you can create a project and navigate to Project Setting to find your projectId key.
Use that to assign the PHRASE_APP_TOKEN environment variable before you start the server.
When you navigate to the page you will see a login modal and once you are authenticated you will see the translated strings change to include edit buttons next to them. The In-context Editor panel will show up as well.
From there, you can manage your translations easier.
Conclusion
In this article, we have seen how to translate go applications using the go-i18n library. We’ve also seen how can we integrate Phrase’s in-context editor in our workflow. Thank you for reading and see you again next time!