Sekrab Garage

Twisting Angular localization

Serving the same Angular build with different URLs

Angular July 25, 22

Previously we created a server in Express to serve the same Angular build with a different language file, using rewrite rules for that file, detecting a cookie. That works well for intranets and authorized applications. Today we shall create our application to detect the language in the URL instead. Like this:

www.site.com/en/content/details

Benefits of language specific URL

I can think of two benefits for having the language in the URL:

  • Search bot confusion: for public content that can be crawled by search bots, if bots can crawl multiple languages, serving different content for the same URL confuses the bot, and affects the site’s rank.
  • Localized results: speaking of bots, the language in the URL allows us to have alternate links in the header for different languages, search engines return the relevant match to users. Google states that
Note that the language-specific subdomains in these URLs (enen-gben-usde) are not used by Google to determine the target audience for the page; you must explicitly map the target audience.

But then again, Google does not document everything, I have a hunch, that it does make a difference.

  • The second benefit is users’ convenience if they happen to choose a different language to their locale. Saving URLs in favorites, opening in different devices, or sharing it amongst their friends, it is preferred that the URL itself holds the language information to know user’s intent.

Great, two benefits, in three bullet points. I hope you’re convinced. Let’s get on with it.

Find the files in StackBlitz, though don't expect much, the environment is too strict to allow them to run.

Browser only application

We need to accomplish the following:

  • Detect the language from URL in the language middleware
  • Serve the right base href value in index.html
  • Reroute unsupported languages to default language
  • Handle the root URL

Detect language from URL

Starting with the language middleware:

module.exports = function (config) {
  return function (req, res, next) {
    // exclude non html sources, for now exclude all resources with extension
    if (req.path.indexOf('.') > 1) {
      next();
      return;
    }

    // derive language from url, the first segment of the URL, no checks yet
    res.locals.lang = req.path.split('/')[1];

    next();
  };
}

We are extracting the first segment of the URL no matter what. Following are the routes: (find them in StackBlitz under /host/server/routes-url.js)

// express routes
module.exports = function (app, config) {
  // reroute according to lang, does not matter what param is passed because it's already set
  app.get('/:lang/locale/language.js', function (req, res) {
    res.sendFile(config.getLangPath(res.locals.lang));
  });
  
  // use static files in client, but skip index
  app.use('/:lang', express.static(config.rootPath + '/client', {index: false}));

	// TODO: exclude unsupported languages
  app.get('/:lang/*', function(req, res){
		// TODO: here, develop an HTML template engine to replace the base href value
    res.render(config.rootPath + `client/index.html`, {lang: res.locals.lang});
  });

  // nothing matches? redirect to /root
  app.get('/*', function (req, res) {
    // if none, redirect to default language (TODO: default language)
    res.redirect(301, '/' + res.locals.lang + req.path);
  });
};

Why index: false option

We had no issues in the browser-only app in the previous article; letting the index.html be served by the express static module, since we served a static file. Now that we are going to develop a template engine to change the index.html, we need to disable the default index for root URLs in the static middleware. So site.com/en/ should not be served by the static middleware, thus we pass index: false option:

app.use('/:lang', express.static(config.rootPath + '/client', {index: false}));

There are less direct methods, renaming index.html and changing the default file; to name a few.

Index base href replacement

The first task on our list of tasks is to generate the right base href per language served. We will create a simple HTML template engine, that replaces the string with the selected language. We can place the following code anywhere on our server:

// in epxress routes
// ...
const fs = require('fs') // this engine requires the fs module

module.exports = function (app, config) {
	// ...
  app.engine('html', (filePath, options, callback) => { 
    // define the template engine
    fs.readFile(filePath, (err, content) => {
      if (err) return callback(err);

      // replace base href tag, with the proper language
      const rendered = content.toString()
        .replace('<base href="/">', `<base href="/${options.lang}/">`);
      return callback(null, rendered)
    });
  });
	// setting the engine and views folder are not needed
  // ...
  app.get('/:lang/*', function(req, res){
	  // use the HTML engine to render
    res.render(config.rootPath + `client/index.html`, {lang: res.locals.lang});
  });
	// ...
}

