/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */ /* Copyright 2017 Mozilla Foundation and others * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* fluent-dom@fa25466f (October 12, 2018) */ const { Localization } = ChromeUtils.import("resource://gre/modules/Localization.jsm"); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); // Match the opening angle bracket (<) in HTML tags, and HTML entities like // &, &, &. const reOverlay = /<|&#?\w+;/; /** * Elements allowed in translations even if they are not present in the source * HTML. They are text-level elements as defined by the HTML5 spec: * https://www.w3.org/TR/html5/text-level-semantics.html with the exception of: * * - a - because we don't allow href on it anyways, * - ruby, rt, rp - because we don't allow nested elements to be inserted. */ const TEXT_LEVEL_ELEMENTS = { "http://www.w3.org/1999/xhtml": [ "em", "strong", "small", "s", "cite", "q", "dfn", "abbr", "data", "time", "code", "var", "samp", "kbd", "sub", "sup", "i", "b", "u", "mark", "bdi", "bdo", "span", "br", "wbr", ], }; const LOCALIZABLE_ATTRIBUTES = { "http://www.w3.org/1999/xhtml": { global: ["title", "aria-label", "aria-valuetext", "aria-moz-hint"], a: ["download"], area: ["download", "alt"], // value is special-cased in isAttrNameLocalizable input: ["alt", "placeholder"], menuitem: ["label"], menu: ["label"], optgroup: ["label"], option: ["label"], track: ["label"], img: ["alt"], textarea: ["placeholder"], th: ["abbr"], }, "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul": { global: [ "accesskey", "aria-label", "aria-valuetext", "aria-moz-hint", "label", "title", "tooltiptext"], description: ["value"], key: ["key", "keycode"], label: ["value"], textbox: ["placeholder"], }, }; /** * Translate an element. * * Translate the element's text content and attributes. Some HTML markup is * allowed in the translation. The element's children with the data-l10n-name * attribute will be treated as arguments to the translation. If the * translation defines the same children, their attributes and text contents * will be used for translating the matching source child. * * @param {Element} element * @param {Object} translation * @private */ function translateElement(element, translation) { const {value} = translation; if (typeof value === "string") { if (!reOverlay.test(value)) { // If the translation doesn't contain any markup skip the overlay logic. element.textContent = value; } else { // Else parse the translation's HTML using an inert template element, // sanitize it and replace the element's content. const templateElement = element.ownerDocument.createElementNS( "http://www.w3.org/1999/xhtml", "template" ); // eslint-disable-next-line no-unsanitized/property templateElement.innerHTML = value; overlayChildNodes(templateElement.content, element); } } // Even if the translation doesn't define any localizable attributes, run // overlayAttributes to remove any localizable attributes set by previous // translations. overlayAttributes(translation, element); } /** * Replace child nodes of an element with child nodes of another element. * * The contents of the target element will be cleared and fully replaced with * sanitized contents of the source element. * * @param {DocumentFragment} fromFragment - The source of children to overlay. * @param {Element} toElement - The target of the overlay. * @private */ function overlayChildNodes(fromFragment, toElement) { for (const childNode of fromFragment.childNodes) { if (childNode.nodeType === childNode.TEXT_NODE) { // Keep the translated text node. continue; } if (childNode.hasAttribute("data-l10n-name")) { const sanitized = namedChildFrom(toElement, childNode); fromFragment.replaceChild(sanitized, childNode); continue; } if (isElementAllowed(childNode)) { const sanitized = allowedChild(childNode); fromFragment.replaceChild(sanitized, childNode); continue; } console.warn( `An element of forbidden type "${childNode.localName}" was found in ` + "the translation. Only safe text-level elements and elements with " + "data-l10n-name are allowed." ); // If all else fails, replace the element with its text content. fromFragment.replaceChild(textNode(childNode), childNode); } toElement.textContent = ""; toElement.appendChild(fromFragment); } /** * Transplant localizable attributes of an element to another element. * * Any localizable attributes already set on the target element will be * cleared. * * @param {Element|Object} fromElement - The source of child nodes to overlay. * @param {Element} toElement - The target of the overlay. * @private */ function overlayAttributes(fromElement, toElement) { const explicitlyAllowed = toElement.hasAttribute("data-l10n-attrs") ? toElement.getAttribute("data-l10n-attrs") .split(",").map(i => i.trim()) : null; // Remove existing localizable attributes. for (const attr of Array.from(toElement.attributes)) { if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)) { toElement.removeAttribute(attr.name); } } // fromElement might be a {value, attributes} object as returned by // Localization.messageFromBundle. In which case attributes may be null to // save GC cycles. if (!fromElement.attributes) { return; } // Set localizable attributes. for (const attr of Array.from(fromElement.attributes)) { if (isAttrNameLocalizable(attr.name, toElement, explicitlyAllowed)) { toElement.setAttribute(attr.name, attr.value); } } } /** * Sanitize a child element created by the translation. * * Try to find a corresponding child in sourceElement and use it as the base * for the sanitization. This will preserve functional attribtues defined on * the child element in the source HTML. * * @param {Element} sourceElement - The source for data-l10n-name lookups. * @param {Element} translatedChild - The translated child to be sanitized. * @returns {Element} * @private */ function namedChildFrom(sourceElement, translatedChild) { const childName = translatedChild.getAttribute("data-l10n-name"); const sourceChild = sourceElement.querySelector( `[data-l10n-name="${childName}"]` ); if (!sourceChild) { console.warn( `An element named "${childName}" wasn't found in the source.` ); return textNode(translatedChild); } if (sourceChild.localName !== translatedChild.localName) { console.warn( `An element named "${childName}" was found in the translation ` + `but its type ${translatedChild.localName} didn't match the ` + `element found in the source (${sourceChild.localName}).` ); return textNode(translatedChild); } // Remove it from sourceElement so that the translation cannot use // the same reference name again. sourceElement.removeChild(sourceChild); // We can't currently guarantee that a translation won't remove // sourceChild from the element completely, which could break the app if // it relies on an event handler attached to the sourceChild. Let's make // this limitation explicit for now by breaking the identitiy of the // sourceChild by cloning it. This will destroy all event handlers // attached to sourceChild via addEventListener and via on // properties. const clone = sourceChild.cloneNode(false); return shallowPopulateUsing(translatedChild, clone); } /** * Sanitize an allowed element. * * Text-level elements allowed in translations may only use safe attributes * and will have any nested markup stripped to text content. * * @param {Element} element - The element to be sanitized. * @returns {Element} * @private */ function allowedChild(element) { // Start with an empty element of the same type to remove nested children // and non-localizable attributes defined by the translation. const clone = element.ownerDocument.createElement(element.localName); return shallowPopulateUsing(element, clone); } /** * Convert an element to a text node. * * @param {Element} element - The element to be sanitized. * @returns {Node} * @private */ function textNode(element) { return element.ownerDocument.createTextNode(element.textContent); } /** * Check if element is allowed in the translation. * * This method is used by the sanitizer when the translation markup contains * an element which is not present in the source code. * * @param {Element} element * @returns {boolean} * @private */ function isElementAllowed(element) { const allowed = TEXT_LEVEL_ELEMENTS[element.namespaceURI]; return allowed && allowed.includes(element.localName); } /** * Check if attribute is allowed for the given element. * * This method is used by the sanitizer when the translation markup contains * DOM attributes, or when the translation has traits which map to DOM * attributes. * * `explicitlyAllowed` can be passed as a list of attributes explicitly * allowed on this element. * * @param {string} name * @param {Element} element * @param {Array} explicitlyAllowed * @returns {boolean} * @private */ function isAttrNameLocalizable(name, element, explicitlyAllowed = null) { if (explicitlyAllowed && explicitlyAllowed.includes(name)) { return true; } const allowed = LOCALIZABLE_ATTRIBUTES[element.namespaceURI]; if (!allowed) { return false; } const attrName = name.toLowerCase(); const elemName = element.localName; // Is it a globally safe attribute? if (allowed.global.includes(attrName)) { return true; } // Are there no allowed attributes for this element? if (!allowed[elemName]) { return false; } // Is it allowed on this element? if (allowed[elemName].includes(attrName)) { return true; } // Special case for value on HTML inputs with type button, reset, submit if (element.namespaceURI === "http://www.w3.org/1999/xhtml" && elemName === "input" && attrName === "value") { const type = element.type.toLowerCase(); if (type === "submit" || type === "button" || type === "reset") { return true; } } return false; } /** * Helper to set textContent and localizable attributes on an element. * * @param {Element} fromElement * @param {Element} toElement * @returns {Element} * @private */ function shallowPopulateUsing(fromElement, toElement) { toElement.textContent = fromElement.textContent; overlayAttributes(fromElement, toElement); return toElement; } /** * Sanitizes a translation before passing them to Node.localize API. * * It returns `false` if the translation contains DOM Overlays and should * not go into Node.localize. * * Note: There's a third item of work that JS DOM Overlays do - removal * of attributes from the previous translation. * This is not trivial to implement for Node.localize scenario, so * at the moment it is not supported. * * @param {{ * localName: string, * namespaceURI: string, * type: string || null * l10nId: string, * l10nArgs: Array || null, * l10nAttrs: string ||null, * }} l10nItems * @param {{value: string, attrs: Object}} translations * @returns boolean * @private */ function sanitizeTranslationForNodeLocalize(l10nItem, translation) { if (reOverlay.test(translation.value)) { return false; } if (translation.attributes) { const explicitlyAllowed = l10nItem.l10nAttrs === null ? null : l10nItem.l10nAttrs.split(",").map(i => i.trim()); for (const [j, {name}] of translation.attributes.entries()) { if (!isAttrNameLocalizable(name, l10nItem, explicitlyAllowed)) { translation.attributes.splice(j, 1); } } } return true; } const L10NID_ATTR_NAME = "data-l10n-id"; const L10NARGS_ATTR_NAME = "data-l10n-args"; const L10N_ELEMENT_QUERY = `[${L10NID_ATTR_NAME}]`; /** * The `DOMLocalization` class is responsible for fetching resources and * formatting translations. * * It implements the fallback strategy in case of errors encountered during the * formatting of translations and methods for observing DOM * trees with a `MutationObserver`. */ class DOMLocalization extends Localization { /** * @param {Array} resourceIds - List of resource IDs * @param {Function} generateBundles - Function that returns a * generator over FluentBundles * @returns {DOMLocalization} */ constructor(resourceIds, generateBundles) { super(resourceIds, generateBundles); // A Set of DOM trees observed by the `MutationObserver`. this.roots = new Set(); // requestAnimationFrame handler. this.pendingrAF = null; // list of elements pending for translation. this.pendingElements = new Set(); this.windowElement = null; this.mutationObserver = null; this.observerConfig = { attribute: true, characterData: false, childList: true, subtree: true, attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME], }; } onChange(eager = false) { super.onChange(eager); this.translateRoots(); } /** * Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements. * FluentDOM makes use of mutation observers to detect changes * to `data-l10n-*` attributes and translate elements asynchronously. * `setAttributes` is a convenience method which allows to translate * DOM elements declaratively. * * You should always prefer to use `data-l10n-id` on elements (statically in * HTML or dynamically via `setAttributes`) over manually retrieving * translations with `format`. The use of attributes ensures that the * elements can be retranslated when the user changes their language * preferences. * * ```javascript * localization.setAttributes( * document.querySelector('#welcome'), 'hello', { who: 'world' } * ); * ``` * * This will set the following attributes on the `#welcome` element. * The MutationObserver will pick up this change and will localize the element * asynchronously. * * ```html *

