Previously we derived the locales file that contains all the language resources in preparation to isolate them. Our focus today is serving through NodeJs and ExpressJS server. 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 $localize adapter, but it is limited to i18n uses. Our res class
can be used even if no locale is targeted, and language.ts
is used directly. We have already made use of it in Error 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 large
const 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 an external configuration file into Angular and came to the conclusion that the best way is to place 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 in index.html
:
<script src="locale/language.js" defer></script>
For that to work in development, we'll add an asset to angular.json
// angular.json options/assets
{
"glob": "*.js",
"input": "src/locale",
"output": "/locale"
}
To make use of the JavaScript keys collection, we declare in our typescript
. The res class
is the only place that uses the keys, and app.module
is the only place that uses the locale id. So let's place everything in res class
:
// in res class, we declare the keys and locale_id
declare 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 in angular.json
. Assuming we want to have multiple locales available, then we include them all in assets:
{
// 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 to let 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 platform
const 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 one refactor before 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 file
const keys = {
NoRes: '',
// ...
};
// combine and namespace
// window will later be global
window.cr = window.cr || {};
window.cr.resources = {
language: 'en',
keys,
localeId: 'en-US'
};
cr is short for cricket. Our project code name.
In our res class
:
// in res class remove imported keys from /locales/language.ts
declare 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, and cr-ar. The cr-ar
contains the added ar-JO locale script, while the cr-en
has 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 following angular.json
settings:
"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 file host/client. Inside that folder, we have locale folder 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 for locale/language.js, now it's our job to rewrite 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 using ExpressJS routing.
In our main server.js, we need to create a middleware to detect language, for now, from a cookie. The cookie name can easily be lost around, so first, I want to create a config file where I will place all my movable parts, this is a personal preference, backend developers probably have a different solution.
// server/config.js
const 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 language
module.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 the language cookie, sets it to response locals property, and then saves the language in cookies.
The basic server:
const express = require('express');
// get the config
const config = require('./server/config');
// express app
const app = express();
// setup express
require('./server/express')(app);
// language middleware
var language = require('./server/language');
app.use(language(config));
// routes
require('./server/routes')(app, config);
const port = process.env.PORT || 1212;
// listen
app.listen(port, function (err) {
if (err) {
return;
}
});
The routes for our application:
// build routes for browser only solution
const express = require('express');
// multilingual, non url driven, client side only
module.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 server config because 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, we isolated 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 in host/server folder is main.js. The following is the routes done with SSR in mind (in StackBlitz it's host/server/routes-ssr.js)
const express = require('express');
// ngExpressEngine from compiled main.js
const ssr = require('./main');
// setup the routes
module.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', {index: false}));
app.get('/*', (req, res) => {
// render our index.html
res.render(config.rootPath + `client/index.html`, {
req,
res
});
});
};
Exclude index.html
file in the static middleware, in order to force the root URL to pass through the Angular engine.
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 platforms
if (window == null){
exports.cr = cr;
}
Looking at Angular 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)
);
In language 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.module
is loading immediately, before any routing occurs. It looks forLOCAL_ID
inglobal.cr.resources
, which has not been loaded anywhere yet. - Defining a default one, the locale does not change on the server, dynamically, since
app.module
has already run with the first locale.
To dynamically change the LOCALE_ID on the server---without restarting the server, Googled and found a simple answer. Implementing useClass
for the provider in app.module
. Looking into the code generated via SSR, this change eliminated the direct referencing of LocalId
, and turned it into a void 0 statement.
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 toString
export 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 different global.cr
collections in explicit keys, and in the language middleware assign our NodeJS global.cr.resources
to the right collection. In our language JavaScript files, let's add 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 our language middleware, whenever a new language is requested, it is added to the global collection. Then we pull out the one we want:
// language middleware
module.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
- Dynamically changing the LocaleId in Angular
- StackBlitz project
- Angular $localize
- ExpressJS response locals
RELATED POSTS
Loading external configurations in Angular Universal
Catching and displaying UI errors with toast messages in Angular
Top comments (0)