Skip to content

Building a Super Small and Simple i18n Script in JavaScript

Build a super small translation library in pure JavaScript and learn some important concepts about i18n.

Building a Super Small and Simple i18n Script in JavaScript

I recently moved my résumé from Google Docs to HTML and CSS, giving me more creative and technical freedom. As part of that move, I wanted the résumé to be available in different languages.

Sure, I could use a (server-side) templating language or a static site generator such as Jekyll to serve translated content, but I wanted to keep things simple and rely on pure HTML and JavaScript.

This article describes how I implemented an i18n feature for my résumé without third-party libraries. This is probably not the perfect solution for you or your project and might be missing some crucial logic, but maybe you'll find it interesting anyway.

Not convinced yet? Here's a working example:

#1 — Imaging The API

It was clear that the translation should happen only in the front end, using nothing but plain JavaScript and HTML. So before I start building such features, I like to figure out what the API should look like.

Say we have the following HTML:

<h1>This is some English title</h1>

If we would like to translate this text, what would be the best way? In case you've worked with templating engines like Handlebars before, you might be familiar with this syntax:

<h1>{{ title }}</h1>

Once the templating engine executes this code, it replaces title with an associated value provided by some object. Alternatively, we can put a function in between the curly braces, like so:

<h1>{{ i18n('title') }}</h1>

Somewhere in your code, there'd be a function i18n() that takes a key as a string and returns the proper translation. The templating engine executes this function each time it encounters it in the template, resulting in the translated HTML.

I like this approach since it's clear and straightforward. However, I didn't want to include a fully-fledged template engine nor build one from scratch, so this idea didn't work out for me.

However, if you have something like Handlebars already included in your app, go ahead and make use of it.

Instead, I decided to go with HTML data attributes:

<h1 data-i18n="title">This is some English title</h1>

Data attributes are part of the HTML specification and a great way to provide additional, custom data to HTML. For each HTML element that we need to translate, we would provide the data-i18n attribute with a key. In this case, title is the key to look for in my translation files and tells the translator what text to apply.

#2 — The translation Files

Now that we can mark our HTML elements as translatable, we should create the translation files next. In my project, I decided to make a new folder i18n and put a .json file for each language inside:

i18n
|—— en.json
|—— de.json
|—— es.json

Inside the translation file it would look similar to this:

{
  "greeting": "Hello World",
  "header": {
    "title": "This is the header text",
    "button": "Click me!"
  }
}

The structure across these files must be consistent! You can't have a header property in one file and a heading for the same text in a different one. The only thing that can (and should) change is the actual text content.

The above code sample is quite simple. Check out this file in my repository if you want to see a more extensive example that's actually in use.

#3 — Building The Translator

The last thing missing is the actual translator. Let’s get our hands dirty writing some JavaScript.

The Base Class

We start by creating a new file called translator.js in our JavaScript directory.

The first thing to do is to create a new class called Translator:

"use strict";

class Translator {
  constructor() {}
}

export default Translator;

So far, we have an empty class with a constructor (which will be useful in a second) that gets exported as the default. This means that from anywhere in our project, we can import and use it like so:

import Translator from "./translator.js";

new Translator();

Determining The User’s Language

One of the trickier things is to determine which language the user wants in the first place. If this were the server-side (e.g., Node.js), things would get a lot better by using the Accept-Language HTTP Header, which contains an ordered, weighted list of preferred languages by the user. The browser sends this header.

Unfortunately, we are limited to the front end and don't have access to headers. Instead, we need to rely on the navigator.languages array, which holds preferred languages based on browser settings.

Go ahead and open your browser's dev tools, no matter if it's Firefox, Chrome, or any other; type navigator.languages and press Enter. It will most likely return a similar array:

navigator.languages
(4)[("en-US", "en", "de-DE", "de")]

With this information in place, we can write our first method in the Translator class:

getLanguage() {
  var lang = navigator.languages
    ? navigator.languages[0]
    : navigator.language;

  return lang.substr(0, 2);
}

The first thing we do is to declare a variable lang. Its assigned value is going to depend on whether or not the navigator.languages array exists. In some older browsers it might not, so we want to have a proper fallback. In that case, navigator.language (note the missing "s") is available, containing just a string like "en-US".

However, the navigator.languages array should be available in most (modern) browsers, and if that's the case, we get its first entry and assign it to lang.

The next thing to consider is that the language string might not always be in the same format. It can either be "en-US" or just "en". We need to consider this when evaluating the string.

Therefore we only return the first two characters using substr(0, 2). If the value is "en-US", only "en" gets returned. If the value was "en" in the beginning, the return doesn't change anything.

Let's store this information in our Translator class. The best place to do so is inside the constructor:

constructor() {
  this._lang = this.getLanguage();
}

Loading The Language Files

Awesome progress; we now have the user's preferred language. This means that we can load the associated language file and consume its data. Let's add a new method to the Translator class:

load(lang = null) {
  if (lang) {
    this._lang = lang;
  }
}

We accept a parameter lang for this method since eventually, the user might want to switch the language using a button or something. In that case, we'll call translator.load('en') to translate the page. By default, however, the lang attribute is not set, meaning that we use whatever is set in this._lang by our language detection from above.

Below that if-condition add the following code:

fetch(`/i18n/${this._lang}.json`)
  .then((res) => res.json())
  .then((translation) => {
    // do something
  })
  .catch(() => {
    console.error(`Could not load ${this._lang}.json.`));
  });

Using the Fetch API, we head out to get the correct language file and process it as JSON. Its content is now available in the second .then callback.

Note that if you didn't place the i18n folder in the root of your project but rather a subfolder, you'd need to change the path accordingly.

