How to translate Angular apps: @angular/localize and XLIFF

Joachim Grill, Andreas Löw
Last updated:
GitHub
How to translate Angular apps: @angular/localize and XLIFF

How to translate your Angular application - a matter of choice

Angular comes with a package called @angular/localize which is Angular's native way of translating your application. However, there are other packages - e.g. ngx-translate which has several advantages over @angular/localize.

If you've already made your decision - this is the right tutorial for you. You might otherwise want to consider the following restrictions:

  • Angular builds separate apps for each language
  • You can't change the locale at runtime

If one of these is a restriction you can't live with checkout ngx-translate.

This tutorial builds a simple application with Angular and adds translations to it. You can easily skip the first steps if you already have an application to work with. The example application is available as source code on github.

Optional: Create a simple app to translate

Now we set up an application that you can use to experiment with some use-cases. You can skip this if you want to work on an existing application.

  • Simple text
  • Text with a simple parameter
  • Text with a counter and a selection

First, install the latest Angular command line client and create a new, empty application:

npm install -g @angular/cli
ng new angular-localization-demo

Answer the questions like this:

? Which stylesheet format would you like to use?
  > SCSS
? Do you want to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)?
  > No

Run ng serve and open your browser on http://localhost:4200

Replace the content of the src/app/app.component.html with the following code:

<style>
    h1 {margin-bottom: 4rem}
    p {margin-bottom: 2rem}
    div {margin: 4rem; font-family: sans-serif; font-size: 14px;}
</style>

<div>
    <!-- static text -->
    <h1>Translation demo</h1>
</div>

Replace src/app/app.component.ts with the following code:

import { Component } from '@angular/core';

enum Fruit { apple = 'apple', pear = 'pear' };

