Alternative way to localize in Angular

ayyash

Ayyash

Posted on July 11, 2022

Alternative way to localize in Angular

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
      // ...
  }
};
// ...
Enter fullscreen mode Exit fullscreen mode

In our component, the final template would look something like this

{{ rate | translate:'RATING':null:rate}}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
  }
};
// ...
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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' },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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',
};
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
  }
Enter fullscreen mode Exit fullscreen mode

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' },

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 datecurrencydecimal 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';
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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

RELATED POSTS

Loading external configurations in Angular Universal

Alternative way to localize in Angular - Sekrab Garage

Twisting Angular localization . Angular out of the box i18nThe 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 hav.... Posted in Angular, Design

favicon garage.sekrab.com
💖 💪 🙅 🚩
ayyash
Ayyash

Posted on July 11, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related