Translating options pages

Same here. I additionally use a simple wrapper that renders translation key if translation itself is missing (makes it easy to see which labels need to be added):

Usage in HTML:

So, I’m the one who posted that question. And, so far, I have attempted to go down the data-i18n route, but encountered a number of edge cases that were not handled by that. I’ve now ended up with a bunch of different versions depending on what I’m trying to do, which still feels hacky and not generic enough. Here’s my current code, with comments on each edge case.

function translate() {
    // Normal translation in many other examples.
    for (const elem of document.querySelectorAll("[data-i18n]"))
        elem.innerHTML = browser.i18n.getMessage(elem.dataset.i18n);

    // Translates title attribute, e.g. tooltip on checkbox.
    for (const elem of document.querySelectorAll("[data-i18n_title]"))
        elem.title = browser.i18n.getMessage(elem.dataset.i18n_title);

    // Translates value attribute, e.g. for input button text
    for (const elem of document.querySelectorAll("[data-i18n_value]"))
        elem.value = browser.i18n.getMessage(elem.dataset.i18n_value);

    // Regex match on string to replace matching {keys} in string, e.g. translates "3 {days}"
    for (const elem of document.querySelectorAll("[data-i18n_re]")) {
        let text = elem.dataset.i18n_re;
        elem.innerHTML = text.replace(/{(.*?)}/g, function (_, m) {
            return browser.i18n.getMessage(m);
        });
    }

    // Localises numbers, e.g. formats "10000 abc" as "10,000 abc"
    var num_format = new Intl.NumberFormat();
    for (const elem of document.querySelectorAll("[data-i18n_num]")) {
        let text = elem.dataset.i18n_num;
        elem.innerHTML = text.replace(/\d+/g, num_format.format);
    }
}

document.addEventListener("DOMContentLoaded", translate);

That’s already a number of slightly hacky solutions to handle all the corner cases I’ve needed so far, and I still haven’t added in a case that will translate strings and localise numbers at the same time, e.g. “10000 {days}”.

So, I feel there should be a somewhat better, more generic approach that can handle all these cases.

Another edge case I just bumped into, try translating something like:

<label data-i18n="username">
    <input type="text" name="username" />
</label>

The current JS code would wipe out the input element when setting the label translation. I’m just separating the label from the input for now, but it’s another edge case that should be considered.

My code works for all these instances :wink:
PS. I added a github link to my post.

@erosman, I’d like to add an example of this to the webextensions-examples repo, probably by adding a simple options page to notify-link-clicks-i18n.

Would you mind if I used your code for this? I will attribute you as the author.

Not at all… go ahead

Just a note to say … Options page, browser/page popups, sidebar, and other HTML documents.
They all have access to i18n.getMessage and the same code works in all of them.

1 Like

PS. I have updated the information on the repository ReadMe.

@wbamberg, can i ask why you wouldn’t simply use {{some-id}} in the HTML and replace as needed? In other words, why have the complexity of a special attribute which may or may not have a pipe character? Here are some examples of what I mean:

<p>{{welcome-text}}</p>
<button>{{click-me}}</button>
<input type="button" value="{{click-me}}">

Similar to angular. Javascript would be simple find/replace.

I’ve written a small script that does translations in the old SDK l10n style, though it also supports some new attributes, namely “data-l10n-nocontent” and the “translate” attribute from the HTML5 standard. As only difference to the SDK l10n it does not support args in its current version and does not support plurals.

I currently maintain it in a gist under the MPL 2.0:

That’s actually a good start, I like the way you’ve handled the attributes, and inserting the content rather than replacing it.

However, I think there are still 2 of my edge cases that your code isn’t handling.

Firstly, the regex match on matching {keys}, in order to translate something like “3 {days}”.

Secondly, any kind of number localisation (see the last part of my code, and the comment immediately after the code about localising numbers with other translations).

But, so far, that is the best example I’ve seen with a concise amount of code.

There are always cases that require a bit of creativity to overcome the limitations.

Firstly, replacements can be handled in messages.json
"someText": { "message": "This message was posted 3 $1 ago." },

Secondly, it can be done in HTML (depending on the situation):
<p>3 <span data-i18n="days"></span></p>

That is a totally different issue relating to the nature of translation rather than method of the replacement but easily solved by first formatting the number separately and then passing it to the i18n.getMessage(text, number.toLocaleString()) (Number.prototype.toLocaleString())

Thank you… I hate bulky voluminous code :wink:
I haven’t yet come across a situation in my own addons that couldn’t be handled by the code. Obviously, as soon as I find such instances, I will update the code accordingly. :slight_smile:

If I may, Eric…

RegEx Find/Replace works on strings.
In order to do <p>{{welcome-text}}</p>

  • The HTML has to be read as a string, that often means XHR the file in advance of loading
  • Then the RegEx to replace
  • Then the outcome has to be loaded as HTML by converting the STRING to DOM (really ugly and poor performance) or other methods of creating the DOM

