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:
- Mark all text you want to translate in your templates.
- Use the
ng extract-i18n
command line tool to extract the translations and create an XLIFF translation file - Translate the messages in the file (e.g. by using BabelEdit)
- Edit the applications' configuration to recognize the new locale
- 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:
Next, drop your src/locale/message.en.xml
onto the language configuration dialog:
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
.
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.
With these steps you should now see a screen similar to the following:
- 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. - 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.
- The text in the
en-US
edit fields is read only (primary language). - The text in the
de-DE
row is editable. - 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:
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 oftrue
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.
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.tsplural
- 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 localesmany
- not available in all localesother
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:
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.
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:
{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:
{VAR_PLURAL, plural,
=0 {sind keine Äpfel}
one {ist ein Apfel}
other {sind <x id="INTERPOLATION"/> Äpfel}
}
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:
What you see here is:
- The new ID with no translation.
- 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:
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.
Nesting ICU message
Let's now combine both examples: We want to change the counter and the fruit type.
English | German |
---|---|
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:
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:
Es <x id="ICU"/> im Korb.
Easy.
The nested ICU part of the translation now became:
{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:
{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.