← All articles

Angular i18n Is Great for Semi-Automated Multilingual Apps — Including DeepL Auto-Translation

Why compile-time Angular i18n shines, plus a workflow to auto-translate JSON locale files with DeepL.

Published
Angular i18n Is Great for Semi-Automated Multilingual Apps — Including DeepL Auto-Translation cover image

The official Angular i18n package (@angular/locale) is very well done, and automatic translation with DeepL was easy too, so I want to share what worked well. If you think "no way, that is still rough" or "my tool is stronger", please write your own article—I would love to see more shared knowledge.

"Angular i18n" can mean the official package (@angular/locale) or internationalizing an Angular app; in this article I mean the former.

Four things I liked about Angular i18n

Here are four points that made developer experience great after I adopted it.

1. No performance issues thanks to compile-time i18n

First, SPA multilingual support generally follows one of two approaches. Using mhevery's https://github.com/robisim74/qwik-speak/discussions/8#discussioncomment-3610200 as reference, compare the two below.

runtime i18n

runtime i18n loads translation files at SPA runtime in the browser and replaces target strings. A simple example: switching localStorage lang might look like this.

hello.js
const dictionaryEn = {
  HELLO: `hello`
};
const dictionaryJa = {
  HELLO: `こんにちは`
};

return dictionary[localStorage.getItem(`lang`)].HELLO;

Libraries like i18next use this, and it is easy to implement, so many libraries multilingualize this way. You must load translation files before rendering the view, which can hurt performance.

compiletime i18n

compiletime i18n replaces target strings per language at compile time and outputs separate files. In the example above, two files are emitted at compile time.

en/hello.js
return 'hello';
ja/hello.js
return 'こんにちは';

Unlike runtime i18n, runtime performance is unaffected, and there is no dictionary file per language at runtime—it stays lightweight. Compile time grows, so during development you often serve one language and output multiple languages only for release.

Angular i18n uses compiletime i18n

Angular i18n uses compiletime i18n, so performance stays fine no matter how large the dictionary grows. During development I only use the source language and do not multilingualize, so serve did not get slow either.

One caveat is the post-build folder layout. For Japanese and English output, it looks like this.

  • wwwroot/en-US/
  • wwwroot/ja/

You can change wwwroot and language folder names in config, but folders are always created per language. The docs describe reading the user's Accept-Language header, returning the matching locale, and falling back to the default when unknown. Firebase Hosting has similar behavior; when users pick a language, switch detection with cookies or similar.