@Component({
  standalone: true,
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  name = "John Doe";
  counter = 1;
  fruit:Fruit = Fruit.apple;

  public changeCounter(change:number)
  {
      this.counter = Math.max(0, this.counter+change);
  }

  public toggleFruit()
  {
      this.fruit = this.fruit === Fruit.apple ? Fruit.pear : Fruit.apple;
  }
}

Prepare your app for translation

Install "localize" package

First, install Angular's localization package in your project:

ng add @angular/localize

Locale IDs

Angular uses locale identifiers defined by BCP47.

These identifiers consist of 2 parts:

  • language
  • country code

Examples:

  • en-US - English (en) spoken in the United States (US)
  • en-GB - English (en) spoken in the Great Britain (GB)
  • fr-FR - French (fr) spoken in France (FR)
  • fr-CA - French (fr) spoken in Canada (CA)

The country code has effects on the language (e.g. gray vs grey in en-US vs en-GB) but also effects how Angular formats numbers, dates, times, currencies and other values when you are using DatePipe, CurrencyPipe, DecimalPipe or PercentPipe.

Angular's default locale is en-US.

Translation workflow

The translation process for Angular applications consists of 5 steps:

  1. Mark all text you want to translate in your templates.
  2. Use the ng extract-i18n command line tool to extract the translations and create an XLIFF translation file
  3. Translate the messages in the file (e.g. by using BabelEdit)
  4. Edit the applications' configuration to recognize the new locale
  5. Compile your application with the locales

This tutorial takes you through all of these steps.

We start with simple static text and go through the whole process. After that we'll cover pluralization and more complex stuff using the ICU syntax.

Add i18n markup and extract text

The src/app/app.component.html contains the following line:

<h1>Translation demo</h1>

To mark it as translatable text, you simply have to add an i18n attribute:

<h1 i18n>Translation demo</h1>

Simple. Now run

ng extract-i18n --out-file src/locale/messages.en.xlf

This generates a file called src/locale/messages.en.xlf - an XML based translation file in an industry standard format called XLIFF. This file contains all messages extracted from your source.

Understanding translation IDs

It now seems a bit low-level to take a deeper look at this file — but I am taking you here to help you understand and avoid problems in your project.

In this file you see a so called trans-unit:

<trans-unit id="3831908475916825840" datatype="html">
    <source>Translation demo</source>
    <context-group purpose="location">
        <context context-type="sourcefile">src/app/app.component.html</context>
        <context context-type="linenumber">9</context>
    </context-group>
</trans-unit>

The trans-unit contains the source text in <source> and a file location.

It also contains an attribute called id - that's an identifier derived from the source text.

Angular created this identifier for you because we did not specify any identifier manually. The problem with this identifier is that it changes each time you edit the source text!

Add a simple ! after the text and run ng extract-i18n --out-file src/locale/messages.en.xlf

<h1 i18n>Translation demo!</h1>

See how the ID changed from 3831908475916825840 to 2503049323772408536. This is a problem because Angular uses the ID to reference the messages belonging together across multiple language files. If you've not created your translation in another file, the translation is now lost! Ouch!

The better way is to specify translation IDs manually. The title has to start with @@.

<h1 i18n="@@main.title">Title with manual ID</h1>

You can use . in the title which allows you to structure your IDs. If you use BabelEdit you'll see the translations in a nice tree view which gives you even more control.

Take a look at the messages.en.xlf: This is much better now:

<trans-unit id="main.title" datatype="html">
    <source>Title with manual ID</source>
    ...
</trans-unit>

If you absolutely don't want to set IDs manually read the section Dealing with auto ids.

The translation process with BabelEdit

I'll show you how to translate the file into German — simply because this is the language I know best :) Feel free to translate the file into a language of your choice.

XLF is not meant to be edited by hand. Yes — you can do this. But: It's a real pain because you have to keep all your language files in sync. After adding a new message in one file, you have to update all other language files.

Do yourself a favor and download BabelEdit — for the sake of this tutorial. It comes with a free trial of 7 days, and it's quite inexpensive for a translation tool.

Install and start the tool. Click on Angular and XLIFF to start a new translation project:

Select Angular XLIFF format

Next, drop your src/locale/message.en.xml onto the language configuration dialog:

Drop extracted XLF file

As soon as the file has been dropped a dialog is opened, there you can choose the language the file contains. Select en-US and click Ok.

In the next step, select en-US as primary language. The primary language is the source language you use in your code. BabelEdit uses it for features like machine translations and suggestions.

It also becomes read-only and can't be edited. The reason for this is that all changes you make to your source language would be over-written the next time you run ng extract-i18n.

Choose the primary language for the translation

To add your first target language, click Add language and choose de-DE. This adds a new file name field for de-DE to the package main. Use the file selector icon on the right side of the file name field to set the name of the new file.

Add a new language to your Angular translation project

With these steps you should now see a screen similar to the following:

BabelEdit main screen with loaded Angular XLIFF files
  1. The left side shows you the translation IDs available in your source code files. Note that the manually set translation ID main.title has a speech bubble icon. Translations with a '.' automatically generate a hierarchical structure. Angular's automatic IDs always create a flat list. These hard-to-read IDs are replaced with parts of the source text.
  2. The center view shows the selected translations from the left tree. Here is where you can see the full ID of the translation in the first line.
  3. The text in the en-US edit fields is read only (primary language).
  4. The text in the de-DE row is editable.
  5. Click Enable if you want to send your source messages to Google Translate™ or other online translation services to get suggestions and automatic translation of your files.

BabelEdit now suggests translations for you:

Automatically translating XLF files with Google Translate

Fill the German text fields and press Save. BabelEdit asks you to enter the name of a .babel project file — this is used to save you the setup time. The file does not contain translation data — it is stored in the xlf files directly.

Adding the xlf translation file to your Angular app

In your angular.json you have to specify the source language. For all target languages you set the file name from which the translated messages should be loaded:

"projects": {
    "angular-localization-demo": {
        "i18n": {
            "sourceLocale": "en-US",
            "locales": {
                "de": {
                    "translation": "src/locale/messages.de.xlf"
                }
            }
        },
        ...

To build the app for all configured languages, you have to add the following options to the build configuration in your angular.json:

"projects": {
    "angular-localization-demo": {
        "architect": {
            "build": {
                "options": {
                    "localize": true,
                    "aot": true,
                    "i18nMissingTranslation": "error",
                    ...
  • the localize option tells Angular to build variants of your app for all languages. Instead of true you can pass a subset of your configured languages here, e.g. "localize": [ "en", "fr" ]
  • the aot option enables ahead-of-time compilation. Angular does not support building localized apps in JIT (just-in-time) compilation mode.
  • the i18nMissingTranslation defines what should happen if a translation is missing: by default a warning is displayed at compile-time, and the app uses the source language text as fallback. If this option is set to error, an error message is displayed, and the build process is aborted.

Note: If you enable "Save empty translations" in the BabelEdit configuration, missing translations will be saved as empty strings in the XLIFF file. Angular will no longer report these empty strings as "missing translation".

ng serve can only serve one language. So you might want to define one build configuration per language, and refer to these build configurations in the serve configurations:

"projects": {
   "angular-localization-demo": {
      "architect": {
         "build": {
            "configurations": {
               "en": {
                  "localize": [ "en-US" ]
               },
               "de": {
                  "localize": [ "de" ]
               },
               ...
         },
         ...
         "serve": {
            "development-de": {
               "buildTarget": "angular-localization-demo:build:development,de"
            },
            "development-en": {
               "buildTarget": "angular-localization-demo:build:development,en"
            },
            "defaultConfiguration": "development-en",
            ...
         }

With this configuration you can serve the localized app:

ng serve --configuration=development-de

You should now see the title in German.

Translate your Angular app

Providing more information to your translators

With the manual IDs you can already give the translator some context. main.title says that this text is on the main screen, and it's the title - that's not perfect but at least better than nothing.

You can give more context to the translator by altering the i18n attribute in the template: Add a more clear description before the @@:

<h1 i18n="Long title on the main screen.@@main.title">Title with manual ID</h1>

Run ng extract-i18n --out-file src/locale/messages.en.xlf and return to BabelEdit. The translation instruction from the file appears right after the translation ID.

Comments and translation instructions

You can also add more detailed instructions by clicking on the little note-icon on the right.

Text in component code

If your TypeScript code contains strings which must be translated, the text must be surrounded with backticks and prefixed with $localize:

helloAgain = $localize `Hello again!`;

It is also possible to specify a description and an ID in front of the text:

helloAgain = $localize `:A text defined in the typescript file@@main.hello-again:Hello again!`;

Messages with parameters (interpolation)

Let's add a welcome message with the name as parameter to the template:

<p i18n="Welcome message.@@main.hello">Hello {{name}}!</p>

This simply places the value of the name variable in the text.

In BabelEdit (after running ng extract-i18n) you'll see something like this:

Hello <x id="INTERPOLATION" equiv-text="{{name}}"/>!

For the translation simply copy the <x.../> to the target language. Don't change it. So the German translation is:

Hallo <x id="INTERPOLATION" equiv-text="{{name}}"/>!

I must admit that this is not quite nice - but you should look at it as work in progress. We have plans to add an ICU syntax editor to BabelEdit. With this you'll get a better representation of placeholders, too.

Save in BabelEdit... and nothing happens in your Angular app. Yes. ng serve does not look for changes in .xlf files... you have to restart ng serve to see the changes.

Number parameters and pluralization

First run

ng serve

to switch back to the main language (English).

Add a new message and 2 buttons (one for an increment, one for a decrement) to the project:

<p i18n="@@icu.plural">There are {{counter}} apples in the basket.<p>

<button (click)="changeCounter(-1)">-</button>
<button (click)="changeCounter(1)">+</button>

Ok... not exactly what you want?

  • There are 0 apples in the basket.
  • There are 1 apples in the basket.
  • There are 5 apples in the basket.

Yes - we can do better! With ICU syntax and something that is called pluralization:

<p i18n="@@icu.plural">There {counter, plural,
        =0 {are no apples}
        one {is one apple}
        other {are {{counter}} apples}
    } in the basket.<p>

The syntax consists of the following:

  • counter - the variable that is used for the choice - see app.component.ts
  • plural - a keyword that triggers the pluralization
  • <selector> {text} - the selector activates the following text in {} if the condition is met

<selector> can be one of the following:

  • =0, =1, =2, ...
  • one
  • two
  • few - not available in all locales
  • many - not available in all locales
  • other

The {text} can be any text — you can even create nested ICU messages.

ICU supports # as placeholder for the current value. Angular does not. If you want to write the counter value you have to use {{counter}}.

Click on the + and - buttons to see the message change:

  • There are no apples in the basket.
  • There is one apple in the basket.
  • There are 5 apples in the basket.

That's way better!

Let's update the translation files with:

ng extract-i18n --out-file src/locale/messages.en.xlf

and open BabelEdit. Ok — now something unexpected happens: Instead of one you now get 2 new entries!

The first one has the ID you've specified in the "i18n" attribute. It contains the static text parts and an <x .../> element as placeholder for the pluralization part:

icu.plural
There
<x equiv-text="{counter, plural, =0 {are no apples} one {is one apple}  other {are {{counter}} apples}}" id="ICU" xid="3168946305235542239"/>
in the basket.

Only the static parts of this entry need to be translated. The <x ...> element will be entirely replaced by the second extracted ID. In the translation the equiv-text and xid attributes can be omitted, but the id="ICU" must be kept. If a text contains multiple pluralization expressions, Angular will generate distinct IDs ICU, ICU_1, ICU_2, ... to identify these expressions.

icu.plural (de-DE)
Es <x id="ICU" /> im Korb.

The other extracted entry contains only the ICU pluralization part of the message, it has an automatically generated ID:

5267610513654552639
{VAR_PLURAL, plural,
    =0 {are no apples}
    one {is one apple}
    other {are <x id="INTERPOLATION"/> apples}
}

Use BabelEdit to enter the German translations for the pluralization forms:

5267610513654552639 (de-DE)
{VAR_PLURAL, plural,
    =0 {sind keine Äpfel}
    one {ist ein Apfel}
    other {sind <x id="INTERPOLATION"/> Äpfel}
}
XLIFF pluralization with ICU syntax for Angular

Selections in ICU syntax

Let's assume you also want to extend the application to not only support apples but also pears. Let's deal with one entry for the start. Plural rules will be added later.

fruit is a variable of an enum type:

enum Fruit { apple, pear };

With this you could write:

<!-- simple select -->
<p i18n="@@icu.selection">There is one {{fruit}} in the basket.<p>

<button (click)="toggleFruit()">Change fruit</button>

Ok... so... this renders to There is one 0 in the basket.. Right. Enums are numbers. We can use an ICU select for this:

<p i18n="@@icu.selection">There is one {fruit, select,
    0 {apple}
    1 {pear}
} in the basket.<p>

There is unfortunately no way to use the enum values instead of the numbers in the template. The only way I know around this is to set a string value for each enum entry:

enum Fruit { apple='apple', pear='pear' };

Now you can write the following in the template:

<p i18n="@@icu.selection">There is one {fruit, select,
    apple {apple}
    pear {pear}
} in the basket.<p>

You can of course use defined strings instead:

type Fruit = 'apple' | 'pear';

Run the extraction and open BabelEdit. In German and several other languages (e.g. French) you have to put the article or numerals ("one") inside the select.

The reason is that these languages use different articles/numerals depending on the gender of the noun.

en-US:

There is one <x equiv-text="{fruit, select, apple {apple} pear {pear} }" id="ICU" xid="5011132262747619083"/> in the basket.

{VAR_SELECT, select, apple {apple} pear {pear}}

de-DE:

Es ist <x id="ICU"/> im Korb.

{VAR_SELECT, select, apple {ein Apfel} pear {eine Birne}}

Dealing with Angular's auto-generated IDs

For the sake of consistency I'd recommend putting the "one" inside the selection - but you don't have to. ICU is clever enough to deal with both variations... anyway, let's do it because I'd like to show you another pitfall with Angular's translation module:

Change the template to this:

<p i18n="@@icu.selection">There is {fruit, select,
    apple {one apple}
    pear {one pear}
} in the basket.<p>

Extract the messages with

ng extract-i18n --out-file src/locale/messages.en.xlf

and open BabelEdit:

Angular XLF with automatically generated IDs

What you see here is:

  1. The new ID with no translation.
  2. The ID that was removed by Angular - the source language is empty.

BabelEdit keeps it open for you so that you can copy the texts to the new IDs. You can find these orphaned translations using BabelEdits filter function:

Filtering orphaned translations

Now copy the text from the deleted translation to the new id.

After that, use the menu item Edit / Delete unused translation... to get rid of them.

Delete unused translations

Nesting ICU message

Let's now combine both examples: We want to change the counter and the fruit type.

EnglishGerman
There are no apples in the basket.Es sind keine Äpfel im Korb.
There is one apple in the basket.Es ist ein Apfel im Korb.
There are 5 apples in the basket.Es sind 5 Äpfel im Korb.
There are no pears in the basket.Es sind keine Birnen im Korb.
There is one pear in the basket.Es ist eine Birne im Korb.
There are 5 pears in the basket.Es sind 5 Birnen im Korb.

The English sentence is simple - because the gender of the fruit does not influence the numeral:

<p i18n="@@icu.combined">
    There
        {counter, plural,
            =0 {are no {fruit, select, apple {apples} pear {pears}} }
            one {is one {fruit, select, apple {apple} pear {pear}} }
            other {are {{counter}} {fruit,select, apple {apples} pear {pears}} }
        }
    in the basket.
<p>

Ok - this works in English. Now run ng extract-i18n --out-file src/locale/messages.en.xlf and open BabelEdit:

You now see a new entry icu.combined with this text:

en-US
There
<x equiv-text="{counter, plural, =0 {are no {fruit, select, apple {apples} pear {pears}} } one {is one {fruit, select, apple {apple} pear {pear}} } other {are {{counter}} {fruit,select, apple {apples} pear {pears}} } }" id="ICU" xid="5608797790597193677"/>
in the basket.

Add the German translation:

de-DE
Es <x id="ICU"/> im Korb.

Easy.

The nested ICU part of the translation now became:

en-US
{VAR_PLURAL, plural,
    =0    {are no {VAR_SELECT, select, apple {apples} pear {pears} } }
    one   {is one {VAR_SELECT_1, select, apple {apple} pear {pear} } }
    other {are <x id="INTERPOLATION"/>
                  {VAR_SELECT_2, select, apple {apples} pear {pears} } }
}

What you see here is that Angular for some strange reason does not create nested entries beyond the first level. So... let's translate this. As seen in our table from above we have to move the "is one" into the select:

de-DE
{VAR_PLURAL, plural,
    =0    {sind keine {VAR_SELECT, select, apple {Äpfel} pear {Birnen} } }
    one   {ist {VAR_SELECT_1, select, apple {ein Apfel} pear {eine Birne} } }
    other {sind <x id="INTERPOLATION"/>
               {VAR_SELECT_2, select, apple {Äpfel} pear {Birnen} } }
}

Ok... it works!

Conclusion

As you have seen, internationalization with @angular/localize has some difficulties. However, with the manual definition of translation IDs and the use of BabelEdit it is still a feasible way.

Nevertheless, it may make sense to consider using ngx-translate instead.