inside i18n-manager
Tags: webcomponents, webdev, i18n, haxtheweb
Published: 2021-03-03 18:48:23

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()
```js
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 };
    }
  };
};
```
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:
```js
// 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();
```
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:
```js
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
    );
  }
```
This is based directly off of [note-list](https://github.com/RolandDreger/web-components/blob/master/note-list/src/note-list.js#L273-L281) from the [review article](https://dev.to/btopro/reviewing-i18n-solutions-a10). 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](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/i18n-manager/src/i18n-manager.js#L197-L268).
```js
  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]();
            }
          }
        }
      }
    }
  }
```
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:
```js
if (el.updateCallback) {
  el.context[el.updateCallback]();
}
```
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.