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.
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 ourkeys
array to a property in thetranslation
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 ofobj
. This only holds true for the very first iteration. - So the first time it runs,
obj
in the callback function is equal to thetranslation
object andi
is equal to"header"
. - In the callback function’s body, we just return
obj[i]
. In this case, it’sobj["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 ofobj
in the second iteration. The value ofi
this time is equal totitle
.- 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 calledtext
. 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 itdata-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 hiddenscript
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 calltranslator.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!