63Days

63 days since this post was last revised. It's possible some minor details may have changed.

About 90% of my working days in the last 5 years has been making feature prototypes for a web application.

With any designs you create that have to work across different languages it’s not uncommon for different string lengths to create new design problems. Text you imagined would fit in space X no longer does in Greek or Italian etc.

There are number of fully featured JS powered translation libraries out there. This won’t be one of them!

I wanted to make something very simple. This is the kind of solution that is bread and butter for any seasoned JavaScript capable developer but I wanted to document a possible solution for the less experienced.

The requirements are minor; the ability to swap out particular strings of text in my prototype based on the language chosen from a drop-down.

Here is how we can do that.

If any seasoned JS devs can point any obvious ways to improve this, I am all ears. A comment or tweet will be appreciated.

The HTML side of things

First of all, we will make our language selector with a standard select element. The internal option elements are going to be populated by JavaScript. You can provide whatever id you like for the select.

<select name="language" class="mb-POCControls_Selector" id="mbPOCControlsLangDrop">
</select>

Then at any place you would like to swap some text in a node, add the attribute data-mlr-text. For example:

<span data-mlr-text>Sausages</span>

OK, that’s all you need to do in the HTML, everything else will be JavaScript.

JavaScript

We are going to have a main function called mlr. We are going to invoke this function with a number of options like this.

mlr({
    dropID: "mbPOCControlsLangDrop",
    stringAttribute: "data-mlr-text",
    chosenLang: "English",
    mLstrings: MLstrings,
    countryCodes: true,
    countryCodeData: mlCodes,
});

Here is an explanation of the options we are going to need:

  • dropID – the id of the select that will be used to chose languages (whatever you set in the HTML)
  • stringAttribute – the attribute name you will add to the nodes you want to switch the text on. Leave this as is unless you have a compelling reason to change it. In our example we used data-mlr-text
  • chosenLang is the initial language you want each string rendered in. The string you pass in should match the string in the language files (case sensitive)
  • mLstrings is the name of the variable/array you have all your translations stored in
  • countryCodes is an optional boolean value for if we want the lang attribute on the HTML element updating too
  • countryCodeData is an optional variable for an array containing country codes for your chosen language

Language files

To store our translations for each string, we can use an array of objects. Each object contains a relevant string in each of the languages to switch between. For example:

var MLstrings = [
    {
        English: "Sausages",
        Polish: "Kiełbaski",
        German: "Würste",
        Russian: "Колбасные изделия",
        Traditional: "香腸",
    },
    {
        English: "Carrot",
        Polish: "Marchewka",
        German: "Karotte",
        Russian: "Морковь",
        Traditional: "胡蘿蔔",
    }
];

You would add another object for each of the strings you need to switch out. I have added only two here to keep things simple.

I tend to keep these strings in a separate file for convenience and ensure that file is loaded before the ‘meat and potatoes’ of the code we are about to write. I tend to use TypeScript these days and a triple slash import works well here. For example: /// <reference path="ts/mlStrings.ts">

Country Codes

The two character language codes for countries is defined in ISO 639–1. If you also want to update your HTML tag with the correct code for the chosen language you need to include a file containing the relevant country codes for the languages you are translating between. It should be formatted like this:

var mlCodes = [
  {
    code: "bg",
    name: "Bulgarian",
  },
  // More country codes
];

Note that the casing of the country names should match the casing in your translation data.

Our string replacement function

Now lets look at the main functions that will drive our functionality. We will step-by-step it afterwards.

// Global var :(
var mlrLangInUse;

var mlr = function({
    dropID = "mbPOCControlsLangDrop",
    stringAttribute = "data-mlr-text",
    chosenLang = "English",
    mLstrings = MLstrings,
    countryCodes = false,
    countryCodeData = [],
} = {}) {
    const root = document.documentElement;

    var listOfLanguages = Object.keys(mLstrings[0]);
    mlrLangInUse = chosenLang;

    (function createMLDrop() {
        var mbPOCControlsLangDrop = document.getElementById(dropID);
        // Reset the menu
        mbPOCControlsLangDrop.innerHTML = "";
        // Now build the options
        listOfLanguages.forEach((lang, langidx) => {
            let HTMLoption = document.createElement("option");
            HTMLoption.value = lang;
            HTMLoption.textContent = lang;
            mbPOCControlsLangDrop.appendChild(HTMLoption);
            if (lang === chosenLang) {
                mbPOCControlsLangDrop.value = lang;
            }
        });
        mbPOCControlsLangDrop.addEventListener("change", function(e) {
            mlrLangInUse = mbPOCControlsLangDrop[mbPOCControlsLangDrop.selectedIndex].value;
            resolveAllMLStrings();
            // Here we update the 2-digit lang attribute if required
            if (countryCodes === true) {
                if (!Array.isArray(countryCodeData) || !countryCodeData.length) {
                    console.warn("Cannot access strings for language codes");
                    return;
                }
                root.setAttribute("lang", updateCountryCodeOnHTML().code);
            }
        });
    })();

    function updateCountryCodeOnHTML() {
        return countryCodeData.find(this2Digit => this2Digit.name === mlrLangInUse);
    }

    function resolveAllMLStrings() {
        let stringsToBeResolved = document.querySelectorAll(`[${stringAttribute}]`);
        stringsToBeResolved.forEach(stringToBeResolved => {
            let originaltextContent = stringToBeResolved.textContent;
            let resolvedText = resolveMLString(originaltextContent, mLstrings);
            stringToBeResolved.textContent = resolvedText;
        });
    }
};

