inside i18n-manager

btopro

Bryan Ollendyke

Posted on March 3, 2021

inside i18n-manager

So we've learned what options exist, what we built as far as DX and now it's into the weeds of how this thing works.

Understanding I18NMixin()

import { I18NManagerStore } from "../i18n-manager.js";
export { I18NManagerStore };
export const I18NMixin = function (SuperClass) {
  return class extends SuperClass {
    constructor() {
      super();
      this.t = {};
    }
    static get properties() {[]
      return {
        ...super.properties,
        t: {
          type: Object,
        },
      };
    }
    pathFromUrl(url) {
      return url.substring(0, url.lastIndexOf("/") + 1);
    }
    // pass through to the manager
    registerTranslation(detail) {
      // ensure we have a namespace for later use
      if (!detail.namespace) {
        detail.namespace = detail.context.tagName.toLowerCase();
      }
      // support fallback calls for requestUpdate (LitElement) and render if nothing set
      if (!detail.updateCallback) {
        if (detail.context.requestUpdate) {
          detail.updateCallback = "requestUpdate";
        } else if (detail.context.render) {
          detail.render = "render";
        }
      }
      // clean up path and force adding locales. part security thing as well
      detail.localesPath = `${this.pathFromUrl(
        decodeURIComponent(detail.basePath)
      )}locales`;
      // register the translation directly
      I18NManagerStore.registerTranslation(detail);
    }
    connectedCallback() {
      super.connectedCallback();
      // store this for later if language is switched back to default
      this._t = { ...this.t };
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

This is the mixin based integration method. Really, all it's doing is ensuring that this.t is set and that it stores the data of this.t on initial load into this._t. This ensures that if the language is toggled from English to Spanish (example), that then we could toggle back to English and still get the original translation / defaults.

registerTranslation we can see is basically just doing some simple checks and normalizing to ensure that we've been passed the namespace (tagName lowercase is default if not supplied) and if no updateCallback is passed in then it attempts to sniff out common LitElement and VanillaJS conventions. *If you implement Vanilla and call your render function somethingElse then you'd have to supply { updateCallback: "somethingElse" } in the original Event driven implementation or else it won't refresh your variables on change.

That's really all there is to it; the real meat is all in the store / singleton.

< i18n-manager >

This is the most common way we make anything a singleton on our team:

// register globally so we can make sure there is only one
window.I18NManagerStore = window.I18NManagerStore || {};
window.I18NManagerStore.requestAvailability = () => {
  if (!window.I18NManagerStore.instance) {
    window.I18NManagerStore.instance = document.createElement("i18n-manager");
    document.body.appendChild(window.I18NManagerStore.instance);
  }
  return window.I18NManagerStore.instance;
};
export const I18NManagerStore = window.I18NManagerStore.requestAvailability();
Enter fullscreen mode Exit fullscreen mode

Then, anyone that calls this file automatically is forcing a single instance into the DOM. Everyone that wants access to the element can then either call I18NManagerStore by import (thus a dependency) or, can account for it for dependency free support via if (window.I18NManagerStore) { let store = window.I18NManagerStore.requestAvailability(); }

These methods ensure only 1 copy of i18n-manager exists in any page, and that we can lazily request access to it in an application of unknown topology. There will only be 1 manager for this regardless of how many elements ask for it.

lang

We obtain default language from the document in the following way:

get documentLang() {
    return (
      document.body.getAttribute("xml:lang") ||
      document.body.getAttribute("lang") ||
      document.documentElement.getAttribute("xml:lang") ||
      document.documentElement.getAttribute("lang") ||
      navigator.language ||
      FALLBACK_LANG
    );
  }
Enter fullscreen mode Exit fullscreen mode

This is based directly off of note-list from the review article. This establishes our default language based typically on <html lang="whatever"> though it can be supplied elsewhere and modified afterwards.

To to general enjoyment of LitElement, I opted for using it in my singleton though I could just do the attributeChangedCallback work directly and might in the future. However, when language attribute / prop is changed, updateLanguage fires and does all the heavy lifting.

  async updateLanguage(lang) {
    if (lang) {
      const langPieces = lang.split("-");
      // get all exact matches as well as partial matches
      const processList = this.elements.filter((el) => {
        return el.locales.includes(lang) || el.locales.includes(langPieces[0]);
      });
      const fallBack = this.elements.filter((el) => {
        return (
          !el.locales.includes(lang) && !el.locales.includes(langPieces[0])
        );
      });
      // no matches found, now we should fallback to defaults in the elements
      if (fallBack.length !== 0) {
        // fallback to documentLanguage
        for (var i in fallBack) {
          let el = fallBack[i];
          // verify we have a context
          if (el.context) {
            el.context.t = { ...el.context._t };
            // support a forced update / function to run when it finishes
            if (el.updateCallback) {
              el.context[el.updateCallback]();
            }
          }
        }
      }
      // run through and match exact matches
      for (var i in processList) {
        let el = processList[i];
        var fetchTarget = "";
        if (el.locales.includes(lang)) {
          fetchTarget = `${el.localesPath}/${el.namespace}.${lang}.json`;
        } else if (el.locales.includes(langPieces[0])) {
          fetchTarget = `${el.localesPath}/${el.namespace}.${langPieces[0]}.json`;
        }
        // see if we had this previous to avoid another request
        if (this.fetchTargets[fetchTarget]) {
          if (el.context) {
            let data = this.fetchTargets[fetchTarget];
            for (var id in data) {
              el.context.t[id] = data[id];
            }
            el.context.t = { ...el.context.t };
            // support a forced update / function to run when it finishes
            if (el.updateCallback) {
              el.context[el.updateCallback]();
            }
          }
        } else {
          // request the json backing, then make JSON and set the associated values
          // @todo catch this if fetch target was previously requested
          this.fetchTargets[fetchTarget] = await fetch(fetchTarget).then(
            (response) => {
              if (response && response.json) return response.json();
              return false;
            }
          );
          if (el.context) {
            for (var id in this.fetchTargets[fetchTarget]) {
              el.context.t[id] = this.fetchTargets[fetchTarget][id];
            }
            el.context.t = { ...el.context.t };
            // support a forced update / function to run when it finishes
            if (el.updateCallback && el.context) {
              el.context[el.updateCallback]();
            }
          }
        }
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Stepping through this code block a bit. First we try and obtain both the language but also the dialect if it exists. This helps us resolve en and en-UK to the same thing if an element does not supply en-UK yet the document is in this (true of other dialects as well).

From there we try to find two groups of elements. this.elements is an array of all the elements that requested to be notified of updates in language. This happened via our CustomEvent for 0 dependency solutions as well as the I18NMixin based method. The two groups are those that match this localization and those that do not.

We need those that match localization for obvious reasons, but to remain "stateful" and true to the active document language, we also need to know what does NOT support this localization. This way we can potentially toggle things from English to Spanish to Japanese (example). Elements that supported Spanish but not Japanese, need to fall back to English as opposed to remaining Spanish (which is what happens if you don't check this list).

After we have these two lists, we force those on the fallback group to default back to the original language (en), while those we found a match on we start loading up the matching json blob. This is then fetch()'ed and the JSON spread across the context's t value. Think of context as the original reference to this in prior examples. This means in each element that supplied a translation we are effectively calling this.t.newText = "Nuevo texto" from the original "New text".

These fetch() statements MUST happen async. fetch() returns a Promise which means we don't know when it's going to return a response, however the function being in an async block means that we can await fetch() in order to ensure that a json blob is returned to the variable prior to proceeding forward in execution. To save on calls to multiple elements we also cache this so that if there are multiple self-check elements we don't spam the server for self-check.jp.json just because we shifted to Japanese.

Lastly, in both lists of elements, after the data has been updated, a callback is executed via:

if (el.updateCallback) {
  el.context[el.updateCallback]();
}
Enter fullscreen mode Exit fullscreen mode

Odd looking as it might be, this is effectively saying this.requestUpdate() in all of our LitElement based elements, while calling this.render() in all our VanillaJS based elements. This call can be modified in how you implement the API but these callbacks force a re-render, now with the this.t variable populated with the new language.

This effectively completes our requirements for the element and i18n because of the following:

  • The singleton ensures 1 thing manages ALL language changes and we can change as a single application
  • those translations can be per element, different per element, and remain unbundled and local to each element we produce
  • things DO NOT need to fully adopt our code base in order to support our i18n methods because of the CustomEvent methodology. Things can opt-in while still maintaining a high level of DX for those using LitElement / not minding dependencies
  • We can translate an unknown number of elements, with unknown language support, successfully in a way that should scale reasonably well

The last post will be a video going on a tour of this working in HAXTheWeb as well as some other elements produced as part of these efforts. The i18n-manager library will be republished with the new version of h-a-x currently in the works. This video will also show off the work in progress toward the next version of HAX.

💖 💪 🙅 🚩
btopro
Bryan Ollendyke

Posted on March 3, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

inside i18n-manager
webcomponents inside i18n-manager

March 3, 2021