Conclusion and final enhancements
Tags: angular, webdev, tutorial, javascript
Published: 2022-08-29 04:45:01

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`

```js
// ...
// 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:

```ts
// 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

```json
{
  "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

```json
// 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](https://stackblitz.com/edit/angular-runtime-translation?file=gulp%2Fextract%2Findex.js).

### 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:

```js
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? 🙂

### RESOURCES

-   [Gulp](https://gulpjs.com/)
-   [gulp-inject](https://www.npmjs.com/package/gulp-inject)
-   [StackBlitz project](https://stackblitz.com/edit/angular-runtime-translation)
-   [Regex for translation keys](https://regexr.com/6rve7)