Sekrab Garage

Twisting Angular localization

Alternative way to localize in Angular

AngularDesign July 11, 22
Subscribe to Sekrab Parts newsletter.

anchorAngular 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.

anchorThe 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.

anchorCustom 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.

anchorSetting 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);

anchorThe 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>

anchorPlural 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

anchorSelect

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.

anchorA 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.

anchorLocales 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.

anchorThe 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.

  1. two years ago
    Alternative way to localize in Angular
  2. one year ago
    Serving multilingual Angular application with ExpressJS
  3. one year ago
    Serving the same Angular build with different URLs
  4. one year ago
    Serving a different index.html in an Angular build for different languages
  5. one year ago
    Currency Angular pipe, UI language switch, and a verdict
  6. one year ago
    Pre-generating multiple index files using Angular Builders and Gulp tasks to serve a multilingual Angular app
  7. one year ago
    Using Angular APP_BASE_HREF token to serve multilingual apps, and hosting on Netlify
  8. one year ago
    Multilingual Angular App hosted on Firebase and Surge with the same build
  9. one year ago
    Alternative way to localized in Angular: Conclusion and final enhancements