Maintaining Multi-language Angular Applications with i18n

Maintaining Multi-language Angular Applications with i18n

Discover the possibilities of Angular internationalization (i18n) and localization (l10n)

Angular i18n and the localizing of applications had an overhaul with version 9, enabled by the new rendering engine Ivy. In this article, we take a closer look at how this built-in package of Angular now works, while pointing out the benefits and drawbacks we find.

We then set up an application with Angular internationalization and go through the complete process from marking texts for translation, extracting them to translation files, and how we manage these files to get the application deployed and maintained while keeping users all over the world happy with our translations.

And speaking of international, if you prefer reading in Spanish:

Illustration by Vero Karén

Internationalization and localization

It’s easy to get confused with the terms internationalization (i18n) and localization (i10n), and where to draw the line between them. Internationalization is the process of designing your application so that it can be adapted to different locales around the world while localization is the process of building the versions of the applications to different locales.

Together they help us in adapting software to different languages and local variations in the look and feel expected by the target audience.

How localization works with Ivy

The new localization process of Angular Ivy is based on the concept of tagged templates. Tags allow you to parse template literals with a function. The tag used here is the global identifier $localize. Instead of translating the strings, the Ivy template compiler converts all template text marked with i18n attributes to $localize tagged strings.

So when we add:

<h1 i18n>Hello World!</h1>

It will be compiled to $localize calls and somewhere in the compiled code we will be able to find:

$localize`Hello World!`

The way the tagged template works is that you put the function that you want to run against the string before the template. Instead of function(), you have function`` or as in this case $localize`` .

When this step is done we have two choices:

  • compile-time inlining: the $localize tag is transformed at compile time by a transpiler, removing the tag and replacing the template literal string with the translation.

  • run-time evaluation: the $localize tag is a run-time function that replaces the template literal string with translations loaded at run-time.

In this article, we use compile-time inlining to achieve our goals. At the very end of the build process, we run a step for the translation files by providing an option flag to get a localized application for the languages. Since we are doing the translations compile-time we get one application per locale.

At the end of the article, we take a further look into run-time evaluation.

Because the application does not need to be built again for each locale, the build process is much faster than before v9 of Angular.

You can read more about this in Angular localization with Ivy from where this picture is.

You can read more about this in Angular localization with Ivy from where this picture is.

Now that we understand the process of building the application we start to get an understanding of what it entails.

The good and the bad

The standard Angular internationalization and localization are designed to produce one compiled application per language. By doing this we get optimal performance since there is no overhead of loading translation files and compiling them at run-time. But, this also means that each language has to be deployed to a separate URL:

www.mydomain.com/en
www.mydomain.com/nb
www.mydomain.com/fi

This means we need to do a bit more set up on our webserver. A limitation with ng serve is that it only works with one language at a time and to run different languages also needs some configuration. To run all languages locally we need to use a local webserver. We look into how we do all this in this article.

Angular i18n uses XLIFF and XMB formats that are XML-based, more verbose formats than JSON. But since these files are used at compile-time it doesn’t matter. It makes sense to use JSON when we load the translation files at run-time to keep the file sizes smaller. The formats chosen for the built-in i18n are used by translation software which helps us with our translations as we will see.

The number one drawback that people find with this solution is that you need to reload the application when you switch languages. But, is this really going to be a problem for you? People usually switch languages once if ever. And that couple of seconds it takes to reload applications will not be a problem.

Having one bundle per language is not a problem for a web SPA other than that you have to configure your web server for this. But for standalone apps, this means you got to make the user download every translated bundle, or distribute a different app for every version.

It’s important to understand your requirements before deciding which route to take.

Transloco

If the standard Angular i18n doesn’t give you what you want then the best alternative today in my opinion is Transloco. It’s being actively maintained and has an active community. It will get you up and running faster and is more flexible than the built-in solution. Since Transloco is runtime translation you have just www.mydoman.com and can change localization on the fly.

