Software localization
A Step-by-Step Guide to Go Internationalization
Go has become a “go-to” programming language (no pun intended) for authoring cloud services, dev ops, web dev, and more. The systems language has seen adoption from industry giants like Dropbox, Microsoft, Netflix, and Riot Games. So it’s puzzling that Go i18n (internationalization) and l10n (localization) are two of the least developed features in the language’s Standard Library. Fortunately for us, some good third-party Go libraries fill that gap.
And what Go’s standard library lacks in i18n features, it makes up for with functionality for handling character encodings, text transformations, and locale-specific text processing. Third-party libraries can take care of the rest, giving us gettext support and additional Unicode processing, so we have all our Go i18n and l10 needs covered.
This hands-on guide explores making Go applications locale-aware using gettext tools and the gotext package. We’ll start with the basics of i18n, work with translations, and delve into date and number formatting.
🔗 Resource » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and in different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.
Before we dive into the code, let’s briefly discuss the current state of Go i18n.
The state of Go i18n and l10n
In the latest version at the time of writing, v1.22, Go includes some built-in capabilities for i18n but lags behind other languages. We often need third-party libraries to complete our i18n solutions. One popular package for i18n in Go is Nick Snyder’s go-i18n, which provides tools for managing localized strings in Go applications.
🔗 Resource » Our guide, A Simple Way to Internationalize in Go with go-i18n, covers working with Synder’s library in depth.
In this tutorial, we’ll instead focus on the gotext i18n package by Leonel Quinteros, which provides gettext
utilities for Go. gettext is a mature, widely used i18n package from FOSS (free and open source) legends, GNU. gotext
provides the core functionality of gettext
to Go developers.
🔗 Resource » Learn more about GNU gettext and its tools
With gotext
Go devs have a comprehensive set of tools and conventions for handling multilingual text. gotext
includes utilities for extracting translatable strings from source code, generating message catalogs for different languages, and runtime libraries for loading translated strings into applications.
Our demo app
The demo we’ll work with in this guide is a simple web app: a video game speedrunning leaderboard that supports multiple locales. We’re keeping the demo very light to focus on i18n here.
Here’s what Speedrun Leaderboard looks like:
Packages used
Below is a list of the packages we’ll use as we develop our app, along with their versions and descriptions:
Package | Version | Comment |
go | 1.22 | |
https://github.com/leonelquinteros/gotext | 1.5.2 | Our main i18n package. |
http://golang.org/x/text | 0.14.0 | Supplementary Unicode text processing libraries. |
OK, coding time. Start by initializing a new Go project from the command line:
$ go mod init PhraseApp-Blog/go-internationalization
$ go mod tidy
Code language: Bash (bash)
Create the code for the backend server in main.go. The main
function defines the Speedrun model, initiates the HTTP server handlers, and starts the application. It also populates the initial leaderboard with some sample data.
// main.go
package main
import (
"html/template"
"net/http"
"time"
)
type Speedrun struct {
PlayerName string `json:"player_name"`
Game string `json:"game"`
Category string `json:"category"`
Time string `json:"time"`
SubmittedAt time.Time `json:"submitted_at"`
}
var speedruns []Speedrun
func main() {
// Initialize sample data
speedruns = []Speedrun{
{PlayerName: "Alex", Game: "Super Mario 64", Category: "Any%",
Time: "16:58", SubmittedAt: time.Now()},
{PlayerName: "Theo", Game: "The Legend of Zelda: Ocarina of Time",
Category: "Any%", Time: "1:20:41", SubmittedAt: time.Now()},
}
// Define routes
http.HandleFunc("/", handleIndex)
http.HandleFunc("/speedruns", handleSpeedruns)
http.HandleFunc("/speedruns/add", handleSpeedrunForm)
http.HandleFunc("/speedrun.html", handleSpeedrunForm)
// Serve static files
http.Handle("/static/", http.StripPrefix("/static/",
http.FileServer(http.Dir("./static"))))
// Start server
fmt.Println("Server listening on port 8080...")
http.ListenAndServe(":8080", nil)
}
Code language: Go (go)
The rest of the code provides the handlers that serve each endpoint. The root (/
) and /speedruns/add
endpoints are served as HTML templates. /speedruns
responds with a JSON list.
// main.go
func handleIndex(w http.ResponseWriter, r *http.Request) {
// Execute the index.html template
tmpl, err := template.ParseFiles("static/index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render the template with the speedruns data
err = tmpl.Execute(w, speedruns)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func handleSpeedruns(w http.ResponseWriter, r *http.Request) {
// Send speedrun data as JSON response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(speedruns)
}
func handleSpeedrunForm(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// Parse request body to get new speedrun data
var speedrun Speedrun
err := json.NewDecoder(r.Body).Decode(&speedrun)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Add the current date as the submitted date
speedrun.SubmittedAt = time.Now()
// Add new speedrun to the global speedruns slice
speedruns = append(speedruns, speedrun)
// Send success response
fmt.Fprintln(w, "Speedrun submitted successfully!")
} else {
http.ServeFile(w, r, "static/speedrun.html")
}
}
Code language: Go (go)
Next, the code for the front-end UI is also a simple view that renders the current leaderboard. It populates the list of entries from the data that we passed on the template:
<!-- static/index.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<div class="container">
<h1>Speedrun Leaderboard</h1>
<table>
<thead>
<tr>
<th>Player Name</th>
<th>Game</th>
<th>Category</th>
<th>Time</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td>{{.PlayerName}}</td>
<td>{{.Game}}</td>
<td>{{.Category}}</td>
<td>{{.Time}}</td>
<td>{{.SubmittedAt.Format "2006-01-02"}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</body>
</html>
Code language: HTML, XML (xml)
Save the above file as index.html
in the static
directory.
🗒️ Note » For brevity, we omit all style code in this tutorial, except styles that relate to localization. You can get all the code for this demo app from GitHub, including styles.
Next, let’s create the speedrun.html
file for adding a new speedrun entry. This would render a form that users could fill out to add new Speedrun entries.
<!-- static/speedrun.html -->
<!DOCTYPE html>
<html lang="en">
<body>
<div class="container">
<h2>Add New Speedrun</h2>
<form id="speedrunForm">
<label for="playerName">Player Name:</label>
<input type="text" id="playerName" name="playerName" required /><br />
<label for="game">Game:</label>
<input type="text" id="game" name="game" required /><br />
<label for="category">Category:</label>
<input type="text" id="category" name="category" required /><br />
<label for="time">Time:</label>
<input type="text" id="time" name="time" required /><br />
<button type="submit">Submit</button>
</form>
</div>
<script>
// ... code to handle form
</script>
</body>
</html>
Code language: HTML, XML (xml)
We should save this file as speedrun.html
in the static
directory.
With these changes, hitting the /
route will render the leaderboard list (index.html
), and accessing /speedruns/add
will render the form to add a new speedrun entry (speedrun.html
).
🔗 Resource » Get all the starter code from GitHub
How do I localize my app with gotext i18n?
Let’s take a high-level overview of how to localize your Go application using the gotext
package:
1. Install gotext
and gettext
.
2. Set up locale directories and translation files for each supported locale.
3. Configure the gotext
package and add supported locales to your application.
4. Translate strings: Translators provide translations for each string, stored in PO files.
5. Load translations for a specific language using the gotext.Get
function.
6. Localize the dates, numbers, and plural forms.
Let’s cover these in detail.
How do I install gotext?
To install the gotext
package for your Go application, you can use the go get
command followed by the package’s repository URL:
$ go get github.com/leonelquinteros/gotext
Code language: Bash (bash)
🗒️ Heads up » Since gotext
depends on gettext
, please ensure that gettext
is installed on your system using this guide before continuing. Note that on Linux and macOS environments, gettext
should already be installed.
Setting up locale directories
gettext
assumes a specific directory structure for managing translations. Organizing translations into per-locale files under a dedicated directory structure makes it easier to manage translations, collaborate with translators, and maintain a structured codebase.
Let’s go through the steps for setting up these locale directories:
First, let’s create a script to automate the creation of locale directories and default translation files. Save the following script as scripts/initialize_locales.sh
:
# scripts/initialize_locales.sh
#!/bin/bash
initialize_locale_directories() {
# Iterate over each provided language code
for lang_code in "$@"; do
# Define locale directory path
locale_dir="locales/$lang_code/LC_MESSAGES"
# Create the locale directory if it doesn't exist
mkdir -p "$locale_dir"
# Initialize the default.po file using msginit
msginit --no-translator -o "$locale_dir/default.po" \
-l "$lang_code" -i "locales/default.po"
# Print status message
echo "Initialized locale directory"
echo "for $lang_code"
done
}
# Check if any language codes are provided as arguments
if [ $# -eq 0 ]; then
echo "Error: No language codes provided."
echo "Please provide one or more language codes as arguments."
exit 1
fi
initialize_locale_directories "$@"
Code language: Bash (bash)
Now run the script to create locale directories and default translation files. For example, to create directories for English-America (
), Greek-Greece (en_US
el_GR
), and Arabic-Saudi-Arabia (ar_SA
), we would run:
$ ./scripts/initialize_locales.sh en_US el_GR ar_SA
Code language: Bash (bash)
This will create the following tree structure:
root
└── locales
├── en_US
│ └── LC_MESSAGES
│ └── default.po
├── el_GR
│ └── LC_MESSAGES
│ └── default.po
└── ar_SA
└── LC_MESSAGES
└── default.po
Code language: plaintext (plaintext)
Once the translation files are initialized, translators can populate them with the translated messages for each locale. Each PO file contains the translations for a specific locale, making it easier to manage and collaborate on translations.
🗒️ Note » You can create the above directory structure and files manually. The Bash script above scales well as you add locales, but you don’t have to use it to follow along here.
A note on locales
A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en
for English, fr
for French, and es
for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH
for Bahrain, CN
for China, US
for the United States) is recommended for accurate date and number localization. So a complete locale might look like
for American English or en_US
zh_CN
for Chinese as used in China.
🔗 Resource » Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.
How do I configure the gotext package?
Next, we need to configure the gotext
package. Since we have laid out our locale directories, we need a way to load the available locales and the default locale based on standard Unix/Linux environmental variables.
🗒️ Note » If you are running this example on non-UNIX/Linux systems, you need to make sure that one of the following environmental variables is set to the initial language type so that the app can pick up the correct locale: LANGUAGE
,
, LC_ALL
LCMESSAGES
or LANG
.
Configure available locales on start-up
The following code handles supported locales in your application. We can define the supported language codes and map them to their respective locales using the gotext
package. Here’s an example of how to do it:
// pkg/i18n/lang.go
package i18n
import (
"github.com/leonelquinteros/gotext"
)
type LanguageCode string
// Supported locales
const (
GR LanguageCode = "el_GR" // Greek
EN LanguageCode = "en_US" // English
AR LanguageCode = "ar_SA" // Arabic
)
// langMap stores Locale instances for each language code.
var langMap = make(map[LanguageCode]*gotext.Locale)
// String returns the string representation of a LanguageCode.
func (l LanguageCode) String() string {
return string(l)
}
// T returns the translated string for the given key
// in the specified language.
func (l LanguageCode) T(s string) string {
if lang, ok := langMap[l]; ok {
return lang.Get(s)
}
// Return the original key if no translation is available
return s
}
Code language: Go (go)
The langMap
specifies a mapping of all supported locales that the application supports. It is used to match a string input type to a gotext.Locale
type. We use this mapping in the next step of the code: configuring gotext
based on the user’s preferred language and setting up the locales accordingly.
// pkg/i18n/i18n.go
package i18n
import (
"fmt"
"os"
"path"
"strings"
"github.com/leonelquinteros/gotext"
)
var (
defaultDomain = "default"
)
func Init() error {
localePath, err := getLocalePath()
if err != nil {
return err
}
languageCode := getLanguageCode()
fullLocale := NewLanguageFromString(languageCode).String()
gotext.Configure(localePath, fullLocale, defaultDomain)
setupLocales(localePath)
fmt.Println("languageCode:", fullLocale)
return nil
}
// Returns the language code from environment
// variables LANGUAGE, LC_ALL, or LC_MESSAGES,
// in that order of priority.
// It returns an empty string if none of the
// variables are set.
func getLanguageCode() string {
// Check LANGUAGE environment variable
if lc := os.Getenv("LANGUAGE"); lc != "" {
return lc
}
// Check LC_ALL environment variable
if lc := os.Getenv("LC_ALL"); lc != "" {
return lc
}
// Check LC_MESSAGES environment variable
if lc := os.Getenv("LC_MESSAGES"); lc != "" {
return lc
}
// No language code found in environment variables
return os.Getenv("LANG")
}
func setupLocales(localePath string) {
// Get a list of all directories in the locale path
localeDirs, err := os.ReadDir(localePath)
if err != nil {
return err
}
// Iterate over each directory and add it
// as a supported language
for _, dir := range localeDirs {
if dir.IsDir() {
langCode := LanguageCode(dir.Name())
lang := gotext.NewLocale(localePath, langCode.String())
lang.AddDomain(defaultDomain)
langMap[langCode] = lang
}
}
return nil
}
func getSupportedLanguages() []LanguageCode {
var languages []LanguageCode
for lang := range langMap {
languages = append(languages, lang)
}
return languages
}
func NewLanguageFromString(code string) LanguageCode {
code = strings.ToLower(code)
if strings.Contains(code, "en") {
return EN
} else if strings.Contains(code, "el") {
return GR
}
return AR
}
func T(s string) string {
return gotext.Get(s)
}
func GetCurrentLanguage() LanguageCode {
return NewLang(getLanguageCode())
}
Code language: Go (go)
🗒️ Note » The getLocalePath
and getPwdDirPath
functions are defined in the pkg/i18n/helpers.go
inside the GitHub project repo.
The above code defines some useful helpers to detect the initial locale from the environment, register the list of available locales, and get the current locale.
We can now call the Init
function in your main.go
file to initialize the localization setup. Here’s how we can do it:
// main.go
package main
import (
+ "PhraseApp-Blog/go-internationalization/pkg/i18n"
+ "log"
// ...
)
func main() {
+ // Initialize i18n package
+ if err := i18n.Init(); err != nil {
+ log.Fatalf("failed to initialize i18n: %v", err)
+ }
// ...
}
Code language: Diff (diff)
To start the project with different environmental variables controlling the locale, you can set the desired language using the LANGUAGE
,
, LC_ALL
LCMESSAGES
, or LANG
environment variables before running the application. Here’s an example:
# Set the desired language environment variable
$ export LANGUAGE=en_US
$ go run main.go
# => languageCode: en_US.UTF-8
$ export LANGUAGE=el
$ go run main.go
# => languageCode: el_GR
Code language: Bash (bash)
The advantage of this approach is that your application can preload all locale resources upon startup and seamlessly switch to the default locale. This method can be particularly useful when localizing CLI applications where you want to switch to a suitable locale on startup automatically.
Now that we’ve configured the gotext
package, we can translate our app. Let’s switch our focus to extracting and managing the strings to be translated, and integrating these translations into our application.
How do I work with translation messages?
Let’s illustrate how to use translation messages in our existing code. Suppose we have a button label in our application that says “Submit” and we want that string to be translated into different languages. Instead of hardcoding the label in our HTML or Go code, we can use our new i18n.T()
function to fetch the translated label dynamically.
Here’s an example of using the i18n.T()
function to translate the “Submit” button label in our HTML template. Remember that the i18n.T()
function internally calls the gotext.Get
function that translates text from the default domain:
<button>{{ i18n.T("Submit") }}</button>
Code language: Go (go)
In this example, i18n.T("Submit")
retrieves the translated version of the “Submit” button label based on the current locale settings.
We can also translate strings using the i18n.T()
function in our Go code. For instance, if we have an error message that says “An error occurred”, we can use the i18n.T()
function to translate it like so:
errorMessage := i18n.T("An error occurred")
Code language: Go (go)
Let’s replace all our hardcoded strings with i18n.T()
calls.
// main.go
- fmt.Println("Server listening on port 8080...")
+ fmt.Println(i18n.T("Server listening on port 8080..."))
...
// Send success response
- fmt.Fprintln(w, "Speedrun submitted successfully!")
+ fmt.Fprintln(w, i18n.T("Speedrun submitted successfully!"))
Code language: Diff (diff)
Now go ahead and provide a translation for those strings in Greek:
# locales/el_GR/LC_MESSAGES/default.po
msgid "Server listening on port 8080..."
msgstr "Ακρόαση Διακομιστή στη θύρα 8080..."
msgid "Speedrun submitted successfully!"
msgstr "Το Speedrun υποβλήθηκε με επιτυχία!"
Code language: plaintext (plaintext)
If you restart the server with the Greek locale configured, you will see the translated message printed in the console:
$ export LANGUAGE=el
$ go run main.go
# => languageCode: el_GR
# => Ακρόαση Διακομιστή στη θύρα 8080...
Code language: Bash (bash)
Now let’s see how to interpolate runtime variables into translation messages.
How do I inject runtime values into translation messages?
We can use placeholders in our translated strings to interpolate dynamic values into translation messages. gotext
supports this functionality through the gotext.Get
function.
We include placeholders in our translation messages using formats such as %s
for strings, %d
for integers, %f
for floats, etc. Then, when calling gotext.Get
, we can provide the necessary values as arguments to replace these placeholders.
We need to update our T
function to accept dynamic values. We can utilize Go’s variadic parameters to do this. Here’s the modified T
function:
// pkg/i18n/i18n.go
- func T(s string) string {
- return gotext.Get(s)
- }
+ func T(s string, args ...interface{}) string {
+ return gotext.Get(s, args...)
+ }
Code language: Diff (diff)
After updating the message catalog, you might need to update the translated string as well:
# locales/el_GR/LC_MESSAGES/default.po
msgid "Server listening on port %d..."
msgstr "Ακρόαση Διακομιστή στη θύρα %d..."
Code language: plaintext (plaintext)
Let’s replace all occurrences of strings that will benefit from this feature:
- fmt.Println(i18n.T("Server listening on port 8080..."))
+ fmt.Println(i18n.T("Server listening on port %d...", 8080))
Code language: Diff (diff)
OK, we’ve been focusing on translating the backend message strings. In the upcoming section, we’ll delve into localizing our HTML templates, ensuring that the front-end part of our application is also being rendered with the right translated content.
How do I localize Go templates?
Localizing Go templates involves translating the static text within the templates and adjusting the layout or structure based on language preferences.
Include the translation function in router handlers
To make T()
available in our templates, we need to inject this function into our route handlers. Let’s create a handlers file to do this.
// pkg/handlers/handlers.go
package handlers
import (
"PhraseApp-Blog/go-internationalization/pkg/i18n"
"PhraseApp-Blog/go-internationalization/pkg/model"
"html/template"
"net/http"
)
func HandleTemplate(w http.ResponseWriter, r *http.Request,
tmplName string,
data interface{}) {
// Parse the template
tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
"T": i18n.T,
}).ParseFiles("static/" + tmplName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Execute the template with the translation function and data
err = tmpl.Execute(w, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func HandleIndex(w http.ResponseWriter,
r *http.Request, data []model.Speedrun) {
HandleTemplate(w, r, "index.html", map[string]interface{}{
"Title": "Speedrun Leaderboard",
"Header": "Speedrun Leaderboard",
"PlayerName": "Player Name",
"Game": "Game",
"Category": "Category",
"Time": "Time",
"Date": "Submitted At",
"Data": data,
})
}
func HandleSpeedrun(w http.ResponseWriter, r *http.Request) {
HandleTemplate(w, r, "speedrun.html", map[string]interface{}{
"Header": "Add New Speedrun",
"Title": "Add New Speedrun",
"PlayerName": "Player Name",
"Game": "Game",
"Category": "Category",
"Submit": "Submit",
"Time": "Time",
})
}
Code language: Go (go)
And update the main.go
to use new handler functions:
// main.go
package main
func handleIndex(w http.ResponseWriter, r *http.Request) {
- http.ServeFile(w, r, "static/index.html")
+ handlers.HandleIndex(w, r, speedruns)
}
func handleSpeedrunForm(w http.ResponseWriter, r *http.Request) {
- http.ServeFile(w, r, "static/speedrun.html")
+ handlers.HandleSpeedrun(w, r)
}
Code language: Diff (diff)
Updating templates
First, let’s update the index.html
template, localizing our hardcoded strings using the new T()
function.
<!-- static/index.html -->
<!-- ... -->
<head>
- <title>Speedrun Leaderboard</title>
+ <title>{{T .Title}}</title>
</head>
<body>
<div class="container">
<h1>{{T .Header}}</h1>
<table>
<thead>
<tr>
- <th>Player Name</th>
- <th>Game</th>
- <th>Category</th>
- <th>Time</th>
- <th>Date</th>
+ <th>{{T "PlayerName"}}</th>
+ <th>{{T "Game"}}</th>
+ <th>{{T "Category"}}</th>
+ <th>{{T "Time"}}</th>
+ <th>{{T "Date"}}</th>
</tr>
</thead>
<tbody>
<!-- ... -->
</tbody>
</table>
</div>
</body>
</html>
Code language: Diff (diff)
🗒️ Note » We do the same for the Add Speedrun form. The updated code is located in static/speedrun.html
inside the GitHub repo.
Now go back and provide translations for all the remaining template strings. Here is the list that I provided for the Greek translations:
# locales/el_GR/LC_MESSAGES/default.po
msgid "Category"
msgstr "Κατηγορία"
msgid "Time"
msgstr "Χρόνος"
msgid "Submit"
msgstr "Υποβολλή"
msgid "Game"
msgstr "Παιχνίδι"
msgid "Submitted At"
msgstr "Υποβλήθηκε:"
msgid "Add New Speedrun"
msgstr "Προσθήκη νέου Speedrun"
Code language: plaintext (plaintext)
Restart the server while having the Greek Locale enabled:
$ export LANGUAGE=el
$ go run main.go
Code language: Bash (bash)
You should be able to see the translated page
Now let’s look at how to render RTL languages like Arabic or Hebrew.
How do I handle text direction (LTR/RTL)?
When developing multilingual web applications, we often neglect locales written from right to left (RTL), such as Arabic, Hebrew, and Persian. Fortunately, HTML provides the dir
attribute to control text directionality.
While golang.org/x/text
package provides utilities for text processing involving Unicode, unfortunately, it does not offer anything related to RTL detection. The same problem exists in gotext
as well.
This means that we have to define those helper functions manually in our code.
Since this package is not installed by default, we need to import it:
$ go get golang.org/x/text
Code language: Bash (bash)
First, define the LanguageDirectionMap
in your i18n
package:
// pkg/i18n/i18n.go
package i18n
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/leonelquinteros/gotext"
+ "golang.org/x/text/language"
)
+ // LanguageDirectionMap maps language codes to their
+ // typical text directions
+ var LanguageDirectionMap = map[LanguageCode]string{
+ "el_GR": "ltr", // Greek
+ "en_US": "ltr", // English
+ "ar_SA": "rtl", // Arabic
+ }
// ...
Code language: Diff (diff)
Update your handleIndex
and handleSpeedrun
function to accept Dir
as a template variable:
// pkg/handlers/handlers.go
// ...
func HandleIndex(w http.ResponseWriter,
r *http.Request, data []model.Speedrun) {
HandleTemplate(w, r, "index.html", map[string]interface{}{
...
+ "Dir": i18n.LanguageDirectionMap[i18n.GetCurrentLanguage()],
})
}
func HandleSpeedrun(w http.ResponseWriter, r *http.Request) {
HandleTemplate(w, r, "speedrun.html", map[string]interface{}{
...
+ "Dir": i18n.LanguageDirectionMap[i18n.GetCurrentLanguage()],
})
}
Code language: Diff (diff)
In your HTML template files, add the dir
attribute to the tag and set it to the
.Dir
template variable you just created. Here’s index.html
as an example:
<!-- static/index.html -->
<!DOCTYPE html>
- <html>
+ <html dir="{{.Dir}}">
<!-- ... -->
Code language: Diff (diff)
🗒️ Note » Remember to do this in speedrun.html
as well.
Now go back and provide translations for all the Arabic strings:
# locales/ar_SA/LC_MESSAGES/default.po
msgid "Speedrun Leaderboard"
msgstr "لوحة نتائج السرعة"
msgid "Player Name"
msgstr "اسم اللاعب"
msgid "Category"
msgstr "الفئة"
msgid "Time"
msgstr "الزمن"
msgid "Submit"
msgstr "تقديم"
msgid "Game"
msgstr "اللعبة"
msgid "Submitted At"
msgstr "تم التقديم في:"
msgid "Add New Speedrun"
msgstr "إضافة سباق سريع جديد"
Code language: plaintext (plaintext)
Restart the server, and you will be able to see the Arabic presented in the right text direction.
$ export LANGUAGE=ar
$ go run main.go
Code language: Bash (bash)
The following screenshots showcase the page before and after adding the RTL direction.
🔗 Resource » Setting the HTML dir
attribute is a great first step in handling language direction. There’s a bit more to RTL handling, and you can read all about it in our CSS Localization guide.
How do I localize dates and times?
Handling dates and times is a crucial aspect of localization, since different regions format dates differently. To handle datetime localization in our app, we’ll use the time
and golang.org/x/text/language packages to create new formatting functions.
Note that libraries like gotext
do not offer built-in support for localized date formatting, so we have to provide the formats manually. Create a date_formats.json
file in the project root folder to hold these formats:
// date_formats.json
{
"en_US": "01/02/2006 03:04:05 PM",
"el_GR": "02/01/2006 15:04:05",
"ar_SA": "02/01/2006 15:04:05"
}
Code language: JSON / JSON with Comments (json)
We’ll be able to add and update date formats for different locales by adding them to date_formats.json
without modifying any code. Let’s write those formatting functions next.
Create a function in the i18n package that formats a date according to the user’s language preference:
// pkg/i18n/i18n.go
// ...
// Add this code after the SetCurrentLocale function declaration.
// FormatLocalizedDate formats the given time.Time object
// according to the specified language locale.
func FormatLocalizedDate(t time.Time, lang string) string {
dateFormats, err := readDateFormatsFromFile("date_formats.json")
if err != nil {
// Fallback to default format if unable to read formats
return t.Format("02/01/2006 15:04:05")
}
format, ok := dateFormats[lang]
if !ok {
// If the language is not recognized, use a default format
return t.Format("02/01/2006 15:04:05")
}
location, err := time.LoadLocation(lang)
if err != nil {
// Log or handle error
// Fallback to default location if an error occurs
location = time.UTC
}
// Format the time using the specified format and location
return t.In(location).Format(format)
}
func readDateFormatsFromFile(filename string)
(map[string]string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var dateFormats map[string]string
if err := json.Unmarshal(data, &dateFormats); err != nil {
return nil, err
}
return dateFormats, nil
}
Code language: Go (go)
This function, FormatLocalizedDate
accepts a time unit and a language tag and provides a suitable formatted representation based on the formats we defined earlier. It uses a helper function to read the contents of date_formats.json
to apply the correct formats.
Provide a template function that will call the above FormatLocalizedDate
so we can use it in the HTML templates. The language.Make
function is part of the golang.org/x/text/language package which provides support for language tags and related functionality.
// pkg/handlers/handlers.go
func HandleTemplate(w http.ResponseWriter, r *http.Request,
tmplName string, data interface{}) {
// Parse the template
tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
"T": i18n.T,
+ "FormatLocalizedDate": func(submittedAt time.Time,
+ currentLanguage i18n.LanguageCode) string {
+ return i18n.FormatLocalizedDate(submittedAt, currentLanguage.String())
+ },
}).ParseFiles("static/" + tmplName)
// ...
Code language: Diff (diff)
Replace the SubmittedAt field in the index template with the new FormatLocalizedDate
field:
<!-- index.html -->
<!-- ... -->
{{range .Data}}
<tr>
<td>{{.PlayerName}}</td>
<td>{{.Game}}</td>
<td>{{.Category}}</td>
<td>{{.Time}}</td>
- <td>{{.SubmittedAt.Format "2006-01-02"}}</td>
+ <td>{{FormatLocalizedDate .SubmittedAt $.CurrentLanguage}}</td>
</tr>
{{end}}
<!-- ... -->
Code language: Diff (diff)
Now try to test this out with the different locales and you will see the dates printed in the correct locale format:
🔗 Resource » We cover datetime localization in detail in our Guide to Date and Time Localization.
How do I localize numbers?
Similar to dates, we can provide a function that formats a given number with the appropriate grouping separators according to the user’s preferred language. The number 1000000 for example, can be formatted as 1,000,000 or as 10,00,000 depending on the country of reference (note the comma locations).
The following function formats a number using the provided language tag. It uses the message.NewPrinter(lang)
function from the golang.org/x/text package.
// pkg/i18n/i18n.go
package i18n
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/leonelquinteros/gotext"
+ "golang.org/x/text/language"
"golang.org/x/text/message"
)
+ // FormatNumber formats the given number according
+ // to the specified language locale.
+ func FormatNumber(number int64, lang language.Tag) string {
+ printer := message.NewPrinter(lang)
+ // Format the number with grouping separators
+ // according to the user's preferred language
+ return printer.Sprintf("%d", number)
+ }
Code language: Diff (diff)
This function takes two parameters: the language tag string from the language
package, representing the user’s preferred language, and the number to be formatted. It creates an instance of a message.Printer
that can format the number according to the specified language, including grouping separators as per the user’s locale. The printer.Sprintf
method will format the number according to the specified locale and return it as a string that we can print.
We can include this helper in the list of template functions as well:
// pkg/handlers/handlers.go
import (
...
+ "golang.org/x/text/language"
)
func HandleTemplate(w http.ResponseWriter, r *http.Request,
tmplName string, data interface{}) {
// Parse the template
tmpl, err := template.New(tmplName).Funcs(template.FuncMap{
"T": i18n.T,
"FormatLocalizedDate": func(submittedAt time.Time,
currentLanguage i18n.LanguageCode) string {
return i18n.FormatLocalizedDate(submittedAt, currentLanguage.String())
},
+ "FormatNumber": func(number int64,
+ currentLanguage i18n.LanguageCode) string {
+ return i18n.FormatNumber(number, language.Make(currentLanguage.String()))
},
}).ParseFiles("static/" + tmplName)
Code language: Diff (diff)
Here’s how you can use this helper function in your templates:
<!-- Example usage of formatNumber helper function -->
<p>{{T "Localized number"}}: {{FormatNumber 12342341123 $.CurrentLanguage}}</p>
Code language: HTML, XML (xml)
We can see two different examples below (notice how Greek and US English use different grouping separators):
🔗 Resource » Our Concise Guide to Number Localization covers grouping separators in detail, and touches on other important aspects of number localization.
How do I work with plurals?
If you come from an English background, or similar, you might assume that plural forms equate to “1 tree” vs “5 trees”. However, different languages have very different plural rules — some languages, like Arabic, can have six plural forms!
🔗 Resource » The canonical source for language’s plural forms is the CLDR Language Plural Rules listing.
In our app, we can use gettext
’s ability to define multiple plural forms for the same translation to manage our localized plurals. We use the Plural-Forms
directive to define the rules for selecting the appropriate plural based on a count variable.
Let’s see all this in action by implementing plurals in our demo app.
Add the Plural-Forms directive to PO files
Greek and English have two plural forms, one
and other
. We often want to cater to the zero (0) form as well so we cover that in our Plural-Forms
logic.
# locales/en_US/LC_MESSAGES/default.po
+ "Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;
# ...
Code language: Diff (diff)
# locales/el_GR/LC_MESSAGES/default.po
+ "Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;
# ...
Code language: Diff (diff)
Arabic has six plural forms that depend on somewhat intricate language rules:
# locales/ar_SA/LC_MESSAGES/default.po
+ "Plural-Forms: nplurals=6; \
+ plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 \
+ : n%100>=11 ? 4 : 5;\n"
# ...
Code language: Diff (diff)
🔗 Resource » Learn more about the Plural-Forms
syntax and more on the official gettext
plural forms page.
Define plural translations in PO files
In your PO files, define plural translations for messages that have plural forms. Each plural message should include a singular form and one or more plural forms. For example:
# locales/en_US/LC_MESSAGES/default.po
"Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;
# ...
+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "%d new Entry Added today"
+ msgstr[1] "%d new Entries today"
+ msgstr[2] "No new Entries today"
Code language: Diff (diff)
# locales/el_GR/LC_MESSAGES/default.po
"Plural-Forms: nplurals=3; plural = n ? n > 1 ? 1 : 0 : 2;
# ...
+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "%d νέα καταχώριση προστέθηκε σήμερα"
+ msgstr[1] "%d νέες καταχωρήσεις σήμερα"
+ msgstr[2] "Καμία καταχωρήση σήμερα"
Code language: Diff (diff)
# locales/ar_SA/LC_MESSAGES/default.po
"Plural-Forms: nplurals=6; \
plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 \
: n%100>=11 ? 4 : 5;\n"
# ...
+ msgid "EntryAdded"
+ msgid_plural "EntriesAdded"
+ msgstr[0] "لم تتم إضافة أي إدخالات جديدة اليوم"
+ msgstr[1] "تمت إضافة إدخال %d جديد اليوم"
+ msgstr[2] "تمت إضافة مدخلين %d جديدين اليوم"
+ msgstr[3] "تمت إضافة %d إدخالاً جديدًا اليوم"
+ msgstr[4] "تمت إضافة مدخلين %d جديدين اليوم"
+ msgstr[5] "تمت إضافة %d إدخالات جديدة اليوم"
Code language: Diff (diff)
Create a new helper function for plural translations
Define a new helper function in your i18n package to simplify the retrieval of plural translations. This function should call gotext.GetN
with the appropriate parameters. For example:
// pkg/i18n/i18n.go
// ...
+ func TN(singular, plural string, count int, args ...interface{}) string {
+ return gotext.GetN(singular, plural, count, count)
+ }
Code language: Diff (diff)
This TN
function takes the singular and plural forms of the message in the default locale, the count of items, and any additional arguments for interpolation.
If the message has a plural form defined (based on the Plural-Forms header), the GetN
evaluates the plural expression using the count value. Based on the index returned by the expression, GetN
selects the corresponding msgstr[n]
entry from the translation file.
For example, if count = 0
and locale is el_GR
, the Plural-Forms
formula we defined will evaluate to the number 2 so that the msgstr[2]
entry will be used to format the string.
If count = 1
then the formula will evaluate to 0 so the msgstr[0]
will be used instead.
Use the helper function in templates
Whenever you have a plural in your template, replace the call to T
with a call to TN
. Pass the singular and plural forms of the message in the default locale (English in our case). Also pass the integer count, which will be used to resolve the plural form of the message in the active locale at runtime.
<p>{{TN "EntryAdded" "EntriesAdded" 5}}</p>
Code language: HTML, XML (xml)
Now re-run the app testing with the different locales. We can see two different examples below:
How do I detect the user’s locale?
To detect the user’s locale in your Go web application, you can create a helper function in the i18n package called DetectPreferredLocale
. This will parse the request query for a language parameter or an Accept-Language
header and will switch to the appropriate supported locale. In case no locale was provided it will default to en_US
.
// pkg/i18n/i18n.go
// Add this code after the GetCurrentLanguage
// function declaration
func DetectPreferredLocale(r *http.Request) string {
// Check if lang parameter is provided in the URL
langParam := LanguageCode(r.URL.Query().Get("lang"))
if langParam != "" {
// Check if the provided lang parameter is supported
for _, supportedLang := range GetSupportedLanguages() {
if langParam == supportedLang {
return langParam.String()
}
}
}
// Get Accept-Language header value
acceptLanguage := r.Header.Get("Accept-Language")
// Parse Accept-Language header
prefs, _, err := language.ParseAcceptLanguage(acceptLanguage)
if err != nil {
// Default to English if parsing fails
return "en_US"
}
// Convert supported language codes to language.Tags
var supportedTags []language.Tag
for _, code := range GetSupportedLanguages() {
tag := language.Make(code.String())
supportedTags = append(supportedTags, tag)
}
// Find the best match between supported languages
// and client preferences
match := language.NewMatcher(supportedTags)
_, index, _ := match.Match(prefs...)
// Get the best match language
locale := GetSupportedLanguages()[index]
return locale.String()
}
// SetCurrentLocale sets the current locale based
// on the language code
func SetCurrentLocale(lang string) {
// If the language parameter is provided,
// set the current locale
if lang != "" {
// Get the preferred locale based on
// the language code
locale := (lang)
// Set the current locale
gotext.SetLanguage(locale)
}
}
Code language: Go (go)
Here we showcase the use of language.NewMatcher
from the golang/x/text
package, which has logic to detect a locale based on a list of matching supported tags. It works as a Regex Matcher for locales and it can be used in cases when you want to perform content negotiation with the Accept-Language header as well.
Next, modify the part of your application responsible for detecting the user’s language preference. You can achieve this by parsing the language preference from the request URL or headers and setting it in the current locale:
// main.go
+ func detectLanguageMiddleware(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // Get the preferred locale based on the request's Accept-Language header
+ lang := i18n.DetectPreferredLocale(r)
+ i18n.SetCurrentLocale(lang)
+ next.ServeHTTP(w, r)
+ }
+ }
Code language: Diff (diff)
Here’s an example of how you can update the main method to include language detection middleware. It wraps each handler with the detectLanguageMiddleware
which will call this
// main.go
func main() {
...
// Middleware to detect user's language preference
+ http.Handle("/", detectLanguageMiddleware(http.DefaultServeMux))
}
Code language: Diff (diff)
Now that we have the backend code ready we need to update the UI to include the language switcher component.
How do I build a language switcher?
Let’s update our static pages, enhancing their UX by providing a selector that allows language switching. This could be a drop-down menu or buttons that trigger a request to change the language. We’re going with the dropdown option here.
Include this code into both the index.html
and speedrun.html
templates:
<!-- index.html, speedrun.html -->
<label for="selectLanguage">{{T "Select Language"}}</label>
<select id="language-selector">
{{range .SupportedLanguages}}
<option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
{{.}}
</option>
{{end}}
</select>
<script>
const languageSelector = document.getElementById("language-selector");
languageSelector.addEventListener("change", () => {
const selectedLanguage = languageSelector.value;
const currentUrl = window.location.href;
const newUrl = updateQueryStringParameter(
currentUrl,
"lang",
selectedLanguage,
);
window.location.href = newUrl;
});
function updateQueryStringParameter(uri, key, value) {
const re = new RegExp("([?&])" + key + "=.*?(&|$)", "i");
const separator = uri.indexOf("?") !== -1 ? "&" : "?";
if (uri.match(re)) {
return uri.replace(re, "$1" + key + "=" + value + "$2");
} else {
return uri + separator + key + "=" + value;
}
}
</script>
Code language: HTML, XML (xml)
This code snippet updates the UI to include a language selection dropdown. When a user selects a language, it dynamically constructs a new URL with the selected language parameter and reloads the page with the updated URL. This allows users to change the language of the application.
With that in place, we can select a new locale from the UI, triggering a re-render of the page with the selected translations:
As an improvement, we can render localized versions of the locale tags like en-US
when selecting their language. Let’s add human-friendly names to our locales:
<!-- index.html, speedrun.html -->
<div id="languageDropdown">
<label for="selectLanguage">{{T "Select Language"}}</label>
<select id="language-selector">
{{range .SupportedLanguages}}
- <option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
- {{ . }}</option>
+ <option value="{{.}}" {{if eq . $.CurrentLanguage}}selected{{end}}>
+ {{ T .String }}</option>
{{end}}
</select>
</div>
Code language: Diff (diff)
And provide the translations for the language codes:
# locales/el_GR/LC_MESSAGES/default.po
msgid "el_GR"
msgstr "Ελληνικά"
msgid "en_US"
msgstr "Αγγλικά"
msgid "ar_SA"
msgstr "Αραβικά"
# locales/ar_SA/LC_MESSAGES/default.po
msgid "el_GR"
msgstr "اليونانية"
msgid "en_US"
msgstr "الإنجليزية"
msgid "ar_SA"
msgstr "العربية"
Code language: plaintext (plaintext)
Now the language switcher now looks more readable and will render the correct translated version for each locale tag:
How do I generate translations from my app?
Now that we have our localization infrastructure set up, let’s explore how we can automate the extraction of translation strings from our application code and initialize our translation files.
Our current tooling’s support for message extraction is currently in beta. Let’s use another tool for extraction.
Additional packages used
For our extraction workflow, we will use the following go package:
Package | Version | Comment |
i18n4go | 0.6 | CLI tool that provides string extraction from source to either JSON or PO file format |
We install the i18n4go CLI tool by using the go install command:
$ go install github.com/maximilien/i18n4go/i18n4go
Code language: Bash (bash)
This tool offers various options on how to extract strings from go sources. In our example, we use the following configuration:
$ i18n4go -c extract-strings --po -d . -r -o "$workdir" --ignore-regexp "$ignored" -output-match-package
Code language: Bash (bash)
🔗 Resource » Read about message extraction in the official readme.
We have provided a script that utilizes the i18n4go
command to extract and merge translation messages. It is located in the scripts/extract-strings.sh
path of the GitHub repo which also contains relevant comments about the specific arguments used with this command.
So if we run our extraction command now, each locale PO file will contain all the detected translation strings from the source code.
🗒️ Note » When a translation is missing from the active locale, the i18n4go
uses the msgid
as the value of the translation.
🗒️ Note » The mentioned script only handles one single domain for now. But with a bit of configuration, you can make it work for multiple language domains.
❯ ./scripts/extract-strings.sh --extract
i18n4go: inspecting dir ., recursive: true
...
Total files parsed: 3
Total extracted strings: 19
Total time: 17.140555ms
Code language: Bash (bash)
If you already had some translation string changes, it will keep them as well since it uses the msgcat tool to properly merge two translation catalogs while preserving conflicts. The following diff showcases the added messages when we run the script after we included more translation strings:
# locales/el_GR/LC_MESSAGES
# filename: main.go, offset: 1936, line: 78, column: 37
msgid "Another text on a different domain"
msgstr "Το Μυνημά μου σε διαφορετικό τομέα γλώσσας"
+ # filename: main.go, offset: 1900, line: 78, column: 25
+ msgid "My text on 'domain-name' domain 2"
+ msgstr "My text on 'domain-name' domain 2"
Code language: Diff (diff)
How do I integrate my app with Phrase Strings?
To take our localization to the next level, we can utilize Phrase Strings. Phrase Strings streamlines the localization process, allowing translators to efficiently translate our application’s content and keep message strings in sync between developer machines.
Creating the Phrase Strings project
To integrate Phrase Strings into your app, you need to configure a new Phrase Strings project:
1. Create a Phrase account (you can start for free).
2. Login, open Phrase Strings, and click the New Project button near the top of the screen to create a project.
3. Configure the project to use the.PO (Portable Object) translation file format
4. Add starting languages. In our case, we can add en-US
first as the default locale, then add ar-SA
and el-GR
5. Generate an access token from your profile page. Click the user avatar near the top-right of the screen and to go Settings → Profile → Access tokens → Generate Token. Make a copy of the generated token and keep it somewhere safe.
Setting up the Phrase Strings CLI
Now let’s set up the Phrase Strings CLI so we can automate the transfer of our translation files to and from our translators.
🔗 Resource » Installation instructions for the Phrase Strings CLI depend on your platform (Windows, macOS, Linux). Just follow the CLI installation docs and you should be good to go.
With the CLI installed, let’s use it to connect our Go project to Phrase Strings. From your project root, let’s run the following command from the command line.
$ phrase init
Code language: Bash (bash)
We’ll then be given some prompts:
Select project
— Select the Phrase Strings project we created above.Select the format to use for language files you download from Phrase Strings
— Hit Enter to select the project’s default.Enter the path to the language file you want to upload to Phrase
— Enter
US/LC./locales/en
MESSAGES/default.po
, since that’s our source translation file.Enter the path to which to download language files from Phrase
— Enter
name>/LC./locales/<locale
MESSAGES/default.po
. (is a placeholder here, and it allows us to download all the translation files for our project:
en-US
,ar-SA
, etc.).Do you want to upload your locales now for the first time?
— Hit Enter to accept and upload.
At this point, our en-US.json
file will get uploaded to Phrase, where our translators can use the powerful Phrase web admin to translate our app in different locales:
Once our translators complete their work, we execute the following command to incorporate all their translations into our project:
$ phrase pull
Code language: Bash (bash)
This command ensures that our translation files are updated with the latest translations. To verify the updated translations, we can proceed to run our app as usual. This setup also provides translators with an optimal environment for their work.
Conclusion
In this tutorial, we started exploring the capabilities of the Golang gotext
package. We developed a small Speedrun leaderboard application that loads gettext translations from the filesystem and starts a web server that serves translations, showcasing how all the components work together. I hope you enjoyed this learning exploration. Please stay tuned for more detailed articles delving into various aspects of localization and internationalization.