function resolveMLString(stringToBeResolved, mLstrings) {
    var matchingStringIndex = mLstrings.find(function(stringObj) {
        // Create an array of the objects values:
        let stringValues = Object.values(stringObj);
        // Now return if we can find that string anywhere in there
        return stringValues.includes(stringToBeResolved);
    });
    if (matchingStringIndex) {
        return matchingStringIndex[mlrLangInUse];
    } else {
        // If we don't have a match in our language strings, return the original
        return stringToBeResolved;
    }
}

I’ve got a globally accessible variable at the outset, mlrLangInUse. I’ve namespaced it with mlr to avoid conflicts but I’m still not massively happy about it. However, I couldn’t find an elegant away around it. I need the value that variable stores to be accessible to the resolveMLString function which can be called independently. More on that function shortly.

The mlr function starts by ‘de-structuring’ the options. This is the ES6 style of setting defaults for an options object, with the value to the right of the = sign being the default setting for each option. Note that the option object itself gets a default to at the end with the = {} part. Best explanation I came across of de-structuring was at https://simonsmith.io/destructuring-objects-as-function-parameters-in-es6/

Next we var the HTML element in case we want to update the lang attribute. Then we create an Array of the possible languages. We do this by looking at the first object in the mLstrings and use the Object.keys() method to create an array from the keys in the object.
We also set the language in use in the globally accessible mlrLangInUse variable. This is initially set to the string passed in for chosenLang.

const root = document.documentElement;

var listOfLanguages = Object.keys(mLstrings[0]);
mlrLangInUse = chosenLang;

Next we create the drop-down options inside an immediately invoked function (note the double brackets at the end).
Each option is appended to the select element we specified in the options (the id was mbPOCControlsLangDrop in our example).

With the options created, we then add an event listener to the select element and use this listener to update things on the change event.

Let’s cover what we do each time there is a change on the select element.

mlrLangInUse = mbPOCControlsLangDrop[mbPOCControlsLangDrop.selectedIndex].value;
resolveAllMLStrings();
// Here we update the 2-digit lang attribute if required
if (countryCodes === true) {
    if (!Array.isArray(countryCodeData) || !countryCodeData.length) {
        console.warn("Cannot access strings for language codes");
        return;
    }
    root.setAttribute("lang", updateCountryCodeOnHTML().code);
}

The first line in the above code re-assigns a value to mlrLangInUse. If you remember this was set at the outset by the options. We want to update this to the value of the language assigned to the option in the drop-down. selectedIndex is part of the Web API and let’s you get at the chosen option from a drop-down. With that we can then read the value of that option.

Next we fire the resolveAllMLStrings() function. But before we get into that lets go to the next lines where we also set the lang attribute of the HTML element. First we check if countryCodes is true. If not, we are done. If it is true, we next check if we have any data in the array of countryCodeData or if it even exists. If not we send a warning to the console and return. Otherwise we go ahead and set the language attribute by running a one line function (updateCountryCodeOnHTML) that returns the object from the countryCodeData array that matches the currently selected language. The .code part just uses the relevant country code part of the retrieved object. For example, I ‘Czech’ was selected I would be retrieving:

{
  code: "cs",
  name: "Czech",
},

So the code part would be ‘cs’.

OK, back to the resolveAllMLStrings function. First of all it goes and finds all the strings in the HTML that have the stringAttribute we defined in the options. In our example this would be all “data-mlr-text” attributes. Then it runs a function on each of them, taking the current textContent and assigning it to originaltextContent and using that to set what the new text will be. We do this by passing the original text to the resolveMLString function along with the strings we want to search within (mLstrings). We then set the textContent of the element to be the new string.

Right, final thing we need to look at is the resolveMLString function. Unless someone can offer an alternative, I think this function has to be external to our main mlr function so that it can be called outside of mlr. For example, if elsewhere we want to set the text of an element with JavaScript, we need to call this function without re-instantiating the mlr function (which would in turn require passing in all the necessary options). I’d welcome any feedback here if there is a better way to handle this. However, at present it is possible to do this elsewhere resolveMLString("Sausages", MLstrings) and I would be returned the correct string based on the language currently set in the global mlrLangInUse variable.

In the absence of a better alternative, let’s consider what resolveMLString actually does.

We want to return a translated string if we have one, otherwise return the original string that was sent in. But how do we actually search for that string? We use the find() method of Array. We use find() on our mLstrings and assign an array to stringValues that contains values from each object of strings. For example, stringValues might look like ["Sausages","Kiełbaski","Würste","Колбасные изделия","香腸"]. We can then use the includes() array method on this new array to return true if the array contains the string we are interested in. Now we have found the correct object from our main mlStrings array, we can return the correct string because we know the language we are interested in. Phew!

To exemplify, had we chosen Polish as our language and we wanted Carrot in this language we can pick it from this data easily:

{
    English: "Carrot",
    Polish: "Marchewka",
    German: "Karotte",
    Russian: "Морковь",
    Traditional: "胡蘿蔔",
}

Which is where we use matchingStringIndex[mlrLangInUse], otherwise we would just return back the original string.

Conclusion

OK, it might not look like much but here is an example of our efforts!

https://codepen.io/benfrain/pen/GdKrVx

See the Pen Simple Language Picker by Ben Frain (@benfrain) on CodePen.

I’ll be honest, I found this blog post quite difficult. Not because the solution is anything fancy but because sometimes it is harder to explain code than it is to read it.
The rub however is that if you can’t read the code/language in the first place it’s hard to understand it! That’s the position I find myself in constantly when trying to learn JavaScript.
I hope any less experienced JavaScript developers found some of this useful.
For the more experienced devs, I’m always happy to hear how any of this could be improved.

Ben Frain Developer, Author: 'Enduring CSS', 'Responsive Web Design with HTML5 & CSS3'.