So, before choosing which way to go in such a fundamental choice you should check Transloco out to see if it would be a better fit for you.

OK, enough technicalities let’s see some code!

Add localize to Angular project

@angular/localize package was released with Angular 9 and supports i18n in Ivy applications. This package requires a global $localize symbol to exist. The symbol is loaded by importing the @angular/localize/init module.

To add the localization features provided by Angular, we need to add the @angular/localize package to our project:

ng add @angular/localize

This command:

  • Updates package.json and installs the package.

  • Updates polyfills.tsto import the @angular/localize package.

If you try using i18n without adding this package you get a self-explanatory error message reminding us to run ng add @angular/localize.

Translating templates

To translate templates in our application, we need first to prepare the texts by marking them with the i18n attribute.

i18n is a custom attribute from the WebExtensions API. It’s recognized by Angular tools and compilers. During the compilation, it is removed, and the tag content is replaced with the translations.

We mark the text like this:

<span i18n>Welcome</span>

This <span> tag is now marked and ready for the next step in the translation process.

Translating TypeScript files

NB! You need Angular 10.1 or later to extract strings from source code (.ts) files.

It’s not only our templates that need to be translated. Sometimes we have code in our TypeScript files that also need a translation. To localize a string in the source code, we use the $localize template literal:

title = $localize`My page`;

