GO TO FIRST PART
What is pluralization?
A popular problem in the i18n realm is pluralization — displaying different text depending on the value of a variable. In most libraries, we will encounter an implementation of the ICU format, and thus two forms of pluralization: select and plural.
Note: For @angular/localize and ngx-translate, the ICU format is implemented by default. We can add it to Transloco by installing the transloco-messageformat package.
Amount plural
We will use this format when we want to display text depending on the value of a number. In most languages we encounter two forms — singular and plural. It is worth remembering that some languages differ in this aspect. Ukrainian, for example, has four forms.
Nevertheless, in our applications, we will most often encounter a situation where we display text in three different cases:
- No data
- A single record
- Multiple records
Plural syntax:
"amountInStock": "… {amount, plural, =0 {Brak przedmiotów} one {Jeden przedmiot} other {Wiele przedmiotów (#)}}"
As we can see above, we open the plural expression with brackets. The first argument is the name of the parameter —”amount,” in our example. For the second argument, we pass the type of the plural: plural or select. The remaining arguments are conditional.
={amount}
is a condition, followed by the text in curly brackets, which will be returned if the condition is metother
is an else — he text inside it will be shown if no other condition matches- In each condition we can use the character
#
, which is replaced with a number passed as an argument.
Select plural
The select type format is useful if we want to display text depending on the value of the string.
"itemType": "… {itemCode, select, grapes {Fruit} carrot {Veggie} other {Unknown}}",
Just like previously, the syntax starts with curly brackets, then the name of the parameter, and the type of the plural — “select”. Other arguments are the conditions and their respective translations.
Pluralization in practice
Let’s add the transloco-messageformat package to our application. We can do this by executing the command
npm and @ngneat/transloco-messageformat
After installing the package, we need to overwrite the Transloco transpiler. We can do this by providing MessageFormatTranspiler in the AppModule.
@NgModule({
declarations: [...],
imports: [...],
providers: [
{provide: TRANSLOCO_TRANSPILER, useClass: MessageFormatTranspiler}
],
bootstrap: [...]
})
export class AppModule {}
After the initial setup, let’s create the sample data that we will display in our storage application. Let’s start by creating an interface in the types/storage-item.interface.ts file:
export interface StorageItem {
itemCode: string;
amount: number;
price: number
}
Next, let’s create some sample data in our component:
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
// …
storage: StorageItem[] = [
{ itemCode: 'grapes', amount: 0, price: 3.49 },
{ itemCode: 'carrot', amount: 1, price: 1.99 },
{ itemCode: 'cookie', amount: 55, price: 0.1 },
]
// …
}
We’re going to display it in the template using the ngFor directive. While creating the paragraphs we will pass our parameters In the second argument of the “t” function.
<ng-container *transloco="let t">
<select (change)="onLanguageChange($event)">
…
</select>
<h1>{{ t("foodStorage") }}</h1>
<ng-container *ngFor="let item of storage">
<hr>
<h2>{{t("item.code", {code: item.itemCode}) }}</h2>
<p>{{t("item.type", {code: item.itemCode}) }}</p>
<p>{{ t("item.amountInStock", {amount: item.amount}) }}</p>
<p>{{ t("item.price", {price: item.price | currency}) }}</p>
</ng-container>
</ng-container>
After this process, we should see a view similar to the one on the screenshot below.
Problems with scaling i18n applications
Scaling applications that use a runtime library can present several challenges. These include zombie keys, increased initial load time or missing translation keys.
Zombie keys
Keeping translation files in order is not an easy task. Zombie keys are translation keys that aren’t used anywhere in the repository. You may ask yourself, where did they come from? The answer is simple — someone forgot to remove them from the i18n files when they removed their use cases in the code. Having a few unused keys is not a big deal. Only with the expansion of this small “epidemic” will we increasingly feel the unnecessarily increased bundle size. Remember to monitor the use of translation keys on a regular basis. We can do this through editor add-ons that will help us find and remove zombie keys present in our application.
Lazy loading translations
As the number of keys in our application increases, we may encounter another big problem. The time it takes to load a json file increases in proportion to its size. This is especially problematic if we already have thousands of keys in a single file — the performance impact can be significant. In that case we can use the simple solution available in most libraries — split translations into modules and lazy-load translations along with the module.
Missing translation keys
While implementing large translations for multiple languages, we may sometimes forget to implement a key. Fortunately for us, fixing this problem is not very difficult. What’s more, we have several options:
- Use an editor extension that shows the missing keys. This solution will work well for small projects with a small number of translation files.
- Many external translation management services show statistics on the number of translated keys and which keys are not yet translated. This solution works especially great for medium and large projects, but can be implemented in smaller projects too. More information on that later in this article.
- We can also create a script to check if any file is missing a translation.
Create a script that finds missing keys
One way to find missing keys in a project is to create a simple script. The advantage of this solution is its flexibility. We can run the script at any time, and even combine it with a husk to block commits if the script finds a missing translation.
Missing-keys-finder.js
const fs = require('fs/promises');
const i18nFolderPath = 'src/assets/i18n';
const getTranslationKeys = (parsedTranslation, prefix = '') => {
let keys = [];
for (let key in parsedTranslation) {
const value = parsedTranslation[key];
if (typeof value === 'string') {
keys.push(prefix + key);
} else {
keys = [...keys, ...getTranslationKeys(value, prefix + `${key}.`)];
}
}
return keys;
};
const findMissingTranslations = async (filePath) => {
const i18nFiles = await fs.readdir(i18nFolderPath);
const parsedTranslations = {};
let translationKeys = new Set();
for (const fileName of i18nFiles) {
const fileContent = await fs.readFile(`${filePath}/${fileName}`, 'utf8');
const fileTranslationKeys = getTranslationKeys(JSON.parse(fileContent));
parsedTranslations[fileName] = fileTranslationKeys;
fileTranslationKeys.forEach((key) => translationKeys.add(key));
}
const missingKeys = {};
let missingTranslations = 0;
for (let key of [...translationKeys]) {
for (let language in parsedTranslations) {
if (!parsedTranslations[language].includes(key)) {
missingKeys[language] = [key, ...(missingKeys[language] ?? [])];
missingTranslations++;
}
}
}
for (let langFile in missingKeys) {
const missingTranslations = missingKeys[langFile];
if (missingTranslations && missingTranslations.length > 0) {
console.log(
`? File ${langFile} is missing following translations:\n[${missingTranslations
.map((key) => `"${key}"`)
.join(' ')}]`
);
}
}
if (missingTranslations) {
console.log(
`\n❌ You're missing a total of ${missingTranslations} translations`
);
process.exit(1);
}
console.log('✅ No missing translations were found. Well done!');
process.exit(0);
};
findMissingTranslations(i18nFolderPath);
The above script collects the translations from files located inside the folder declared in the i18nFolderPath variable. After loading all of the translations, it detects missing keys and then displays the search results in the console.
Now that we know how our script works, let’s add it to package.json so that it can be run using a npm run command.
{
"name": "translations-app",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
…
"find-missing-keys": "node missing-keys-finder.js"
},
"dependencies": {
…
}
}
To test the script, run the following command in the terminal
npm run find-missing-keys
You should see an output similar to the one in the screenshot below.
What else is worth remembering?
- We should translate every available text on our site, especially attributes such as placeholder, title, aria-label or alt.
- Add alternative versions for images and infographics that contain text. Include the path for these images in the translations file, and then reference them with our library in the src attribute of the img element.
- If you are using the Angular Material library, you should translate all the elements that the user interacts with. In addition to the obvious things such as text inside tooltips and snack bars, you shouldn’t forget about the mat-paginator. This component comes with default tooltips, which can be translated using the MatPaginatorIntl provider.
- Set the page title based on the user’s language. We can do this by creating a custom class extending the TitleStrategy class. In it, we override the updateTitle method to set the title depending on the page title declared in the route. Take a look at this example implementation to learn more.
Internationalization of displayed data formats
Is that all we should know about i18n? Not really — internationalization of an application is more than just translating text into different languages. The process is much more complicated and consists of many steps. One of the things that goes into internationalization is adjusting the format of the displayed data to regional standards.
Angular provides various pipes that allow us to display internationalized data with ease. We have the following tools at our disposal:
- DatePipe – formats the displayed dates
- CurrencyPipe – formats the displayed currencies
- DecimalPipe – formats the displayed numbers
- PercentPipe – formats the displayed percentages
Why is LOCALE_ID so important?
LOCALE_ID is an injection token with which we can set the global language code in our application. The aforementioned pipes use this token to format displayed data.
In the above screenshot, we can see how much formatting can vary depending on the locale, especially for currencies and dates. Thankfully, all we need to do is change the token value while the application is running. It can’t be that difficult, right?
Changing LOCALE_ID while the application is running
The problems with changing LOCALE_ID start at the very beginning. This token stores a string value, and thus we cannot edit it after app initialization as we would with reference type values — for example, objects.
This problem does not occur in Angular’s native library, as it uses different builds for each supported language. This is worth taking into account if you plan to choose a runtime library.
Despite this, it is possible to create a dynamic LOCALE_ID.
Editing keys inside the IDE and external services
Editing keys through the GUI
Working on translation files can be problematic — it frequently requires us to scroll through extremely large files to add new translations or edit existing ones. To make life easier for ourselves, we can use IDE extensions that allow us to edit translations from the GUI.
These extensions greatly improve the DX, especially when they uses a columns-based UI to display translations. I guarantee you that when you start using a UI editor, the time spent on unnecessary jumping between files will be drastically reduced. You will also be able to view, add and edit new keys with ease.
VS Code add-ons
- i18n Ally — a very rich add-on containing many i18n features. It allows us to extract, edit or preview translations. In my opinion, it is the best solution available for VS Code.
- i18n json editor — an extension that allows us to edit translations in a simple UI editor.
Add-ons for JetBrains IDE
- Easy i18n — this add-on allows us to edit translation keys in a table or tree view. It also does not lack such features as highlighting missing keys or editing translations directly from the template.
External key management services
Many external translation management services are available to developers. Personally, I think that this technology is a game changer. This is especially true for huge projects that involve dozens of people — including translators. It is in these types of projects that developers are often forced to mindlessly copy translations received from translators into json files. In the long run, this results in wasted time and opens the application to micro typing errors.
An external service can take our work to the next new level. Below you will find some of the benefits that translation management services offer us.:
- Edit history for individual translations
- Application release management
- Integration with CI/CD
- Automatic translation of files using DeepL, Google Translate or Amazon Translate
- Various types of warnings, such as missing translations
- Grouping translations, adding tags, comments and setting flags to keys. This allows you to expand the context for translators without impacting the sizes of translation files.
Possibility of self-hosting
Examples of external translations management services include:
Summary
As you surely noticed, despite appearing simple, application internationalization has its secrets. Before introducing i18n into your application, you should think about what kind of library you need and what advantages and limitations it provides you with.
What experiences have you had with application internationalization? Which libraries and services have you used?