/**
* Translates WebExtension's HTML document by attributes.
*
* @public
* @module Localizer
* @requires ./replaceInnerContent
*/
import { replaceInnerContent } from "./replaceInnerContent.js";
const I18N_ATTRIBUTE = "data-i18n";
const I18N_DATASET = "i18n";
const I18N_DATASET_INT = I18N_DATASET.length;
const I18N_DATASET_KEEP_CHILDREN = "optI18nKeepChildren";
const UNIQUE_REPLACEMENT_SPLIT = "$i18nSplit$";
const UNIQUE_REPLACEMENT_ID = "i18nKeepChildren#";
/**
* Splits the _MSG__*__ format and returns the actual tag.
*
* The format is defined in {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/i18n/Locale-Specific_Message_reference#name}.
*
* @private
* @param {string} tag
* @returns {string}
* @throws {Error} if pattern does not match
*/
function getMessageTag(tag) {
/** {@link https://regex101.com/r/LAC5Ib/2} **/
const splitMessage = tag.split(/^__MSG_([\w@]+)__$/);
// throw custom exception if input is invalid
if (splitMessage.length < 2) {
throw new Error(`invalid message tag pattern "${tag}"`);
}
return splitMessage[1];
}
/**
* Converts a dataset value back to a real attribute.
*
* This is intended for substrings of datasets too, i.e. it does not add the "data" prefix
* in front of the attribute.
*
* @private
* @param {string} dataSetValue
* @returns {string}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset#Name_conversion}
*/
function convertDatasetToAttribute(dataSetValue) {
// if beginning of string is capital letter, only lowercase that
/** {@link https://regex101.com/r/GaVoVi/1} **/
dataSetValue = dataSetValue.replace(/^[A-Z]/, (char) => char.toLowerCase());
// replace all other capital letters with dash in front of them
/** {@link https://regex101.com/r/GaVoVi/3} **/
dataSetValue = dataSetValue.replace(/[A-Z]/, (char) => {
return `-${char.toLowerCase()}`;
});
return dataSetValue;
}
/**
* Returns the translated message when a key is given.
*
* @private
* @param {string} messageName
* @param {string[]} substitutions
* @returns {string} translated string
* @throws {Error} if no translation could be found
* @see {@link https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/i18n/getMessage}
*/
function getTranslatedMessage(messageName, substitutions) {
const translatedMessage = browser.i18n.getMessage(messageName, substitutions);
if (!translatedMessage) {
throw new Error(`no translation string for "${messageName}" could be found`);
}
return translatedMessage;
}
/**
* Translates only the text nodes of the element, and adjusts the psition of the
* other HTML elements.
*
* It does this wout inserting HTML just by moving DOM elements and inserting text,
* so it works around potential security problems of innerHtml etc.
*
* @private
* @param {HTMLElement} parent the element to tramslate
* @param {string} translatedMessage the already translated and prepared string
* @param {Object} subsContainer
* @param {string[]} subsContainer.substitutions IDs correspond to number of htmlOnlyChilds
* @param {Node[]} subsContainer.textOnlyChilds
* @param {HTMLElement[]} subsContainer.htmlOnlyChilds
* @param {Node[]} [subsContainer.allChilds] not actually used currently
* @returns {void}
*/
function innerTranslateTextNodes(parent, translatedMessage, subsContainer) {
const splitTranslatedMessage = translatedMessage.split(UNIQUE_REPLACEMENT_SPLIT);
console.log("Replacing text nodes for", parent, ", message:", translatedMessage, "detected elements:", subsContainer);
// sanity check whether all translations were used
// We also trigger for =, because we assume we have at least one text node, which
// is also returned in splitTranslatedMessage
if (splitTranslatedMessage.length <= subsContainer.substitutions.length) {
console.warn(
"You used only", splitTranslatedMessage.length, "message blocks, altghough you could use",
subsContainer.substitutions.length, "substitutions. Possibly you did not include all substitutions in your translation?",
"Check for typos in the placeholder name e.g.",
{
translationObject: splitTranslatedMessage,
intendedSubstitutions: subsContainer.substitutions
}
);
}
// create iterator out of arrays
const textOnlyIterator = subsContainer.textOnlyChilds[Symbol.iterator]();
// for first element, fake the first element as the next element
let previousElement = { nextSibling: parent.fistChild };
for (const message of splitTranslatedMessage) {
// if it is placeholder, replace it with HTML element
if (message.startsWith(UNIQUE_REPLACEMENT_ID)) {
const childId = message.slice(UNIQUE_REPLACEMENT_ID.length);
const child = subsContainer.htmlOnlyChilds[childId - 1];
// move child element in there, *after* the last element = before the next one
const newElement = parent.insertBefore(child, previousElement.nextSibling);
// save last element
previousElement = newElement;
} else {
// otherwise we have a text message, which we need to put into a
// text node
const nextText = textOnlyIterator.next();
const nextTextElement = nextText.value;
// if we have no more text elements
if (nextText.done) {
console.warn("Translation contained more text then HTML template. We now add a note. Triggered for translation: ", message);
// just create & add a new one
const newTextNode = document.createTextNode(message);
// move child element in there, *after* the last element = before the next one
parent.insertBefore(newTextNode, previousElement.nextSibling);
// save last element
previousElement = nextTextElement;
} else {
// replace the next text element
nextTextElement.textContent = message;
// save last element
previousElement = nextTextElement;
}
}
}
}
/**
* Replaces attribute or inner text of element with string.
*
* @private
* @param {HTMLElement} elem
* @param {string} attribute attribute to replace, set to "null" to replace inner content
* @param {string} translatedMessage
* @returns {void}
*/
function replaceWith(elem, attribute, translatedMessage) {
const isHTML = translatedMessage.startsWith("!HTML!");
if (isHTML) {
translatedMessage = translatedMessage.replace("!HTML!", "").trimLeft();
}
switch (attribute) {
case null:
replaceInnerContent(elem, translatedMessage, isHTML);
break;
default:
// attributes are never allowed to contain unbescaped HTML
elem.setAttribute(attribute, translatedMessage);
}
}
/**
* Returns the HTML children..
*
* @private
* @param {HTMLElement} elem
* @returns {void}
*/
function getSubitems(elem) {
// only keep subitems if enabled
if (!(I18N_DATASET_KEEP_CHILDREN in elem.dataset)) {
return {};
}
// always creates arrays to freeze elements, so later DOM changes do not
// affect it
// get all children elements
const childs = Array.from(elem.childNodes);
// filter out text childs
const htmlOnlyChilds = Array.from(elem.children);
const textOnlyChilds = childs.filter((node) => node.nodeType === Node.TEXT_NODE);
// create list of substitutions, i.e. $1, $2, ยง3 etc.
const substitutions = htmlOnlyChilds.map((elem, num) => `${UNIQUE_REPLACEMENT_SPLIT}${UNIQUE_REPLACEMENT_ID}${num + 1}${UNIQUE_REPLACEMENT_SPLIT}`);
return {
substitutions: substitutions,
allChilds: childs,
textOnlyChilds: textOnlyChilds,
htmlOnlyChilds: htmlOnlyChilds
};
}
/**
* Localises the strings to localize in the HTMLElement.
*
* @private
* @param {HTMLElement} elem
* @param {string} tag the translation tag
* @returns {void}
*/
function replaceI18n(elem, tag) {
const subsContainer = getSubitems(elem);
// localize main content
if (tag !== "") {
try {
const translatedMessage = getTranslatedMessage(getMessageTag(tag), subsContainer.substitutions);
// if we have substrings to replace
if (subsContainer.substitutions) {
innerTranslateTextNodes(elem, translatedMessage, subsContainer);
} else {
// otherwise do "usual" full replacement
replaceWith(elem, null, translatedMessage);
}
} catch (error) {
// log error but continue translating as it was likely just one problem in one translation
console.error(error.message, "for element", elem);
}
}
// replace attributes
for (const [dataAttribute, dataValue] of Object.entries(elem.dataset)) {
if (
!dataAttribute.startsWith(I18N_DATASET) || // ignore other data attributes
dataAttribute.length === I18N_DATASET_INT // ignore non-attribute replacements
) {
continue;
}
const replaceAttribute = convertDatasetToAttribute(dataAttribute.slice(I18N_DATASET_INT));
try {
const translatedMessage = getTranslatedMessage(getMessageTag(dataValue));
replaceWith(elem, replaceAttribute, translatedMessage);
} catch (error) {
// log error but continue translating as it was likely just one problem in one translation
console.error(error.message, "for element", elem, "while replacing attribute", replaceAttribute);
}
}
}
/**
* Localizes static strings in the HTML file.
*
* @public
* @returns {void}
*/
export function init() {
document.querySelectorAll(`[${I18N_ATTRIBUTE}]`).forEach((currentElem) => {
const contentString = currentElem.dataset[I18N_DATASET];
replaceI18n(currentElem, contentString);
});
// replace html lang attribut after translation
document.querySelector("html").setAttribute("lang", browser.i18n.getUILanguage());
}
// automatically init module
init();