Sekrab Garage

Twisting Angular localization

Alternative way to localized in Angular: Conclusion and final enhancements

Angular August 29, 22

In the last 8 episodes of this long article to twist and replace the i18n package of Angular, we accomplished the following:

  • We recreated the functionality of translation via a pipe, and added externally fed language scripts, to be able to use a single build for all languages, we figured out a better way to do Plural functionality, and customized the Angular provided locales to a certain safe limit (to be specific, we added the Woolong currency)
  • We created an ExpressJS server to serve the single build with different languages, driven by URL: /en/route, or driven by a cookie saved via browser /route
  • We added a few nuts and bolts to make it run in SSR
  • We dived into creating different index.html on runtime using Express Template Engines, and on build time using Angular builders, or Gulp tasks
  • We added UI elements to switch language whether by URL, or saving a cookie, we extracted the movable configurable parts into its own config file
  • We tested on cloud hosts with a bit more restricted environment than Express, mainly Netlify, Firebase and Surge

What is nice about this solution is:

  • One build, serve all, whether by URL or cookie, SSR, or client, Express or cloud hosting
  • The language scripts are external files, they can be managed separately
  • We still made use of the out-of-the-box libraries for locales
  • I might be biased here but I don’t think it’s as complicated as the classical solution, what do you think?

Extract task

The last bit to this mission is to extract translation keys into the cr-lang scripts. This is done with Angular Builder or Gulp. Since we are running the task locally before build, the Gulp packages do not have to be committed into our project git. This is better when dealing with remote pipelines where the host installs the npm packages; because Gulp packages are not well maintained.

The task should do the following:

  • Scan .html and .ts files inside a source folder (where components live)
  • Find patterns of "something" | translate:"code"
  • Create a key: "code": "something" ready to be placed in the language scripts
  • Ignore already existing keys: this is a step ahead Angular’s i18n extract task, which regenerates the whole xlf file leaving it to us to merge with already translated text.
  • Keep it simple, do not create Count and Select keys, most often than not, we have already created them during development
  • If language file does not exist, copy from the default language first, the default language script is chosen to be other than the default en, which has the embedded script code

First, let’s create the right replacement comment tags in the right place in our scripts, let’s begin with our default language: /locale/en.js, also let’s move any references to the locales and languages into their own const

// ...
// locales/en.js or ar.js
// let's move language references to a key at the top 
const _LocaleId = 'ar-JO';
const _Language = 'ar';

// ...
const keys { 
	NorRes: '',
	SomethingDone: '', // always have a comma at the end
	// place those two lines for Gulp and Angular Builder, at the end of the keys
	// inject:translations
	// endinject
}

The Angular Builder, we’ll create a new task: /extract/index.ts, and install glob to help us collect the target files:

// we will use glob from npmjs/glob to find our files easier
import glob from 'glob';

// languages, have name "ar" and localeId: "ar-JO", and isDefault to use script for new languages
interface ILanguage { name: string, localeId: string, isDefault?: boolean; }

interface IOptions {
  // the source location to look for components
  scan: string;
  // the locales folder for scripts
  destination: string;
  // supported languages
  languages: ILanguage[];
  // optional, if not provided, taken from other targets, for prefix-language file name
  prefix: string;
}

// very generic regex: "words" | translate:"code"
const _translateReg = /\s*["']([\w\d?.,!\s\(\)]+)["']\s*\|\s*translate:['"]([\w]+)['"]\s*/gim;

// I could have more distinctive patterns for select and plural, but I don't wish to

export default createBuilder(ExtractKeys);