Note that template literals use the backtick (`) character instead of double or single quotes.

Extracting texts

When our application is prepared to be translated, we can use the extract-i18n command to extract the marked texts into a source language file named messages.xlf.

The command options we can use are:

  • --output-path: Change the location of the source language file.

  • --outFile: Change the file name.

  • --format: Change file format. Possible formats are XLIFF 1.2 (default), XLIFF 2, and XML Message Bundle (XMB).

Running this command from the root directory of the project:

ng extract-i18n

We get the messages.xlf file looking like this:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="3492007542396725315" datatype="html">
        <source>Welcome</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">7</context>
        </context-group>
      </trans-unit>
      <trans-unit id="5513198529962479337" datatype="html">
        <source>My page</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.ts</context>
          <context context-type="linenumber">9</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

We can see that we have the texts “Welcome” and “My page” in the file but what does it all mean?

  • trans-unit is the tag containing a single translation. id is a translation identifier that extract-i18n generates so don’t modify it!

  • source contains translation source text.

  • context-group specifies where the given translation can be found.

  • context-type="sourcefile" shows the file where translation is from.

  • context-type="linenumber" tells the line of code of the translation.

Now that we have extracted the source file, how do we get files with the languages we want to translate?

Create translation files

After we have generated the messages.xlf file, we can add new languages by copying it and naming the new file accordingly with the associated locale.

To store Norwegian translations we rename the copied file to messages.nb.xlf. Then we send this file to the translator so that he can do the translations with an XLIFF editor. But, let’s not get ahead of us and first do a manual translation to get a better understanding of the translation files.

Translating files manually

Open the file and find the <trans-unit> element, representing the translation of the <h1> greeting tag that was previously marked with the i18n attribute. Duplicate the <source>...</source> element in the text node, rename it to target, and then replace its content with the Norwegian text:

<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en-US" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="3492007542396725315" datatype="html">
        <source>Welcome</source>
        <target>Velkommen</target>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">7</context>
        </context-group>
      </trans-unit>
      <trans-unit id="5513198529962479337" datatype="html">
        <source>my page</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.ts</context>
          <context context-type="linenumber">9</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

This is all that there is to it to add the translations to the files. Let’s see how we do it with an editor.

Translating files with an editor

Before we can use an editor, we need to provide the translation language. We can do this by adding the target-language attribute for the file tag so that translation software can detect the locale:

<file source-language="en-US" datatype="plaintext" original="ng2.template" target-language="nb">

Let’s open this file in a translation tool to see what we are working with. I’m using the free version of PoEdit in this article:

This looks much easier to work with than the manual way. We even get some suggestions for translations. Let’s translate “my page” and save the file. If we then open messages.nb.xlf we can see that it has added the translation in a target block like when we did it manually:

<source>My page</source>
<target state="translated">Min side</target>

We see that it added state="translated" to the target tag. This is an optional attribute that can have the values translated, needs-translation, or final. This helps us when using the editor to find the texts that are not yet translated.

This is a great start but before we try out the translations in our application, let’s see what more we can do by adding more information into the box in the screenshot named “Notes for translators”.

Notes for translators

Sometimes the translator needs more information about what they are translating. We can add a description of the translation as the value of the i18n attribute:

<span i18n="Welcome message">Welcome</span>

We can add even more context to the translator by adding the meaning of the text message. We can add the meaning together with the description and separate them with the | character: <meaning>|<description>. In this example we might want to let the translator know that this welcome message is located in the toolbar:

<span i18n="toolbar header|Welcome message">Welcome</span>

The last part that we can add to the value of the i18n attribute is an ID by using @@. Be sure to define unique custom ids. If you use the same id for two different text messages, only the first one is extracted, and its translation is used in place of both original text messages.

Here we add the ID toolbarHeader:

<span i18n="toolbar header|Welcome message@@toolbarHeader">Welcome</span>

If we don’t add an ID for the translation, Angular will generate a random ID as we saw earlier. Running ng extract-i18n again we can see that the helpful information has been added to our translation unit:

<trans-unit id="toolbarHeader" datatype="html">
  <source>Welcome</source>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.component.html</context>
    <context context-type="linenumber">7</context>
  </context-group>
  <note priority="1" from="description">Welcome message</note>
  <note priority="1" from="meaning">toolbar header</note>
</trans-unit>
  • There are now a couple of note tags that provide the translation description and meaning and the id is no longer a random number.

If we copy these to the messages.ng.xlf file and open it in PoEdit we see that all these are now visible in “Notes for translators”:

Providing context in TypeScript files

Like with Angular templates you can provide more context to translators by providing meaning, description, and id in TypeScript files. The format is the same as used for i18n markers in the templates. Here are the different options as found in the Angular Docs:

$localize`:meaning|description@@id:source message text`;
$localize`:meaning|:source message text`;
$localize`:description:source message text`;
$localize`:@@id:source message text`;

Adding an id and description to our title could look like this:

title = $localize`:Header on first page@@firstPageTitle:My page`;

If the template literal string contains expressions, you can provide the placeholder name wrapped in : characters directly after the expression:

$localize`Hello ${person.name}:name:`;

Specialized use cases

There are some specialized use cases for translations that we need to look at. Attributes can easily be overlooked but are also important to translate, not least for accessibility.

Different languages have different pluralization rules and grammatical constructions that can make translation difficult. To simplify translation, we can use plural to mark the uses of plural numbers and select to mark alternate text choices.

Attributes

Apart from the usual suspects of HTML tags, we need to also be aware that we need to translate HTML attributes. This is especially important when we are making our applications accessible to all people.

Let’s take the example of an img tag. People using a screen reader would not see the picture but instead, the alt attribute would be read to them. For this reason and others, provide a useful value for alt whenever possible.

<img [src]="logo" alt="Welcome logo" />

To mark an attribute for translation, add i18n- followed by the attribute that is being translated. To mark the alt attribute on the img tag we add i18n-alt:

<img [src]="logo" i18n-alt alt="Welcome logo" />

In this case, the text “Welcome logo” will be extracted for translation.

You can also assign a meaning, description, and custom ID with the i18n-attribute="<meaning>|<description>@@<id>" syntax.

Plurals

Pluralization rules between languages differ. We need to account for all potential cases. We use the plural clause to mark expressions we want to translate depending on the number of subjects.

For example, imagine we do a search and want to show how many results were found. We want to show “nothing found” or the number of results appended with “items found”. And of course, let’s not forget about the case with only one result.

The following expression allows us to translate the different plurals:

<p i18n>
{itemCount, plural, =0 {nothing found} =1 {one item found} other {{{itemCount}} items found}}
</p>
  • itemCount is a property with the number of items found.

  • plural identifies the translation type.

  • The third parameter lists all the possible cases (0, 1, other) and the corresponding text to display. Unmatched cases are caught by other. Angular supports more categories listed here.

When we translate plural expression we have two trans units: One for the regular text placed before the plural and one for the plural versions.

Alternates

If your text depends on the value of a variable, you need to translate all alternatives. Much like plural, we can use the select clause to mark choices of alternate texts. It allows you to choose one of the translations based on a value:

<p i18n>Color: {color, select, red {red} blue {blue} green {green}}</p>

Based on the value of color we display either “red”, “blue”, or “green”. Like when translating plural expressions we get two trans units:

<trans-unit id="7195591759695550088" datatype="html">
  <source>Color: <x id="ICU" equiv-text="{color, select, red {red} blue {blue} green {green}}"/></source>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.component.html</context>
    <context context-type="linenumber">12</context>
  </context-group>
</trans-unit>
<trans-unit id="3928679011634560837" datatype="html">
  <source>{VAR_SELECT, select, red {red} blue {blue} green {green}}</source>
  <context-group purpose="location">
    <context context-type="sourcefile">src/app/app.component.html</context>
    <context context-type="linenumber">12</context>
  </context-group>
</trans-unit>

The editors understand these units and help us with the translations:

Interpolation

Let’s combine a welcome message the title property:

<h1 i18n>Welcome to {{ title }}</h1>

This places the value of the title variable that we earlier translated in the text. When we extract this text we see how the interpolation is handled:

<source>Welcome to <x id="INTERPOLATION" equiv-text="{{ title }}"/></source>

For the translation the <x.../> stays the same for the target language:

<target>Velkommen til <x id="INTERPOLATION" equiv-text="{{ title }}"/></target>

And that’s the last example of translations that we are looking at. Now, let’s see how we can get this applications up and running with our new language!

Configuring locales

To be able to run our application in many languages we need to define the locales in the build configuration. In the angular.json file, we can define locales for a project under the i18n option and locales, that maps locale identifiers to translation files:

"projects": {
  "i18n-app": {
    "i18n": {
      "sourceLocale": "en-US",
      "locales": {
        "nb": "messages.nb.xlf"
      }
   }
}

Here we added the configuration for the Norwegian language. We provide the path for the translation file for the locale "nb". In our case, the file is still in the root directory.

The sourceLocale is the locale you use within the app source code. The default is en-US so we could leave this line out or we could change it to another language. Whatever value we use here is also used to build an application together with the locales we define.

To use your locale definition in the build configuration, use the "localize" option in angular.json to tell the CLI which locales to generate for the build configuration:

  • Set "localize" to true for all the locales previously defined in the build configuration.

  • Set "localize" to an array of a subset of the previously-defined locale identifiers to build only those locale versions.

The development server only supports localizing a single locale at a time. Setting the "localize" option to true will cause an error when using ng serve if more than one locale is defined. Setting the option to a specific locale, such as "localize": ["nb"], can work if you want to develop against a specific locale.

Since we want to be able to ng serve our application with a single language, we create a custom locale-specific configuration by specifying a single locale in angular.json as follows:

"build": {
  "configurations": {
    "nb": {
      "localize": ["nb"]
    }
  }
},
"serve": {
  "configurations": {
    "nb": {
      "browserTarget": "ng-i18n:build:nb"
    }
  }
}

With this change we can serve the Norwegian version of the app and make sure the translations are working by sending in nb to the configuration option:

ng serve --configuration=nb

We can also build the app with a specific locale:

ng build --configuration=production,nb

Or with all the locales at once:

ng build --prod --localize

In other words, it’s more flexible to configure it the way we did but we could also have just set localize and aot to true and be done with it.

Run multiple languages locally

For performance reasons, running ng serve only supports one locale at a time. As we saw earlier we can serve the specific languages by sending in the locale to the configuration option. But, how can we run the application with all the configured languages?

Multiple languages

To run all languages simultaneously we need first to build the project. We can build applications with the locales defined in the build configuration with the localize option:

ng build --prod --localize

When the build is localized and ready we need to set up a local webserver to serve the applications. Remember we have one application per language, which is what makes this a bit more complex.

In Angular Docs, there are a couple of examples of server-side code that we can use.

Nginx

To get our application up and running we need to:

  1. Install Nginx

  2. Add config from Angular Docs to conf/nginx.conf

  3. Build our applications

  4. Copy applications to the folder defined in root in nginx.conf.

  5. Open browser in localhost

The port is set in listen and is normally set to 80. You change languages by changing the URL. We should now see our Norwegian application at localhost/nb.

Here is an example of the nginx.conf file:

events{}
http {
  types {
    module;
  }
  include /etc/nginx/mime.types;

  # Expires map for caching resources
  map $sent_http_content_type $expires {
    default                    off;
    text/html                  epoch;
    text/css                   max;
    application/javascript     max;
    ~image/                    max;
  }

  # Browser preferred language detection
  map $http_accept_language $accept_language {
    ~*^en en;
    ~*^nb nb;
  }

  server {
      listen       80;
    root         /usr/share/nginx/html;

    # Set cache expires from the map we defined.
    expires $expires;

    # Security. Don't send nginx version in Server header.
    server_tokens off;

    # Fallback to default language if no preference defined by browser
    if ($accept_language ~ "^$") {
      set $accept_language "nb";
    }

    # Redirect "/" to Angular app in browser's preferred language
    rewrite ^/$ /$accept_language permanent;

    # Everything under the Angular app is always redirected to Angular in the correct language
    location ~ ^/(en|nb) {
      try_files $uri /$1/index.html?$args;

      # Add security headers from separate file
      include /etc/nginx/security-headers.conf;
    }

    # Proxy for APIs.
    location /api {
      proxy_pass https://api.address.here;
    }
  }
}

If we use Nginx in production, it makes sense to also test our application locally with it.

Deploy to production

If you are using Nginx in production, then you already have the language configuration setup. If not, you need to find out what changes you need for your particular server configuration.

We have to take into consideration if we are running the application locally or in production. We can do this by using isDevMode, which returns whether Angular is in development mode:

isDevMode() ? '/' : `/${locale}/`;

So, when we are running the application locally with ng serve we don’t add the locale to the URL as we do when we have localized the application in the production build.

Maintaining the application

Usually, when the application has been deployed it’s time to end the article. This time I wanted to address a few more things before ending. Let’s start by looking into what challenges we run into when going into maintenance mode.

The biggest challenge is the handling of the translation files. We need to make sure that the marked texts find their way to the translators and back to the application before it’s deployed. To help with this we need to find a way to automate the generation of translation files and get notified when we have missing translations.

Generating the translation files

It’s not sustainable to keep merging the translation files manually. We need some automation! To implement this, I’m using a free tool called Xliffmerge.

Since this tool has old Angular versions as peerDependencies we need to use --legacy-peer-deps if we are using a new version of NPM (v7) that would otherwise fail on installation.

The documentation for Xliffmerge is targeting older versions of Angular, but after some experimentation, I found it enough to install the @ngx-i18nsupport/tooling package:

npm install -D @ngx-i18nsupport/tooling --legacy-peer-deps

Note that -D installs to devDependencies, and for use in a CI pipeline, you should omit it to use in dependencies.

Then we can add new languages to the configurations in angular.json under projects -&gt; projectName -&gt; architect -&gt; xliffmerge.

"xliffmerge": {
  "builder": "@ngx-i18nsupport/tooling:xliffmerge",
  "options": {
    "xliffmergeOptions": {
      "defaultLanguage": "en-US",
      "languages": ["nb"]
    }
  }
}

After adding new translations, we can extract them and migrate them to our translation files by running this script:

ng extract-i18n && ng run projectName:xliffmerge

We get a couple of warnings running the script which tells us its working!

WARNING: merged 1 trans-units from master to "nb"
WARNING: please translate file "messages.nb.xlf" to target-language="nb"

After this, you can distribute the language files to the translators. And when the translations finish, the files need to be merged back into the project repository.

Just a word of caution that this library was not being actively maintained at the time of this writing, so you might want to look into other options. There is an Angular issue on merging translated files. Go and upvote it if you think this is something that we need!

Missing Translations

Another way to make sure the translations are valid is to get noticed if translations are missing. By default, the build succeeds but generates a warning of missing translations. We can configure the level of the warning generated by the Angular compiler:

  • error: An error message is displayed, and the build process is aborted.

  • warning (default): Show a Missing translation warning in the console or shell.

  • ignore: Do nothing.

Specify the warning level in the options section for the build target of your Angular CLI configuration file, angular.json. The following example shows how to set the warning level to error:

"options": {
  "i18nMissingTranslation": "error"
}

If you run the application and no translation is found, the application displays the source-language text. We have to make a decision here on how important the translations are. If they are crucial then we should break the build to make sure we get all translations delivered.

Format data based on locale

Languages are not the only thing to take into consideration when localizing applications. A couple of the more obvious things we need to think about is how we present dates and numbers to our local customers.

In Angular, we provide the LOCALE_ID token to set the locale of the application and register locale data with registerLocaleData(). When we use the --localize option with ng build or run the --configuration flag with ng serve, the Angular CLI automatically includes the locale data and sets the LOCALE_ID value.

With the LOCALE_ID set to the correct locale, we can use the built-in pipes of Angular to format our data. Angular provides the following pipes:

For example, {{myDate | date}} uses DatePipe to display the date in the correct format. We can also use the pipes in TypeScript files as long as we provide them to the module.

Runtime translations

When we run ng serve --configuration=xx or ng build --localize then the application is compiled and translated before we run it. However, if we don’t tell Angular to localize our application, then the $localize tags are left in the code, and it’s possible to instead do the translation at runtime.

This means that we can ship a single application and load the translations that we want to use before the application starts. There is a function loadTranslations in @angular/localize that can be used to load translations, in the form of key/value pairs, before the application starts.

Since the translations have to be called before any module file is imported, we can put it in polyfills.ts. You could also use it in main.ts by using a dynamic import(...) for the module.

Here is an example of using loadTranslations in polyfills.ts:

import '@angular/localize/init';
import { loadTranslations } from '@angular/localize';

loadTranslations({
  'welcome': 'Velkommen'
});

Note that the outcome of this is effectively the same as translation at compile-time. The translation happens only once If you want to change the language at runtime then you must restart the whole application. Since $localize messages are only processed on the first encounter, they do not provide dynamic language changing without refreshing the browser.

The main benefit is allowing the project to deploy a single application with many translation files. The documentation on this part is still lacking, but hopefully, we get official documentation on how to best work with loadTranslations and $localize. There are 3rd party libraries like Soluling out there trying to bridge the gaps.

If a dynamic and runtime-friendly solution is what you are looking for, then you should use Transloco.

Conclusion

We started this article by looking into how the new Ivy engine changed the i18n and localizing of applications with Angular. We looked into what benefits and drawbacks this entails and if and when we should use alternative solutions.

We then looked into adding the built-in package to a solution and how we mark texts for translation. We learned how to configure the application for localization and added tooling to manage our translation files. When we used an editor for translating, we saw how adding context to translations helps.

Finally, after configuring and translating the application, we set up a web server to serve our application both locally and in production.

There are many parts to localizing an application and I hope that after reading this article, you have a better understanding of how you can create and manage multi-language applications with Angular.

Resources

Did you find this article valuable?

Support Michael Karén by becoming a sponsor. Any amount is appreciated!