Angular localization in a multi application workspace setup
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.