If the file doesn't exist, the promise will fail and .catch is called, logging an error. At this point, we could talk about error handling, but let's not worry about that for now.

Instead, we assume that the file has been loaded and we have access to the object containing all of our texts. The next step is to translate the HTML page.

Inside the .then callback function, put the following code:

.then((translation) => {
  this.translate(translation);
})

this.translate is a new method inside our Translator class, but we haven’t created it, yet:

translate(translation) {...}

Translating The Page

But what does the translate method do? Basically, we need to:

  • Loop through all HTML elements with a data-i18n attribute and get the translation key.
  • Match the key with an entry in the translation file.
  • Replace the HTML elements' text with the found translation.

But we still don't have a list of the HTML elements with a data-i18n attribute. So let's go back into our classes' constructor and add the following line of code:

constructor() {
  this._lang = this.getLanguage();

  // This is new:
  this._elements = document.querySelectorAll("[data-i18n]");
}

All we do is use querySelectorAll to select all elements with said attribute. A reference to a NodeList will be stored in this._elements, making it accessible in the entire class.

Let's get back to the translate method and add a forEach loop:

translate(translation) {
  this._elements.forEach((element) => {..});
}

All this does is to iterate over each element with a data-i18n attribute. Inside the callback function, we can define what should happen to this element, so let's continue:

var keys = element.dataset.i18n.split(".");
var text = keys.reduce((obj, i) => obj[i], translation);

if (text) {
  element.innerHTML = text;
}

So what in the world is actually going on here?

  • On line 1 we declare a new variable named keys.
  • keys gets assigned an array as a result of the .split(".") method. What we do is split our values whenever a dot appears. For example, data-i18n="header.title" would result in the array ["header", "title"].
  • The following line is perhaps the most difficult one because it involves reduce. To make it short, here, we map our keys array to a property in the translation object and store its value.
  • Then, if the key string matched anything in the object, we override the element's HTML on line 5 with the new text from the translation file.

But let's go back to line 2 for a second… "What does this even do?" I hear you asking. Stick with me.

The problem is that our data attribute is something like header.title. We now have to find the right property in the nested object, right?

{
  "header": {
    "title": "The precious text to look for"
  }
}

You most likely are familiar with these two ways of accessing properties in objects:

obj["title"];
obj.title;

Unfortunately, neither works for us because the object is nested. So we can't write obj["header.title"], it would just return undefined.

That's why we converted the string header.title into an array. With .reduce, we can now iterate over these two values in the array:

["header", "title"].reduce((obj, i) => obj[i], translation);
  • reduce runs once for every value in the array. In this example, it runs twice.
  • The second parameter, translation, is the initial value of obj. This only holds true for the very first iteration.
  • So the first time it runs, obj in the callback function is equal to the translation object and i is equal to "header".
  • In the callback function’s body, we just return obj[i]. In this case, it’s obj["header"]. This equals to the sub object { title: "The precious text to look for" }. You could also write it like this to make it more explicit:
["header", "title"].reduce(function (obj, i) {
  return obj[i];
}, translation);
  • reduce accumulates values of an array. Since we returned { title: "The precious text to look for" } in the first iteration, this is going to be the value of obj in the second iteration. The value of i this time is equal to title.
  • We execute the function’s body the second time equally. Now we return obj["title"], which, if you remember the object’s structure, returns the string of “The precious text to look for”.
  • Since this is the final iteration (we only have two items in the array), the reduce method returns this value and we store it in a variable called text.
  • text is now equal to "The precious text to look for".

Yeah, I didn't get it in the beginning as well. reduce is still one of my biggest fears in JavaScript, but on the other hand, it's so incredibly useful.

So, in the end, we archived our goal. We can call the method load("en") and it will load the appropriate translation from a folder and apply it's contents to our HTML. Pretty neat, isn't it?

Changing The lang Attribute

One of the last things we could do to improve the script is to update the lang attribute each time the language changes.

Let's add a little method for that:

toggleLangTag() {
  if (document.documentElement.lang !== this._lang) {
    document.documentElement.lang = this._lang;
  }
}

All this does is to change the lang attribute, which is set on the root element of your website:

<html lang="en"></html>

#4 — Some Observations

  • In the first iteration of this script, I didn't use fetch to load the language files but dynamic imports (import()). After some feedback, I changed this because fetching the data is semantically more correct. JavaScript imports should rather be for modules and conditionally loaded logic, not resources.
  • The attribute data-i18n can be anything. If you don't like this name, you can call it data-translation or whatever else you'd prefer and change the associated code in the constructor.
  • I'm using innerHTML to override the text content upon translation. Some people might say that this is bad practice because it could lead to unsafe script execution in the HTML. While this argument holds true if you load data from a server or get it from a user (where you don't know what you get), in this very case, I know very well where the data is coming from and don't have to fear any hidden script tag lingering inside. After all, I'm the one writing the translation files, not a stranger.
  • In the beginning, we wrote the getLanguage method, which detects the user's preferred language. If you want to translate your site on load (without the user having to click on a button first), you can just call translator.load() (without passing any language parameter).

Conclusion

And that's it. If you want to use this script in your app, all you have to do is to import the Translator class, initialize it and call the load() method:

import Translator from "./translator.js";

var translator = new Translator();
translator.load("de");

The call to load could be inside an event handler, for example, after a user selects a language from a dropdown or presses a button. The implementation is entirely up to you.

If you want to see an example of the script in use, here you go:

I have also created a GitHub repository with a few additions like configuration, so it's easier to use this library in your projects.

As always, if you have any questions, encounter problems, or would like to provide suggestions, feel free to drop a comment or contact me directly. Happy coding!