зеркало из https://github.com/mozilla/gecko-dev.git
840 строки
26 KiB
JavaScript
840 строки
26 KiB
JavaScript
/* 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", "value"],
|
|
},
|
|
};
|
|
|
|
|
|
/**
|
|
* 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<name>
|
|
// 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<Object> || 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<String>} 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
|
|
* <p id='welcome'
|
|
* data-l10n-id='hello'
|
|
* data-l10n-args='{"who": "world"}'>
|
|
* </p>
|
|
* ```
|
|
*
|
|
* @param {Element} element - Element to set attributes on
|
|
* @param {string} id - l10n-id string
|
|
* @param {Object<string, string>} 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) {
|
|
// Sometimes we connect the root while the document is already in the
|
|
// process of being closed. Bail out gracefully.
|
|
// See bug 1532712 for details.
|
|
if (!newRoot.ownerGlobal) {
|
|
return;
|
|
}
|
|
|
|
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<Element>} 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<Element>} elements
|
|
* @param {Array<Object>} 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<Element>}
|
|
* @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"];
|