The current i18n packages provided by Angular for that purpose serve the purpose well. In this series of articles I want to create an alternative solution for smaller scale apps.
Angular out of the box i18n
The current solution of Angular for localization suffices for most of the requirements. The main feature is that the language content goes out in the build, so if you have multiple languages, you will end up with multiple builds. On the positive side:
- The build time has been reduced in Angular 9 with post compilation.
- Localized pipes like Date and Decimal are great to work with and remove the pain of dealing with many locales.
- It's out of the box and well documented.
- Separation of translation files means you can hire a third party to translate using their preferred tools.
- The default text is included directly in the development version, so no need to fish around during development to know what this or that key is supposed to say.
The problems with it
- First and most obvious, it generates multiple builds. Even though it is necessary to serve pre-compiled language, it still is a bit too much for smaller scale multilingual apps.
- It's complicated! I still cannot get my head around it.
- Extracting the strings to be localized is a one-direction process. Extracting again will generate a new file and you have to dig in to manually merge left outs.
- It is best used in non-content based apps, where the majority of content comes from a data source---already translated---via an API. Which makes the value of pre-compiling a little less than what it seems.
- Did I mention it was complicated? Did you see the xlf file?
- To gain control, you still need to build on top of it a service that unifies certain repeated patterns.
Custom solution
Our custom solution is going to be fed by JavaScript (whether on browser or server platform), and there will be one build. The following is our plan:
- Create a seperate JavaScript for each language, fed externally, and not part of the build.
- Create a pipe for translating templates.
- Figure out a couple of different challenges, specifically plural forms.
- The fallback text is the one included in the development version, just like Angular package.
- The resources need to be extracted into our JavaScript, for translation, so we need to use a task runner for that purpose.
- Angular package reloads app per language, and that is the right thing to do, so our solution will reload upon change of language.
- Since it is one build, it is one index.html, so we need to figure out a way to generate an index file per language, post build.
- We will serve from NodeJs, so we will write our own separate expressJS server.
We probably also want to customize our own locales, but for now Angular can handle those on runtime with LOCALE_ID
token.
So let's get started.
Setting up
We begin with a simple page that has content, with an eye on making it translatable. We will create a translate pipe, the template should finally look like this
<h4>{{'Hello World' | translate:'HelloWorld'}}</h4>
The translate pipe:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'translate' })
export class TranslatePipe implements PipeTransform {
transform(original: string, res: string): string {
// TODO: get the string from the resources if found
// return GetResourceByKey(res, original);
return original;
}
}
We just need to get the string, using a key
, if that key does not exist, simply return original.
The resources is a static function that maps the key
to some key-value resource file, we'll place that in a res.ts
file.
// find the keys somewhere, figure out where to place them later
import { keys } from '../../locale/language';
// a simple class that translates resources into actual messages
export class Res {
public static Get(key: string, fallback?: string): string {
// get message from key
if (keys[key]) {
return keys[key];
}
// if not found, fallback, if not provided return NoRes
return fallback || keys.NoRes;
}
}
In the pipe
we return this instead:
return Res.Get(res, original);
The language file
Initially, the language file is simple, and we shall for now let it sit somewhere inside the app. Later we are going to place everything in a JavaScript file outside the build.
// in /locales/language.ts
export const keys = {
// an empty one just in case
NoRes: '',
// here we can add all other text that needs translation
HelloWorld: 'Aloha',
};
This can also be used in attributes:
<tag [attr.data-value]="'something' | translate:'Something'"></tag>
Plural forms
An example of a plural form is displaying the total of search results. For example, students. Let us check out the general rules defined by Angular i18n so that we can recreate them.
We have two choices, the first is to use the same plural function definitions in Angular Locales packages. For now let's copy it over and use it. The limitation of this is that it can only be a JavaScript file, not a JSON. That is not a big deal because it most probably will have to be JavaScript. We will cover the second choice later.
The language file now holds the definition of plural:
// locales/language.ts
export const keys = {
// ...
// plural students for English
Students: { 1: 'one student', 5: '$0 students' },
};
// plural rule for english
export const plural = (n: number): number => {
let i = Math.floor(Math.abs(n)),
v = n.toString().replace(/^[^.]*\.?/, '').length;
if (i === 1 && v === 0) return 1;
return 5;
};
// later we can redefine the plural function per language
The res class
is rewritten to replace $0
with the count, or fall back:
// core/res.ts
export class Res {
// ...
public static Plural(key: string, count: number, fallback?: string): string {
// get the factor: 0: zero, 1: one, 2: two, 3: few, 4: many, 5: other
// import plural from locale/resources
let factor = plural(count);
// if key does not exist, return fall back
if (keys[key] && keys[key][factor]) {
// replace any $0 with the number
return keys[key][factor].replace('$0', count);
}
return fallback || keys.NoRes;
}
}
The translation pipe passes through the count:
@Pipe({ name: 'translate', standalone: true })
export class TranslatePipe implements PipeTransform {
transform(original: string, res: string, count: number = null): string {
// if count is passed, pluralize
if (count !== null) {
return Res.Plural(res, count, original);
}
return Res.Get(res, original);
}
}
And this is how we would use it:
<section>
<h4 class="spaced">Translate plurals in multiple languages:</h4>
<ul class="rowlist">
<li>{{ 'Total students' | translate: 'Students':0 }}</li>
<li>{{ 'Total students' | translate: 'Students':1 }}</li>
<li>{{ 'Total students' | translate: 'Students':2 }}</li>
<li>{{ 'Total students' | translate: 'Students':3 }}</li>
<li>{{ 'Total students' | translate: 'Students':11 }}</li>
</ul>
</section>
I personally like to display zero as no for better readability, so in StackBlitz I edited the function in locale/language
Select
Looking at the behavior in i18n package select
, there is nothing special about it. For the gender example:
<span>The author is {gender, select, male {male} female {female}}</span>
That can easily be reproduced with having the keys in the language file, and simply pass it to the pipe:
<span>The author is {{gender | translate:gender}}</span>
But let's take it up a notch, and have a way to place similar keys in a group. For example rating values: 1 to 5. One being Aweful
. Five being Great
. These values are rarely localized in storage, and they usually are translated into enums
in an Angular App (similar to gender). The final result of the language file I want to have is this:
// locale/language
export const keys = {
// ...
// the key app-generated enum, never map from storage directly
RATING: {
someEnumOrString: 'some value',
// ...
}
};
// ...
In our component, the final template would look something like this
{{ rate | translate:'RATING':null:rate}}
The translate pipe should now be like this:
@Pipe({ name: 'translate', standalone: true })
export class TranslatePipe implements PipeTransform {
transform(
original: string,
res: string,
count: number = null,
// new argument
select: string = null
): string {
if (count !== null) {
return Res.Plural(res, count, original);
}
if (select !== null) {
// map to a group
return Res.Select(res, select, original);
}
return Res.Get(res, original);
}
}
And our res class
simply maps the key to the value
export class Res {
public static Select(key: string, select: any, fallback?: string): string {
// find the match in resources or fallback
return (keys[key] && keys[key][select]) || fallback || keys.NoRes;
}
}
We just need to ensure that we pass the right key, that can be a string
, or an enum
. Here are few examples
// somewhere in a model
// left side is internal, right side maps to storage
enum EnumRate {
AWEFUL = 1,
POOR = 2,
OK = 4,
FAIR = 8,
GREAT = 16,
}
// somewhere in our component
@Component({
template: `
<ul>
<li *ngFor="let item of arrayOfThings">
{{ item.key | translate: 'THINGS':null:item.key }}
</li>
</ul>
<ul>
<li *ngFor="let rate of rates">
{{
enumRate[rate] | translate: 'RATING':null:enumRate[rate]
}}
</li>
</ul>
A product is
{{ productRating.toString() |
translate: 'RATING':null:enumRate[productRating]
}}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OurComponent {
// example of simple string keys directly translated into resources
// what comes from stroage is ids only, and we map internally to strings
arrayOfThings = [
{
id: 1,
key: 'elephant',
},
{
id: 2,
key: 'lion',
},
{
id: 3,
key: 'tiger',
},
{
id: 4,
key: 'bear',
},
];
// example of using internal enums
enumRate = EnumRate;
rates = [
EnumRate.AWEFUL,
EnumRate.POOR,
EnumRate.OK,
EnumRate.FAIR,
EnumRate.GREAT,
];
// example of a single value
productRating = EnumRate.GREAT;
}
Our language file now looks like this:
// locale/language
export const keys = {
// ...
// example of enums
RATING: {
AWEFUL: 'aweful',
POOR: 'poor',
OK: 'okay',
FAIR: 'fair',
GREAT: 'great'
},
// example of string keys
THINGS: {
elephant: 'Elephant',
bear: 'Bear',
lion: 'Lion',
tiger: 'Tiger',
}
};
// ...
It's true I'm using a broad example of elephants and lions, this is not supposed to be data coming from storage, what comes is the ids, our app model maps them to strings
, usually enums
, but I just wanted to test with simple strings. Because our final language file cannot have direct strings coming from storage, it would be a nightmare to maintain.
A pitfall of the plural function
One easy addition to our app is relative times, we want to first find the right relative time, then translate it. I will use this example to demonstrate that the current Angular package falls short of a tiny friendly enhancement. Let's create a new pipe for relative time:
import { Pipe, PipeTransform } from '@angular/core';
import { Res } from '../core/res';
@Pipe({ name: 'relativetime' })
export class RelativeTimePipe implements PipeTransform {
transform(date: Date): string {
// find elapsed
const current = new Date().valueOf();
const input = date.valueOf();
const msPerMinute = 60 * 1000;
const msPerHour = msPerMinute * 60;
const msPerDay = msPerHour * 24;
const msPerMonth = msPerDay * 30;
const msPerYear = msPerDay * 365;
const elapsed = current - input;
const fallBack = date.toString();
let relTime = Res.Plural('YEARS', Math.round(elapsed / msPerYear), fallBack);
if (elapsed < msPerMinute) {
relTime = Res.Plural('SECONDS', Math.round(elapsed / 1000), fallBack);
} else if (elapsed < msPerHour) {
relTime = Res.Plural('MINUTES', Math.round(elapsed / msPerMinute), fallBack);
} else if (elapsed < msPerDay) {
relTime = Res.Plural('HOURS', Math.round(elapsed / msPerHour), fallBack);
} else if (elapsed < msPerMonth) {
relTime = Res.Plural('DAYS', Math.round(elapsed / msPerDay), fallBack);
} else if (elapsed < msPerYear) {
relTime = Res.Plural('MONTHS', Math.round(elapsed / msPerMonth), fallBack);
}
return relTime;
}
}
In our language file:
// add these to locale/language
export const keys = {
// ...
// 1 and 5 for English
SECONDS: { 1: 'one second', 5: '$0 seconds' },
MINUTES: { 1: 'one minute', 5: '$0 minutes' },
HOURS: { 1: 'one hour', 5: '$0 hours' },
DAYS: { 1: 'one day', 5: '$0 days' },
MONTHS: { 1: 'one month', 5: '$0 months' },
YEARS: { 1: 'one year', 5: '$0 years' },
// ...
}
Using it in a template goes like this:
{{ timeValue | relativetime }}
This produces: 2 seconds, 5 minutes, 3 hours ... etc. Let's spice it up a bit, is it ago? or in the future?
Do not rely on negative lapses to decide the tense. A minus number is a bug as it is, do not get along with it and change the tense based on it.
First, the language file:
// add to locale/language
export const keys = {
// ...
TIMEAGO: '$0 ago',
INTIME: 'in $0',
};
Then the pipe
:
// adapt the pipe for the future
@Pipe({ name: 'relativetime' })
export class RelativeTimePipe implements PipeTransform {
transform(date: Date, future: boolean = false): string {
// ...
// change this to take absolute difference
const elapsed = Math.abs(input - current);
// ...
// replace the $0 with the relative time
return (future ? Res.Get('INTIME') : Res.Get('TIMEAGO')).replace('$0', relTime);
}
}
Here is my problem with the current plural function; there is no way to display few seconds ago. I want to rewrite the plural behavior, to allow me to do that. I want to let my language file decide regions, instead of exact steps, then comparing an incoming count to those regions, it would decide which key to use. Like this:
SECONDS: { 1: 'one second', 2: 'few seconds', 10: '$0 seconds' }
The keys represent actual values, rather than enums
. The Plural function now looks like this:
// replace the Plural function in res class
public static Plural(key: string, count: number, fallback?: string): string {
const _key = keys[key];
if (!_key) {
return fallback || keys.NoRes;
}
// sort keys desc
const _pluralCats = Object.keys(_key).sort(
(a, b) => parseFloat(b) - parseFloat(a)
);
// for every key, check if count is larger or equal, if so, break
// default is first element (the largest)
let factor = _key[_pluralCats[0]];
for (let i = 0; i < _pluralCats.length; i++) {
if (count >= parseFloat(_pluralCats[i])) {
factor = _key[_pluralCats[i]];
break;
}
}
// replace and return;
return factor.replace('$0', count);
}
The language file now has the following keys
// change locales/language so that numbers are edge of ranges
export const keys = {
Students: { 0: 'no students', 1: 'one student', 2: '$0 students' },
SECONDS: { 1: 'one second', 2: 'few seconds', 10: '$0 seconds' },
MINUTES: { 1: 'one minute', 2: 'few minutes', 9: '$0 minutes' },
HOURS: { 1: 'one hour', 2: 'few hours', 9: '$0 hours' },
DAYS: { 1: 'one day', 2: 'few days', 9: '$0 days' },
MONTHS: { 1: 'one month', 2: 'few months', 4: '$0 months' },
// notice this one, i can actually treat differently
YEARS: { 1: 'one year', 2: '$0 years', 5: 'many years' },
// ...
}
We can drop the plural function in our language file, we no longer rely on it.
This is much more relaxed and flexible, and it produces results like these:
- one second ago
- few days ago
- 3 years ago
- many years ago
- in few hours
It also takes care of differences in languages. When we move the language file to its proper location next week, we'll use that feature to create different rules for different languages.
Locales packages
The last thing we need to place before we push the locales out of the project is Angular locales packages that allow default pipes to work properly. Those are the date
, currency
, decimal
and percentage
pipes.
{{ 0.55 | currency:UserConfig.Currency }}
{{ today | date:'short' }}
To do that, we need to provide the LOCALE_ID
token with the right locale. The right locale will be sourced from our language file, which will later become our external JavaScript.
// in locale/language
// bring in the javascript of the desired locale
import '@angular/common/locales/global/ar-JO';
// export the LOCALE_ID
export const EXTERNAL_LOCALE_ID = 'ar-JO';
In the root app.module
, we use useValue
, for now, but this will prove wrong when we move to SSR. We'll fix it later.
// in app module (or root module for the part we want to localize)
@NgModule({
// ...
providers: [{ provide: LOCALE_ID, useValue: EXTERNAL_LOCALE_ID }],
})
export class AppModule {}
In StackBlitz I set up a few examples to see the edges of how date and currency pipes function under ar-JO
locale. Notice that if the locale imported does not have a specific definition for the currency, the currency will fall back to the code provided. So for example, under ar-JO
, a currency with TRY
, will display as:\
TRY 23.00
.\
If the tr
locale was provided, it would display the right TRY currency symbol: ₺
. Let's keep that in mind, for future enhancements.
The language files
So far so good. Now we need to move all locale references, and make them globally fed by an external JavaScript file, and build and prepare the server to feed different languages according to either the URL given, or a cookie. That will be our next episode. 😴
Thank you for sticking around, please let me know if you saw a worm, or spotted a bug.
RESOURCES
- StackBlitz project
- Angular i18n
- Angular Locales packages
- Angular post compilation localization migration
Top comments (0)