Angular localization in a multi application workspace setup

Nicolas Gehlert
Virtual Minds
Published in
4 min readDec 6, 2022
Code Editor view that displays some example code
Photo by David Pupaza on Unsplash

In this quick little post I want to show you how you can use a single localization file for multiple applications within one Angular workspace.

This is really useful if your workspaces consists internal libraries next to applications and you want to share translations from the localization between the different applications. In Angular usually the translations of a library would end up in each localization file of a project that uses it. This causes a lot of additional maintenance effort if you want to use the same translations across your application.

To get the most out of this article you should already be familiar how the general i18n behavior of Angular works (https://angular.io/guide/i18n-overview)

Key extraction

In the angular.json configuration file of the workspace we should adapt/add a part to extract the i18n keys from applications to a locales directory that is outside of the projects scope because we want to share it for all applications.

"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "appOne:build:development",
"outFile": "locales/messages.appOne.xlf"
}
},

Add this for every application that you want to use the same translations for. To simplify the extraction afterwards you can add an entry to your package.json that looks like this

"get-translation-keys": "ng extract-i18n appOne && ng extract-i18n appTwo …

Combine key files into one

We now have multiple mesages.XXX.xlf files that contain keys for the different applications. To combine those we will add a small library in order to parse .xml files: xml2js (https://www.npmjs.com/package/xml2js)

Now we can create a small JavaScript file concat-key-files.js that contains the following

const xml2js = require('xml2js');
const fs = require('fs');
const args = process.argv.slice(2);

const projects = args[0].split(',');
const filePath = 'locales';

async function run() {
const results = [];
for (const project of projects) {
const filePathConfiguration = `${filePath}/messages.${project}.xlf`;
const content = fs.readFileSync(filePathConfiguration, 'utf8');
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(content);
results.push(result);
fs.unlinkSync(filePathConfiguration);
}
const finalResult = results.shift();
results.reduce((result, currentProject) => {
currentProject.xliff.file[0].body[0]['trans-unit'].forEach(currentKeyEntry => {
if (!result.xliff.file[0].body[0]['trans-unit'].find(entry => {
return entry.$.id === currentKeyEntry.$.id;
})) {
result.xliff.file[0].body[0]['trans-unit'].push(currentKeyEntry);
}
});

return result;
}, finalResult);

const builder = new xml2js.Builder();
const xml = builder.buildObject(finalResult);
fs.writeFileSync(`${filePath}/messages.xlf`, xml);
}

run().then(() => {
process.exit(0);
});

Lets go over the parts individually. We add support to call this script with arguments so we can specify the different projects from the command line later and add a script to our package.json that looks like this

"combine-key-files": "node concat-key-files.js appOne,appTwo",

With fs.readFileSync we get the content as a string to then pass to the xml2js.Parser().parseStringPromise method. Because we don’t need the single messages.XXX.xlf files anymore I’d like to remove them with fs.unlinkSync but feel free to remove this line if you like to keep them for debugging purposes.

const finalResult = results.shift();
results.reduce((result, currentProject) => {
currentProject.xliff.file[0].body[0]['trans-unit'].forEach(currentKeyEntry => {
if (!result.xliff.file[0].body[0]['trans-unit'].find(entry => {
return entry.$.id === currentKeyEntry.$.id;
})) {
result.xliff.file[0].body[0]['trans-unit'].push(currentKeyEntry);
}
});

return result;
}, finalResult);

This section iterates over the individual results. From the parser we get a JSON representation of the XML file content. We take the first file result as base and reduce the other files onto this result. With the .find() on the result we check if an entry with the same id is already there and add it if not. We do this to not have the same entry multiple times afterwards.

Last step is to use the xml2js library again and convert the JSON structure back to a proper XML file called messages.xlf .

If you now run this script with npm run combine-key-files you should only have one messages.xlf file that contains all keys.

Merge and update individual language files

From this one key file we now can generate files for every language we need. For this I recommend a small npm library called ngx-i18nsupport (https://www.npmjs.com/package/@ngx-i18nsupport/ngx-i18nsupport). This add a cli script called xliffmerge .

Add a xliffmerge.json configuration file to the root of your project with the following content

{
"xliffmergeOptions": {
"srcDir": "locales",
"genDir": "locales",
"i18nFile": "messages.xlf",
"i18nBaseFile": "messages",
"i18nFormat": "xlf",
"encoding": "UTF-8",
"defaultLanguage": "key",
"languages": ["de", "en", "fr"],
"removeUnusedIds": true,
"supportNgxTranslate": false,
"ngxTranslateExtractionPattern": "@@|ngx-translate",
"useSourceAsTarget": true,
"targetPraefix": "(MISSING): ",
"targetSuffix": "",
"beautifyOutput": true,
"allowIdChange": false,
"autotranslate": false,
"apikey": "",
"apikeyfile": "",
"verbose": false,
"quiet": false
}
}

You can look up each option with an explanation in the documentation. The most important two things is the languages property. Add an entry there for every language you want to support.
And the targetPraefix property. This will add a string prefix for every entry that is in your key file but not in your translation file already. This way you can easily spot which translations you still need to maintain.

Now we can add another entry to our package.json script section

"xliffmerge-translations": "xliffmerge --profile xliffmerge.json en de",

And specify the proper files in the end.

Putting everything together

Now we can add one last script entry to our package.json that just calls the other script entries we created so far.

"i18n": "npm run get-translation-keys && npm run combine-key-files && npm run xliffmerge-translations",
"get-translation-keys": "ng extract-i18n appOne && ng extract-i18n appTwo",
"combine-key-files": "node concat-key-files.js appOne appTwo",
"xliffmerge-translations": "xliffmerge --profile xliffmerge.json en de",

And now we can just run npm run i18n to extract all translation keys, combine them into a single key file and merge this keyfile with our existing translation files.
Already existing translations obviously wont be touched, and new ones will be added with a (MISSING): prefix.

Note: You can use the merge approach as well for a single application as well. Just adapt the respective srcDir and genDir properties in the configuration.

--

--