Alternatively …

  • Allow the HTML page to load as-is
  • Run a TreeWalker or NodeIterator to search of all nodes and find those instances and replace them
    That also doesn’t perform as well either.

Finally, just compare how many lines of code and functions (thus overheads) are needed in order to perform either of above tasks … with the 4 lines of code in my example :stuck_out_tongue:

Actually, that could be a really good idea, if we can add arguments into the data-i18n attribute. The idea of translating “3 {days}” is that, only the {days} string needs to be translated. So, if we could do something like
"days": {"message": "$1 days"}
with an attribute something like
data-i18n="days?3"
where the 3 is passed in as an argument, then that would handle my use case much better. Even allowing the translator to change the ordering of the number and word if necessary.

OK, just had a play around with that idea, and this is my version with arguments, allowing you to do translations with syntax like “days?3|title”. Note that I replaced the ternary operator out of personal preference, as I don’t think heavy-weight statements should be run as the result of a ternary operator. I also changed to use HTML, as I think it’s fine to include HTML in translations using the placeholders feature, like this:

"optionsContact": {
    "message": "Send bug reports to $MAILTO$.",
    "placeholders": {
        "mailto": {"content": "<a href='mailto:support@foo.com'>support@foo.com</a>"}
    }
}

Not including it in the translations, I would need to make an assumption about sentence structure for all languages.

The code is:

for (const elem of document.querySelectorAll("[data-i18n]")) {
    let [stub, attr] = elem.dataset.i18n.split("|", 2);
    stub = stub.split("?");
    let text = browser.i18n.getMessage(stub[0], stub.slice(1));

    if (attr)
        elem[attr] = text;
    else
        elem.insertAdjacentHTML("beforeend", text);
}

I also extended that idea to include number localisation with the # character. So, you can just add a # to the key to indicate that any numbers should be localised e.g. “days#?3”, or to localise a number without any translations, you can do “#123”. The final code is:

function translate() {
    var num_format = new Intl.NumberFormat();
    for (const elem of document.querySelectorAll("[data-i18n]")) {
        let [stub, attr] = elem.dataset.i18n.split("|", 2);
        stub = stub.split("?");
        let [key, format_num] = stub[0].split("#");

        let text;
        if (key)
            text = browser.i18n.getMessage(key, stub.slice(1));
        else
            text = format_num;

        if (format_num !== undefined)
            text = text.replace(/\d+/g, num_format.format);

        if (attr)
            elem[attr] = text;
        else
            elem.insertAdjacentHTML("beforeend", text);
    }
}
document.addEventListener("DOMContentLoaded", translate);

I think that now handles all the use cases I’ve encountered, and is still fairly concise.

There is no need for all that extra code/functions/overheads

Please note that the concept is HTML internationalization (which is static).
The dynamic string internationalization and substitutions are handled in the script part (which is not the subject of this discussion).

BTW… we are going a bit off-topic :wink:

But, without that extra code, it does not handle all the things in the HTML options page I am trying to translate.

Numbers are still static, and should be localised.

Substitutions avoid the translator needing to translate a dozen identical strings.

In the options page I am translating, I have a set of hard-coded options in a select, which look like this (with my new translation format):

<select>
    <option value="1" data-i18n="1day#"></option>
    <option value="2" data-i18n="ndays#?2"></option>
    <option value="3" data-i18n="ndays#?3"></option>
    <option value="4" data-i18n="ndays#?4"></option>
    <option value="5" data-i18n="ndays#?5"></option>
    <option value="6" data-i18n="ndays#?6"></option>
    ...
</select>

You seem to be suggesting that I should just have each of these as different messages, and require translators to translate the same string again and again, each with a different number in them.

I have no idea how your option looks but this is a possible approach:

<select>
    <option value="1" data-i18n="day">1 </option>
    <option value="2" data-i18n="ndays">2 </option>
    <option value="3" data-i18n="ndays">3 </option>
    <option value="4" data-i18n="ndays">4 </option>
    <option value="5" data-i18n="ndays">5 </option>
    <option value="6" data-i18n="ndays">6 </option>
    ...
</select>

Nonetheless, developers are free to chose the method that suits them best. :+1:

The problem I see with that, is that you are making the assumption that the word for days should always come after the number etc. By using a substitution in the translation, you give more control to the translator to decide the best way to display in their language.

There are also cases where I need to append a character after the translation, and it just feels like it’s getting messy when I have to start using some spans to handle that, in addition to the translator problems.

But, either way, I think we’ve got a good starting point for a recommended approach to translating an options page, and people can always build off it as needed.

Love the code. I am using it in my addon.

One thing missing from the github repository is a license (I know, only 5 lines of code),

Is it safe to assume that it is a Mozilla Public License 2.0 like your addons?

yes… Mozilla Public License 2.0