Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
July 18, 2022 04:25 am GMT

Serving multilingual Angular application with ExpressJS

Previously we derived the locales file that contains all the language resources in preparation to isolate them. Our focus today is serving throughNodeJs and ExpressJSserver. We will serve different languages using cookies, and later relying on the URL. But before we dig in, one last benefit of our resources class.

Accessing resources from anywhere

Out of the box, Angular provides$localizeadapter, but it is limited to i18n uses. Ourres classcan be used even if no locale is targeted, andlanguage.tsis used directly. We have already made use of it inError catching and toast messages. Here is a snippet of how it can be freely used:

// using the res class for any linguistic content// extreme case of a warning when an upload file is too largeconst size = Config.Upload.MaximumSize;this.toast.ShowWarning(  // empty code to fallback  '',  // fallback to a dynamically created message  { text: Res.Get('FILE_LARGE').replace('$0', size)});// where FILE_LARGE in locale/language is:// FILE_LARGE: 'The size of the file is larger than the specified limit ($0 KB)'

Note: The source files are in StackBlitz, but they don't necessarily work in StackBlitz, because the environment is too strict.

Language JavaScript file

We covered in a previous article the basics of how to inject anexternal configuration file into Angularand came to the conclusion that the best way is toplace the javascript file in the index header. At this stage, we have no clear model that we need to cast to, so let's start with a simple script tag inindex.html:

<script src="locale/language.js" defer></script>

For that to work in development, we'll add an asset toangular.json

// angular.json options/assets{    "glob": "*.js",    "input": "src/locale",    "output": "/locale"}

To make use of the JavaScript keys collection, wedeclarein ourtypescript. Theres classis the only place that uses the keys, andapp.moduleis the only place that uses the locale id. So let'splace everything inres class:

// in res class, we declare the keys and locale_iddeclare const keys: any;declare const EXTERNAL_LOCALE_ID: string;export class Res {  // export the locale id somehow, a property shall do  public static LOCALE_ID = EXTERNAL_LOCALE_ID;  // using them directly: keys  public static Get(key: string, fallback?: string): string {    if (keys[key]) {        return keys[key];    }    return fallback || keys.NoRes;  }// ...}// in app.module, we import the locale id// ...providers: [{provide: LOCALE_ID, useValue: Res.LOCALE_ID }]

Angular Locale Package

But how do we import the locale from Angular packages? The easiest, most straightforward way is to do exactly the same as above. Add a script, and reference inangular.json. Assuming we want to have multiple locales available, then we include them all inassets:

{  // initially, add them all  "glob": "*.js",  "input": "node_modules/@angular/common/locales/global",  "output": "/locale"}

This means that the locales' files are copied to the host when we build, which is ideal, because this way we know we always have the latest version of the locale. One way is this:

<script src="locale/ar-JO.js" defer></script>

The other is tolet the language file create the tag. Remember though, this file will eventually be called on server platform, so we want to be at least ready for that.

// in browser platformconst script = document.createElement('script');script.type = 'text/javascript';script.defer = true;script.src = 'locale/ar-JO.js';document.head.appendChild(script);// in server platform, we'll add this later// require('./ar-JO.js');

Let's do onerefactorbefore we jump into serving the files. Create a single JavaScript key, and namespace it, so that the 10xers don't troll us, not that it matters.

// the locales/language.js fileconst keys = {  NoRes: '',  // ...};// combine and namespace// window will later be globalwindow.cr = window.cr || {};window.cr.resources = {  language: 'en',  keys,  localeId: 'en-US'};

cris short forcricket. Our project code name.

In ourres class:

// in res class remove imported keys from /locales/language.tsdeclare const cr: {  resources: {    keys: any;    language: string;    localeId: string;  };};export class Res {  // to use in app.module  public static get LocaleId(): string {    return cr?.resources.localeId;  }  // add a private getter for keys  private static get keys(): any {    return cr?.resources.keys;  }  // use it like this this  public static Get(key: string, fallback?: string): string {    const keys = Res.keys;    // ...  }  // ...}

Language specific files

We shall now create two files in locale folder ready to be shipped:cr-en, andcr-ar. Thecr-arcontains the addedar-JOlocale script, while thecr-enhas nothing special.We prefix not to clash with Angular packages, since ar.js and en.js already exist.

(the en-AE mentioned below is for example only, we are not going to use it.)

We are building now with the followingangular.jsonsettings:

"projects": {    "cr": {      "architect": {        "build": {          "options": {            "resourcesOutputPath": "assets/",            "index": "src/index.html",            "assets": [              // ...              // add all locales in dev              {                "glob": "*.js",                "input": "src/locale",                "output": "/locale"              },              {                // add angular packages in dev, be selective                // en-AE is an example                "glob": "*(ar-JO|en-AE).js",                "input": "node_modules/@angular/common/locales/global",                "output": "/locale"              }            ]          },          "configurations": {            "production": {              // place in client folder              "outputPath": "./host/client/",              // ...              // overwrite assets              "assets": [                // add only locales needed                // names clash with Angular packages, prefix them                {                  "glob": "*(cr-en|cr-ar).js",                  "input": "src/locale",                  "output": "/locale"                },                {                  // add angular packages needed                  "glob": "*(ar-JO|en-AE).js",                  "input": "node_modules/@angular/common/locales/global",                  "output": "/locale"                }              ]            }          }        },        // server build        "server": {          "options": {            // place in host server            "outputPath": "./host/server",            "main": "server.ts"            // ...          },          // ...        }      }    }

Let's build.

Browser only application

Starting with the Angular builder:

ng build --configuration=production

This generates the output filehost/client. Inside that folder, we havelocalefolder that contains all javascript files we included in assets:

  • /host/client/locale/cr-en.js
  • /host/client/locale/cr-ar.js
  • /host/client/locale/ar-JO.js

The index file contains a reference forlocale/language.js, now it's our job torewrite that URL to the right language file. Creating multiple index files is by far the most extreme, and the best solution. But today, we'll just rewrite usingExpressJS routing.

In our mainserver.js,we need to create amiddleware to detect language, for now, from a cookie. The cookie name can easily be lost around, so first, I want tocreate a config filewhere I will place all my movable parts, this is a personal preference, backend developers probably have a different solution.

// server/config.jsconst path = require('path');const rootPath = path.normalize(__dirname + '/../');module.exports = {  env: process.env.Node_ENV || 'local',  rootPath,  // we'll use this for cookie name  langCookieName: 'cr-lang',  // and this for prefix of the language file  projectPrefix: 'cr-'};

The language middleware:

// a middleware to detect languagemodule.exports = function (config) {  return function (req, res, next) {    // check cookies for language, for html request only    res.locals.lang = req.cookies[config.langCookieName] || 'en';    // exclude non html sources, for now exclude all resources with extension    if (req.path.indexOf('.') > 1) {      next();      return;    }    // set cookie for a year    res.cookie(config.langCookieName, res.locals.lang, {      expires: new Date(Date.now() + 31622444360),    });    next();  };};

This middleware simply detects thelanguagecookie, sets it toresponse locals property, and then saves the language in cookies.

The basic server:

const express = require('express');// get the configconst config = require('./server/config');// express appconst app = express();// setup expressrequire('./server/express')(app);// language middlewarevar language = require('./server/language');app.use(language(config));// routesrequire('./server/routes')(app, config);const port = process.env.PORT || 1212;// listenapp.listen(port, function (err) {  if (err) {    return;  }});

The routes for our application:

// build routes for browser only solutionconst express = require('express');// multilingual, non url driven, client side onlymodule.exports = function (app, config) {  // reroute according to lang, don't forget the prefix cr-  app.get('/locale/language.js', function (req, res) {    res.sendFile(config.rootPath +        `client/locale/${config.projectPrefix}${res.locals.lang}.js`    );    // let's move the path to config, this becomes    // res.sendFile(config.getLangPath(res.locals.lang));  });  // open up client folder, including index.html  app.use(express.static(config.rootPath + '/client'));  // serve index file for all other urls  app.get('/*', (req, res) => {    res.sendFile(config.rootPath + `client/index.html`);  });};

Running the server, I can see the cookie saved in Chrome Dev tools, changing it, reloading, it works as expected.

Let's move the language path to serverconfigbecause I will reuse it later.

module.exports = {  // ...  getLangPath: function (lang) {    return `${rootPath}client/locale/${this.projectPrefix}${lang}.js`;  }};

Server platform

Going back to a previous article:Loading external configurations in Angular Universal, weisolated the server, and I specifically mentioned one of the benefits is serving a multilingual app using the same build. Today, we shall make use of it. When building for SSR, using:

ng run cr:server:production

The file generated inhost/server folder is main.js.The following is the routes done with SSR in mind (in StackBlitz it'shost/server/routes-ssr.js)

const express = require('express');// ngExpressEngine from compiled main.jsconst ssr = require('./main');// setup the routesmodule.exports = function (app, config) {  // set engine, we called it AppEngine in server.ts  app.engine('html', ssr.AppEngine);  app.set('view engine', 'html');  app.set('views', config.rootPath + 'client');  app.get('/locale/language.js', function (req, res) {    // reroute according to lang    res.sendFile(config.getLangPath(res.locals.lang));  });  // open up client folder  app.use(express.static(config.rootPath + '/client'));  app.get('/*', (req, res) => {    // render our index.html    res.render(config.rootPath + `client/index.html`, {      req,      res    });  });};

Previously we used a trick to differentiate between server and browser platforms to include the same JavaScript on both platforms:

// in javascript, an old trick we used to make use of the same script on both platformsif (window == null){    exports.cr = cr;}

Looking atAngular Locale scripts, they are wrapped like this:

// a better trick(function(global) {  global.something = 'something';})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||   typeof window !== 'undefined' && window);

This is better. Why didn't I think of that earlier? Oh well. Let's rewrite our language files to be wrapped by a function call:

// locale/language.js (cr-en and cr-ar) make it run on both platforms(function (global) {  // for other than en  if (window != null) {    // in browser platform    const script = document.createElement('script');    script.type = 'text/javascript';    script.defer = true;    script.src = 'locale/ar-JO.js';    document.head.appendChild(script);  } else {    // in server platform    require('./ar-JO.js');  }  const keys = {    NoRes: '',    // ...  };  global.cr = global.cr || {};  global.cr.resources = {    language: 'ar',    keys,    localeId: 'ar-JO',  };})(  (typeof globalThis !== 'undefined' && globalThis) ||    (typeof global !== 'undefined' && global) ||    (typeof window !== 'undefined' && window));

Inlanguage middleware, require the file.

module.exports = function (config) {  return function (req, res, next) {    // ... get cookie    // if ssr is used    require(config.getLangPath(res.locals.lang));    // ... save cookie  };};

Running the server. We are faced with two problems:

  • app.moduleis loading immediately, before any routing occurs. It looks forLOCAL_IDinglobal.cr.resources, which has not been loaded anywhere yet.
  • Defining a default one, the locale does not change on the server, dynamically, sinceapp.modulehas already run with the first locale.

To dynamically change the LOCALE_ID on the server---without restarting the server, Googled and found asimple answer. ImplementinguseClassfor the provider inapp.module. Looking into the code generated via SSR, this change eliminated the direct referencing ofLocalId, and turned it into avoid 0statement.

exports.Res = exports.LocaleId = void 0;

This is a recurring problem in SSR, whenever you define root level static elements. Note that once the application hydrates (turns into Browser platform), it no longer matters, browser platform is magic!

// in Res class, extend the String class and override its default toStringexport class LocaleId extends String {    toString() {        return cr.resources.localeId || 'en-US';    }}// and in app.module, useClass instead of useValue@NgModule({  // ...  providers: [{ provide: LOCALE_ID, useClass: LocaleId }]})export class AppModule {}

This takes care of the first problem. It also partially takes care of the second one. The new problem we're facing now is:

  • NodeJS requires files once. If required again, the file will be pulled out the cache, and it will not run the function within. Thus on server platform, switching the language works the first time, but switching back to a previously loaded language, will not update the locale.

To fix that, we need to save the differentglobal.crcollections in explicit keys, and in the language middleware assign our NodeJSglobal.cr.resourcesto the right collection. In our language JavaScript files, let'sadd the explicit assignment:

// in cr-en cr-ar, etc,(function (global) {  // ...  // for nodejs, add explicit references  // global.cr[language] = global.cr.resources  global.cr.en = global.cr.resources;})(typeof globalThis !== 'undefined' && globalThis || typeof global !== 'undefined' && global ||    typeof window !== 'undefined' && window);

In ourlanguage middleware, whenever a new language is requested, it is added to the global collection. Then we pull out the one we want:

// language middlewaremodule.exports = function (config) {  return function (req, res, next) {    // ...    require(config.getLangPath(res.locals.lang));    // reassign global.cr.resources    global.cr.resources = global.cr[res.locals.lang];    // ...  };};

Running the server, I get no errors. Browsing with JavaScript disabled, it loads the default language. Changing the cookie in the browser multiple times, it works as expected.

That wasn't so hard was it? Let's move on to URL-based language.

URL-based application

For content-based and public websites, deciding the language by the URL is crucial. To turn our server to capture selected language from URL instead of a cookie, come back next week.

Thanks for reading through another episode. Let me know if I raised an eyebrow.

RESOURCES

RELATED POSTS

Loading external configurations in Angular Universal

Catching and displaying UI errors with toast messages in Angular


Original Link: https://dev.to/ayyash/serving-multilingual-angular-application-with-expressjs-ocf

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To