You can also place your own index.html and redirect (example: https://github.com/ionic-team/capacitor/issues/3912#issuecomment-1272498658) and serve each language on a separate path, for example:

Or use separate domains:

Remember you need server-side considerations, not just the JS layer.

2. Easy string extraction; XLF shows exactly what to translate

Angular marks translatable strings and extracts them automatically. In templates, add i18n.

<h1>こんにちは</h1>



<h1 i18n>こんにちは</h1>

In TypeScript, add $localize to strings in code.

@Component({
  ...
})
export class AppComponent implements OnInit {
  title = $localize`こんにちは`;
}

Run ng extract-i18n and you get a file like this automatically.

messages.xlf
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="ja" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="1129199713308059907" datatype="html">
        <source>こんにちは</source>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.html</context>
          <context context-type="linenumber">10</context>
        </context-group>
        <context-group purpose="location">
          <context context-type="sourcefile">src/app/app.component.ts</context>
          <context context-type="linenumber">5</context>
        </context-group>
      </trans-unit>

Briefly: file source-language="ja" means the source language is Japanese. trans-unit id="1129199713308059907" is the string ID (auto-assigned; you can fix it). source is the string to translate. context-group purpose="location" shows file and line.

When I multilingualized before, I copied every string into a spreadsheet myself—this is so much better!!!! To translate in XLF, do this:

xml:messages.xlf
  <?xml version="1.0" encoding="UTF-8" ?>
  <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
    <file source-language="ja" datatype="plaintext" original="ng2.template">
      <body>
        <trans-unit id="1129199713308059907" datatype="html">
          <source>こんにちは</source>
+         <target>Hello</target>

3. JSON format makes DeepL auto-translation easy

Maybe this was my lack of research, but keeping XLF format stable under programmatic control is hard. I gave up midway. Specifically, loading messages.xlf with xliff or xml2json, converting to JS objects, writing back to XML, and overwriting messages.xlf changed line breaks and whitespace from the original.

https://twitter.com/rdlabo/status/1578652224439615488https://twitter.com/rdlabo/status/1578652224439615488

That makes diffs hard to follow on every translation run, shapes change when programs read the file, and blank lines break matching between source and target strings—I was stuck until I learned Angular i18n can use JSON instead of XLF for translation files.

{
  "locale": "ja",
  "translations": {
    "1129199713308059907": "こんにちは",

JSON loads in Node, you overwrite objects, and diffs stay clean. Success.
Looping over entries let me send source strings straight to the DeepL API for translation.

4. Automatic date conversion

I did not notice this until translating: date formats differ by country. Japanese and English differ even with half-width characters.

  • Japanese: 2022/07/19
  • English: Jul 19, 2022

That was a blind spot, but Angular's Date Pipe can change display automatically when using Angular i18n. Nice. Currency is not converted automatically, so ¥1,000 did not become $1,000 hyperinflation (laugh).

Guide to Angular i18n with DeepL

Finally, a very simple guide to Angular i18n with DeepL.

1. Install

Install with ng add @angular/locale. After install, set in angular.json which language is the source and which files are translations.
Below is angular.json from my app: project app (Ionic Angular default), Japanese source, English output.

angular.json
{
...
  "projects": {
    "app": {
    ...
      "i18n":{
        "sourceLocale": {
          "code": "ja",
          "baseHref": "/ja/"
        },
        "locales": {
          "en-US": {
            "translation": "src/locale/messages.en-US.json",
            "baseHref": "/en-us/"
          }
        }
      },

See https://gist.github.com/rdlabo/6a5c96ebaffd0996f38e521edbdb500a for the full file. Fine details are faster in the official docs.

https://angular.jp/guide/i18n-common-mergehttps://angular.jp/guide/i18n-common-merge

2. Create translation files

First, mark templates with i18n and TypeScript with $localize—no way around choosing translatable vs not.

When done, extract translation files with:

% ng extract-i18n --output-path src/locale --format=json && ng extract-i18n --output-path src/locale

I output two files: JSON for processing and XLF to see where strings live. I only reference XLF and do not modify it.

You also need an initial target file, so copy the JSON:

% cp src/locale/messages.json src/locale/messages.en-US.json

3. Translate with DeepL

For DeepL, prepare deepl.config.json. Use your own authKey.

{
  "source": "src/locale/messages.json",
  "outputDir": "src/locale/",
  "fromLanguage": "ja",
  "toLanguage": [
    "en-US"
  ],
  "ulr": "https://www.deepl.com/docs-api",
  "authKey": "****************************"
}

Set source file, source language, and output languages here. Next, a script that sends strings to DeepL and writes JSON. For future French etc., BCP47 is an object.

import deeplConfig from 'deepl.config.json';
const translate = require('deepl');
const fs = require('fs');

const BCP47 = {
  'en-US': 'EN',
};

(async () => {
  const sourceFile = fs.readFileSync(deeplConfig.source).toString();

  const translated = await Promise.all(
    deeplConfig.toLanguage.map(async (language) => {
      const sourceMessage = JSON.parse(sourceFile);
      sourceMessage.locale = language;

      const toFile = fs.readFileSync(deeplConfig.outputDir + `messages.${language}.json`).toString();
      const translatedMessage = JSON.parse(toFile);

      const translatedValue: {
        key: string;
        value: string;
      }[] = [];

      await Promise.all(
        Object.keys(sourceMessage.translations).map(async (item) => {
          let text = sourceMessage.translations[item];

          if (
            translatedMessage.translations.hasOwnProperty(item) &&
            !translatedMessage.translations[item].match(/[亜-熙ぁ-んァ-ヶ]/)
          ) {
            // すでに翻訳されている
            text = translatedMessage.translations[item];
          } else if (!text.includes('{$') && !text.includes('https://') && !text.includes('<') && text) {
            // textを翻訳
            const response = await translate({
              free_api: true,
              text,
              source_lang: 'JA',
              // @ts-ignore
              target_lang: BCP47[language],
              auth_key: deeplConfig.authKey,
            });
            text = response.data.translations[0].text;
            console.log(`${text}を翻訳しました`);
          }

          translatedValue.push({
            key: item,
            value: text,
          });
        }),
      );

      for (const item of Object.keys(sourceMessage.translations)) {
        sourceMessage.translations[item] = translatedValue.find((v) => v.key === item)!.value;
      }

      return {
        locale: language,
        translations: sourceMessage.translations,
      };
    }),
  );

  translated.forEach((item) => {
    const content = JSON.stringify(item, null, '  ');
    fs.writeFileSync(deeplConfig.outputDir + `messages.${item.locale}.json`, content);
  });
})();

/[亜-熙ぁ-んァ-ヶ]/ checks for Japanese; if none, treat as already translated and skip. Skip translation when the string contains {$, https://, or <{$ marks variables, https:// URLs, and < markup.

Running this writes translated JSON to src/locale/messages.en-US.json.

Output looks like this.

json
  {
    "locale": "ja",
    "translations": {
-     "1129199713308059907": "ログアウトしました",
-     "1847641466768577553": "アカウントを削除しました",
-     "1568991857305935308": "パスワードリセットのためのメールを送信しました",
-     "342060829965180955": "認証のためのメールを送信しました",
-     "7333828782410849414": "メールアドレスが必要です",
+     "1129199713308059907": "Logged out.",
+     "1847641466768577553": "Account deleted.",
+     "1568991857305935308": "Email sent to reset password",
+     "342060829965180955": "Email sent for authentication.",
+     "7333828782410849414": "Email address is required",

Translation worked fine.

Summary

You can use runtime-style libraries like ngx-translate with Angular, but automatic extraction, XLF plus JSON for translation work, and locale-aware date formatting without extra libraries felt great, so I will keep using @angular/locale. It also switches the lang attribute on index.html automatically.

It was fun—try multilingual/i18n yourself!