*

* ``` * * @param {Element} element - Element to set attributes on * @param {string} id - l10n-id string * @param {Object} args - KVP list of l10n arguments * @returns {Element} */ setAttributes(element, id, args) { element.setAttribute(L10NID_ATTR_NAME, id); if (args) { element.setAttribute(L10NARGS_ATTR_NAME, JSON.stringify(args)); } else { element.removeAttribute(L10NARGS_ATTR_NAME); } return element; } /** * Get the `data-l10n-*` attributes from DOM elements. * * ```javascript * localization.getAttributes( * document.querySelector('#welcome') * ); * // -> { id: 'hello', args: { who: 'world' } } * ``` * * @param {Element} element - HTML element * @returns {{id: string, args: Object}} */ getAttributes(element) { return { id: element.getAttribute(L10NID_ATTR_NAME), args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null), }; } /** * Add `newRoot` to the list of roots managed by this `DOMLocalization`. * * Additionally, if this `DOMLocalization` has an observer, start observing * `newRoot` in order to translate mutations in it. * * @param {Element} newRoot - Root to observe. */ connectRoot(newRoot) { for (const root of this.roots) { if (root === newRoot || root.contains(newRoot) || newRoot.contains(root)) { throw new Error("Cannot add a root that overlaps with existing root."); } } if (this.windowElement) { if (this.windowElement !== newRoot.ownerGlobal) { throw new Error(`Cannot connect a root: DOMLocalization already has a root from a different window.`); } } else { this.windowElement = newRoot.ownerGlobal; this.mutationObserver = new this.windowElement.MutationObserver( mutations => this.translateMutations(mutations) ); } this.roots.add(newRoot); this.mutationObserver.observe(newRoot, this.observerConfig); } /** * Remove `root` from the list of roots managed by this `DOMLocalization`. * * Additionally, if this `DOMLocalization` has an observer, stop observing * `root`. * * Returns `true` if the root was the last one managed by this * `DOMLocalization`. * * @param {Element} root - Root to disconnect. * @returns {boolean} */ disconnectRoot(root) { this.roots.delete(root); // Pause the mutation observer to stop observing `root`. this.pauseObserving(); if (this.roots.size === 0) { this.mutationObserver = null; this.windowElement = null; this.pendingrAF = null; this.pendingElements.clear(); return true; } // Resume observing all other roots. this.resumeObserving(); return false; } /** * Translate all roots associated with this `DOMLocalization`. * * @returns {Promise} */ translateRoots() { if (this.resourceIds.length === 0) { return Promise.resolve(); } const roots = Array.from(this.roots); return Promise.all( roots.map(async root => { // We want to first retranslate the UI, and // then (potentially) flip the directionality. // // This means that the DOM alternations and directionality // are set in the same microtask. await this.translateFragment(root); let primaryLocale = Services.locale.appLocaleAsBCP47; let direction = Services.locale.isAppLocaleRTL ? "rtl" : "ltr"; root.setAttribute("lang", primaryLocale); root.setAttribute(root.namespaceURI === "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" ? "localedir" : "dir", direction); }) ); } /** * Pauses the `MutationObserver`. * * @private */ pauseObserving() { if (!this.mutationObserver) { return; } this.translateMutations(this.mutationObserver.takeRecords()); this.mutationObserver.disconnect(); } /** * Resumes the `MutationObserver`. * * @private */ resumeObserving() { if (!this.mutationObserver) { return; } for (const root of this.roots) { this.mutationObserver.observe(root, this.observerConfig); } } /** * Translate mutations detected by the `MutationObserver`. * * @private */ translateMutations(mutations) { for (const mutation of mutations) { switch (mutation.type) { case "attributes": if (mutation.target.hasAttribute("data-l10n-id")) { this.pendingElements.add(mutation.target); } break; case "childList": for (const addedNode of mutation.addedNodes) { if (addedNode.nodeType === addedNode.ELEMENT_NODE) { if (addedNode.childElementCount) { for (const element of this.getTranslatables(addedNode)) { this.pendingElements.add(element); } } else if (addedNode.hasAttribute(L10NID_ATTR_NAME)) { this.pendingElements.add(addedNode); } } } break; } } // This fragment allows us to coalesce all pending translations // into a single requestAnimationFrame. if (this.pendingElements.size > 0) { if (this.pendingrAF === null) { this.pendingrAF = this.windowElement.requestAnimationFrame(() => { // We need to filter for elements that lost their l10n-id while // waiting for the animation frame. this.translateElements(Array.from(this.pendingElements) .filter(elem => elem.hasAttribute("data-l10n-id"))); this.pendingElements.clear(); this.pendingrAF = null; }); } } } /** * Translate a DOM element or fragment asynchronously using this * `DOMLocalization` object. * * Manually trigger the translation (or re-translation) of a DOM fragment. * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM * with information about which translations to use. * * Returns a `Promise` that gets resolved once the translation is complete. * * @param {DOMFragment} frag - Element or DocumentFragment to be translated * @returns {Promise} */ translateFragment(frag) { if (frag.localize) { // This is a temporary fast-path offered by Gecko to workaround performance // issues coming from Fluent and XBL+Stylo performing unnecesary // operations during startup. // For details see bug 1441037, bug 1442262, and bug 1363862. // A sparse array which will store translations separated out from // all translations that is needed for DOM Overlay. const overlayTranslations = []; const getTranslationsForItems = async l10nItems => { const keys = l10nItems.map( l10nItem => ({id: l10nItem.l10nId, args: l10nItem.l10nArgs})); const translations = await this.formatMessages(keys); // Here we want to separate out elements that require DOM Overlays. // Those elements will have to be translated using our JS // implementation, while everything else is going to use the fast-path. for (const [i, translation] of translations.entries()) { if (translation === undefined) { continue; } const hasOnlyText = sanitizeTranslationForNodeLocalize(l10nItems[i], translation); if (!hasOnlyText) { // Removing from translations to make Node.localize skip it. // We will translate it below using JS DOM Overlays. overlayTranslations[i] = translations[i]; translations[i] = undefined; } } // We pause translation observing here because Node.localize // will translate the whole DOM next, using the `translations`. // // The observer will be resumed after DOM Overlays are localized // in the next microtask. this.pauseObserving(); return translations; }; return frag.localize(getTranslationsForItems.bind(this)) .then(untranslatedElements => { for (let i = 0; i < overlayTranslations.length; i++) { if (overlayTranslations[i] !== undefined && untranslatedElements[i] !== undefined) { translateElement(untranslatedElements[i], overlayTranslations[i]); } } this.resumeObserving(); }) .catch(e => { this.resumeObserving(); throw e; }); } return this.translateElements(this.getTranslatables(frag)); } /** * Translate a list of DOM elements asynchronously using this * `DOMLocalization` object. * * Manually trigger the translation (or re-translation) of a list of elements. * Use the `data-l10n-id` and `data-l10n-args` attributes to mark up the DOM * with information about which translations to use. * * Returns a `Promise` that gets resolved once the translation is complete. * * @param {Array} elements - List of elements to be translated * @returns {Promise} */ async translateElements(elements) { if (!elements.length) { return undefined; } // Remove elements from the pending list since // their translations will get applied below. for (let element of elements) { this.pendingElements.delete(element); } const keys = elements.map(this.getKeysForElement); const translations = await this.formatMessages(keys); return this.applyTranslations(elements, translations); } /** * Applies translations onto elements. * * @param {Array} elements * @param {Array} translations * @private */ applyTranslations(elements, translations) { this.pauseObserving(); for (let i = 0; i < elements.length; i++) { if (translations[i] !== undefined) { translateElement(elements[i], translations[i]); } } this.resumeObserving(); } /** * Collects all translatable child elements of the element. * * @param {Element} element * @returns {Array} * @private */ getTranslatables(element) { const nodes = Array.from(element.querySelectorAll(L10N_ELEMENT_QUERY)); if (typeof element.hasAttribute === "function" && element.hasAttribute(L10NID_ATTR_NAME)) { nodes.push(element); } return nodes; } /** * Get the `data-l10n-*` attributes from DOM elements as a two-element * array. * * @param {Element} element * @returns {Object} * @private */ getKeysForElement(element) { return { id: element.getAttribute(L10NID_ATTR_NAME), args: JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null), }; } } /** * Helper function which allows us to construct a new * DOMLocalization from DocumentL10n. */ var getDOMLocalization = () => new DOMLocalization(); var EXPORTED_SYMBOLS = ["DOMLocalization", "getDOMLocalization"];