inside i18n-manager
Bryan Ollendyke
Posted on March 3, 2021
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 };
}
};
};
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();
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
);
}
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]();
}
}
}
}
}
}
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]();
}
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 theCustomEvent
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.
Posted on March 3, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.