Skip to content

Internationalization system

Nathan S edited this page Sep 27, 2020 · 13 revisions

The library comes with a internationalization / localisation / translation system (call it whatever you want, I will use "i18n" in this page). It allows you to define all the strings of your app in separate files, outside of the code, and swap the set of strings used depending on the system locale (language + country).

So far, only the Switch platform is able to detect current locale; PC platform is hardcoded to always use the default one.

Hold up! To learn, the best way is to look at the example, which showcases every feature of the system. This way you will see how to use multiple JSON files, nested strings and format arguments. So I recommend to read the documentation to understand how the system works, and to look at the example to understand how to use it.

Note: it is not advised to use the i18n system to translate logs - they should be in English for everyone to be able to diagnose any issue with the app.

The default locale is en-US.

How does it work ?

You start by defining a set of strings, in one or more files. A string is a (localized) piece of text, identified by its name. Every locale has the same strings - the name and hierarchy are the same, but the values of the strings are obviously different (because it's localized).

Don't worry, we'll see later how to add strings to your app.

In the code, you use strings by their names. The system takes care of finding the corresponding text depending on the system current locale.

The lookup order of strings is the following:

  1. current system language (localized)
  2. default locale (English string)
  3. raw string name as a fallback (illegible garbage for users)

Note: If the lookup fails, the raw string name will be returned but an error will be logged. Check the logs to see what went wrong.

Example

Let's consider that you have the string main/hello_world on the two locales English and French:

  1. on an English Switch, the lookup will return "Hello world"
  2. on a French Switch, the lookup will return "Bonjour 🥖"
  3. on a Spanish Switch, the lookup will also return "Hello world" because you app hasn't been translated to Spanish yet

Please note that the default locale is not mandatory to have in your app, but it's heavily recommended. Having no default language can lead to people having illegible text on the entire app. It's better to have an UI in English than no UI at all.

In our case, no default language would mean that Spanish users see "main/hello_world", which is not a nice greeting.

Supported locales

The following locales are currently supported: ja, en-US, en-GB, fr, fr-CA, de, it, es, zh-CN, zh-Hans, zh-Hant, zh-TW, ko, nl, pt, pt-BR, ru, es-419.

Translation files

To add translation files, start by creating an i18n folder in your resources if it's not already here (hint: it should be here, see Reserved borealis strings below.

Inside i18n, the file structure is the following:

  • one folder by locale
  • as many JSON files as you want in each folder - files must end with .json
  • each locale must have the same file names
  • the strings must match between the same files of different locales

Each file must contain one top-level JSON "object" (key/value pairs surrounded by {}). Inside, you can write all your strings - the key is the string name, the value is the translation of that string.

The string names must not contain the following characters: /, ~, (whitespace), #, $, which are JSON Pointer reserved keywords. Numbers are fine.

To use a string in your app, you need to reference the file name followed by a slash and the string name. So if your hello world string is in the file main.json, it would be called main/hello_world in the code.

You can optionnally nest multiple JSON objects in your translation files. To refer to a nested string in the code, use a slash as a separator.

Example

If you write the following JSON to i18n/en-US/main.json:

{
    "nested": {
        "string": "henlo"
    },
    "simple": "worl"
}

You can access "henlo" by getting main/nested/string, and "worl" by getting main/simple.

Then, to translate that, create a main.json in fr and write the following content:

{
    "nested": {
        "string": "salut"
    },
    "simple": "monde"
}

Reserved borealis strings

The brls.json file contains all the strings reserved by the libs, such as the bottom-right hints ("OK", "Back", "Exit"...). If you don't want your app to look broken, you should copy paste the example i18n folder in your resources when creating a new project. Then, delete everything but brls.json in every locale folder. It's tedious, I know, but it allows you to translate those texts if you want to.

How to get the strings?

Now that you wrote some strings in your JSON files, it's time to actually get them in the code. The i18n API offers two ways to achieve that...

BUT NOT YET! You first have to LOAD the translations, by using i18n::loadTranslations();. This is not automatically done in the Application init to allow you to translate the app name.

Once the strings are loaded, you can use the following two functions in the brls::i18n namespace:

  1. a getStr(format, args...) function: std::string translated = i18n::getStr("main/hello_world");
  2. a _i18n user-defined string literal: std::string translated = "main/hello_world"_i18n;

The main difference is that getStr supports 🌈 format arguments 🌈. You should use it if you don't like literals, or if the string has format arguments. We'll get there in a minute.

How to use the namespace

brls::i18n is annoying to write. You can do namespace i18n = brls::i18n; to make a shortcut of that namespace called i18n.

To use the _i18n literal, you HAVE to "use" the i18n::literals namespace: using namespace brls::i18n::literals;

You can copy paste one of those tiny pieces of code at the top of every file you need the i18n functions in:

namespace i18n = brls::i18n; // for loadTranslations() and getStr()
using namespace i18n::literals; // for _i18n
namespace i18n = brls::i18n; // for loadTranslations() and getStr()
using namespace brls::i18n::literals; // for _i18n

Format arguments

The format arguments allows you to define "holes" in the strings, that are injected at runtime because you don't know what will be written here. It works exactly the same way as the logger. In fact, they both use the fmt library.

To use format arguments in your string, put the index of the argument between brackets, like so: {0}, {1}. They are called positional arguments because you are explicitely stating which argument goes where in the string... and that is very important for localization.

You don't have to use all arguments in a translation, however you must ALWAYS put all of the arguments in the getStr function call, regardless of the translation. If you don't do that, the string lookup will fail with the error "argument not found". See the example below if it's not clear.

Oh and also, arguments starts at 0 😉

You can escape the brackets by doubling them: {{}}. Single brackets don't need to be escaped as they will be ignored by the library.

Formatting example

Let's say that you want to welcome an user using their first and last name: Min Feng.

The English string would be Welcome {0} {1}!, and you would call the function like so: std::string hello = i18n::getStr("main/hello", firstName, lastName);. That would display Welcome Min Feng!.

However, let's consider that you translate your app in Korean. In Korea, you refer to people using their last name first, then their first name. The string would then be 어서 오십시오 {1} {0}, but the getStr call STAYS the same. The zero and one are just inverted, to tell the library to put the second argument first, then the first argument. The resolved string would be 어서 오십시오 Feng Min!.

Now let's say that you want to add a middle name to that string. You change the English string to Welcome {0} {2} {1}, but you forget to add the corresponding argument in the function call... 💥 it fails with "argument not found" because the library doesn't know what to put instead of the {2}.

You don't have to change the Korean string and can keep 어서 오십시오 {1} {0} because you don't have to use all arguments in the string. You can use the first and second one and discard the third one just fine.

The correct call is std::string hello = i18n::getStr("main/hello", firstName, lastName, middleName); so that the middle name is in third - it will show in between the first and last name in English, and will be hidden in Korean.

The linter

Because it can be easy to mess up the i18n files, the library contains a Python linter script that checks that everything is in place for you:

  • Stray files and directories in the i18n folder
  • Unknown locales
  • Invalid JSON files and types
  • Presence of the default locale
  • Illegal characters in strings names
  • Untranslated strings
  • Translations of unknown strings
  • Duplicated strings

That script is located in scripts/i18n-linter.py. To use it, you need Python 3 and the pip libraries in requirements.txt.

The linter takes a single argument, the path to the i18n folder to check: python scripts/i18n-linter.py resources/i18n.

Here is the list of all warnings and errors generated by the linter, and some help on how to resolve them. Errors will cause your app to malfunction and/or crash, and warnings will cause missing translations in the UI.

Error codes:

  • E01: Requested i18n folder doesn't exist, check the command you are running
  • E02: Requested i18n folder is not a folder, check the command line you are running
  • E05: Invalid JSON data, see error message for the details
  • E06: The name of a string contains illegal characters, remove it
  • E07: A string is of an invalid type, please make sure that you only use JSON strings for your translations

Warning codes:

  • W01: Stray directory in i18n folder, remove it
  • W02: Stray file in i18n folder, remove it
  • W03: A folder has been found for an unknown locale, look at the supported locales list and make sure there is no typo in the folder name
  • W04: The default locale contains a string that is not translated on all locales (untranslated string), add it to the concerned locales or remove the string from the default locale
  • W05: A locale contains a translation for a string that is not in the default locale (translation of unknown string), remove the translation or add the string to the default locale
  • W06: Default locale is missing from the i18n folder, add it
  • W07: A string is present in multiple files, remove all duplicates

Note: the linter stops at the first error it encounters. Solving one error doesn't mean there will not be another one just after - you need to run the script until there is no warning or error to make sure everything is fine.