Unsupported languages

The other challenge is finding a non supported language and rolling back. In the language middleware, we need to find the language first, compare it to supported languages list, if not found, return a default language. Let’s first add a list of supported languages to our config (again, this is a personal choice, that looks a bit all over the place, but for the scope, it should do).

// config.js
module.exports = {
	// ...
  // supported languages
  languages: ['en', 'ar']
};

In our language middleware:

// language middleware:
// derive language from url, the first segment of the URL, 
// check if found in supported languages
res.locals.lang = config.languages.find(n => n === req.path.split('/')[1]) || 'en';

In our routes, we need to take care of one route only, the one that decides the language. So for the index.html route, we’ll pass an array of all supported languages as a path:

// routes, use only supported lanugages URLs
app.get(config.languages.map(n => `/${n}/*`), function(req, res){
  // pass language found in language middleware
  res.render(config.rootPath + `client/index.html`, {lang: res.locals.lang});
});

Root URL

The last bit is to redirect the root URL to an existing language. The best choice is to try and fetch a cookie first before defaulting to some language. Thus the cookie bit is still useful in our language middleware.

// language middleware
module.exports = function (config) {
  return function (req, res, next) {

		// check cookies for language
    res.locals.lang = req.cookies[config.langCookieName] || 'en';

		// exclude non html sources, exclude all resources with extension
    if (req.path.indexOf('.') > 1) {
      next();
      return;
    }

    // derive language from url, the first segment of the URL, 
		// then fall back to cookie 
    res.locals.lang = config.languages.find((n) => n === req.path.split('/')[1]) ||
      res.locals.lang;
		
		// set cookie for a year
    res.cookie(config.langCookieName, res.locals.lang, {
      expires: new Date(Date.now() + 31622444360),
    });

    next();
  };
}

Then in the routes, the last route to add:

(This also takes care of any URLs that were not previously prefixed with language, or prefixed with a non supported language, which is a scenario we do not wish to dive into.)

// nothing matches? redirect to /en/path
app.get('/*', function (req, res) {
  res.redirect(301, '/' + res.locals.lang + req.path);
});

Server platform

Pretty much the same as browser only routes. We do not need to create a new engine, the template engine is already provided by Angular. Reading the documentation of the ngExpressEngine, the property that renders the HTML file is document.

// build routes in SSR and change language via url
// find it in stackblitz host/server/routes-ssr-url.js

const ssr = require('./main');
const fs = require('fs');

module.exports = function (app, config) {
  // ngExpressEngine
  app.engine('html', ssr.AppEngine);
  app.set('view engine', 'html');
  app.set('views', config.rootPath + '/client');

  // reroute according to lang, does not matter what param is passed because its already set
  app.get('/:lang/locale/language.js', function (req, res) {
    res.sendFile(config.getLangPath(res.locals.lang));
  });

  // use static files in client, skip index.html
  app.use(
    '/:lang',
    express.static(config.rootPath + '/client', { index: false })
  );

  // exclude unsupported languages
  app.get(config.languages.map((n) => `/${n}/*`), function (req, res) {

    // use Angular engine, pass a new string of HTML in document property
    const content = fs.readFileSync(config.rootPath + `client/index.html`);
    const rendered = content.toString().replace('<base href="/">', `<base href="/${res.locals.lang}/">`);
    
  	// first attribute does not matter, it's the default in views folder
		res.render('', {
      req,
      res,
      // overwrite here
      document: rendered
     });
    }
  );

  // nothing matches? redirect to /en/path
  app.get('/*', function (req, res) {
    res.redirect(301, '/' + res.locals.lang + req.path);
  });
};

Serving a little more than language

There are solutions out there for translation, that switch the site language without a refresh (ngx-Translate is one), there are a couple of issues with that. One of them is the need to change more than just the language file in index.html. We already adapted the HTML base href value, what else can we adapt? Let’s find out next episode. 😴

Thank you for reading this far, I have been typing with a makeshift bandage around my index finger. Forgive my mizbells.

  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