// read script content, if not existent, copy isDefault language file
const getScriptContent = (options: IOptions, prefix: string, lang: ILanguage): string => {

  // read file /destination/prefix-lang.js
  const fileName = `${options.destination}/${prefix}-${lang.name}.js`;

  let content = '';

  // if does not exist, create it, copy the default language content
  if (!existsSync(fileName)) {
    const defaultLanguage = options.languages.find(x => x.isDefault);
    const defaultFileName = `${options.destination}/${prefix}-${defaultLanguage.name}.js`;
    const defaultContent = readFileSync(defaultFileName, 'utf8');

    // replace language keys
		// example replace 'ar-JO' with 'fr-CA; This is why it is important to separate those
		// keys in the language script
    content = defaultContent
      .replace(`'${defaultLanguage.localeId}'`, `'${lang.localeId}'`)
      .replace(`'${defaultLanguage.name}'`, `'${lang.name}'`);

    writeFileSync(fileName, content);
  } else {
    content = readFileSync(fileName, 'utf8');
  }

  return content;

};
// extract translation terms from all ts and html files under certain folder
const extractFunction = (options: IOptions, prefix: string, lang: ILanguage) => {
  // per language
  const fileName = `${options.destination}/${prefix}-${lang.name}.js`;

	// read content
  const script = getScriptContent(options, prefix, lang);

  // get all ts and html files
  const files = glob.sync(options.scan + '/**/*.@(ts|html)');
  
	// read files, for each, extract translation regex, add key if it does not exist
  let _keys: string = '';
  files.forEach(file => {
    const content = readFileSync(file, 'utf8');
    let _match;
    while ((_match = _translateReg.exec(content))) {
      // extract first and second match
      const key = _match[2];
      // if already found skip, also check destination script if it has the key
      if (_keys.indexOf(key + ':') < 0 && script.indexOf(key + ':') < 0) {
        _keys += `${key}: '${_match[1]}',\n`;
      }
    }
  });
  // write and save, keep the comment for future extraction
	_keys += '// inject:translations';
  writeFileSync(fileName, script.replace('// inject:translations', _keys));
};
async function ExtractKeys(
  options: IOptions,
  context: BuilderContext,
): Promise<BuilderOutput> {

	// read prefix from angular.json metadata
  const { prefix } = await context.getProjectMetadata(context.target.project);

  try {
    options.languages.forEach(lang => {
      extractFunction(options, options.prefix || prefix.toString(), lang);
    });
  } catch (err) {
    context.logger.error('Failed to extract.');
    return {
      success: false,
      error: err.message,
    };
  }
  context.reportStatus('Done.');

  return { success: true };
}

Add the new task and the schemas to the builders.json file

{
  "builders": {
    // ... add new extract builder
    "extract": {
      "implementation": "./dist/extract/index.js",
      "schema": "./extract/schema.json",
      "description": "Extract translation terms"
    }
  }
}

In angular.json create a new target for the extract task

// in angular.json add the prefix in project metadata, or pass prefix to extract options
"prefix": "cr",
"architect": {
  // new task for extractions, see builder/extract
  "extract": {
    "builder": "./builder:extract",
    "options": {
      "destination": "./src/locale",
      "scan": "./src/app/components",
      // if different that meta data, you can pass prefix override here
      "prefix": "cr",
      "languages": [
        {
          "name": "en",
          "localeId": "en"
        },
        {
          "name": "ar",
          "localeId": "ar-JO",
          // copy from default file that has the injected script
          "isDefault": true
        },
        {
          "name": "fr",
          "localeId": "fr-CA"
        }
      ]
    }
  },
// ...

Build, then run ng run cr:extract. This generates the right leftover keys, and creates missing files if needed. Find the builder code under StackBlitz /builder/extract folder.

In Gulp, we create the missing files with simple sequence:

  • gulp.src
  • gulp.transform
  • gulp.rename
  • gulp.dest

Then I used gulp-inject library to inject the keys, which is quite out of date, but it’s otherwise awesome. Then simply gulp.series to put them together. Find the final code in StackBlitz gulp/extract folder.

Task enhancements

I can do this forever, going back to fix or enhance a few lines. But I will not scratch that itch.

Nevertheless, find under StackBlitz builder/locales/index.enhanced.ts and under gulp/gulpfile.js a couple of enhancements:

  • Combined index file generators under one configuration, that generates only one scenario instead of both (URL or cookie driven, index.[lang].html, or [lang]/index.html)
  • used getProjectMetadata in Angular builder to get the project prefix, not to repeat ourselves
  • I also separated the options in Gulp, into the gulpfile.js for better control

A detail

One of the details I avoided was relying on full names fr-CA instead of two keys: fr for language and fr-CA for locale. I deliberately separated them because in my mind, French is French for all those who speak it, choosing the right locale is a business decision that we should not bother our users with. The Application should know whether the user is from Nigeria, or from Canada. The difference in outcome is however not a big difference. The index files would be named index.fr-CA.html, all of our redirects would have fr-CA instead of plain fr, and our schemas would reflect that. The display language however, must be specific, in configuration file it would be something like this:

languages: [
	{name: 'en', display: 'English'}, 
  {name: 'fr-CA', display: 'Canadian French'},
  {name: 'fr-NG', display: 'Nigerian French'},
]

But I pass, since for Arabic, it is quite annoying to ask the user to choose the version of Arabic to display with.

I am sure you’d find other enhancements throughout the project, I myself could not help going back to older episodes for more enhancements. Can you think of any? Share them with me please.

Thank you for reading this far, have you found any part of it too complicated? Was it worth the effort? Did you find out what Woolongs were? 🙂

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