Bug 1280670 - Add chrome-privileged HTML support. r?gandalf draft
authorStaś Małolepszy <stas@mozilla.com>
Fri, 07 Oct 2016 19:54:13 +0200
changeset 422229 1295fd288be769676a85c3b4c2c9def633841f28
parent 422228 d151ab07062e783e89e401655dbd6727cf29a01f
child 533281 7d2ebb54b8e24323b6641b856d8d2fa0edfc0425
push id31715
push usersmalolepszy@mozilla.com
push dateFri, 07 Oct 2016 18:04:01 +0000
reviewersgandalf
bugs1280670
milestone52.0a1
Bug 1280670 - Add chrome-privileged HTML support. r?gandalf MozReview-Commit-ID: 2ffxDglCu4L
toolkit/content/jar.mn
toolkit/content/l20n-chrome-html.js
toolkit/content/l20n-chrome-xul.js
--- a/toolkit/content/jar.mn
+++ b/toolkit/content/jar.mn
@@ -52,16 +52,17 @@ toolkit.jar:
    content/global/finddialog.js
 *  content/global/finddialog.xul
    content/global/findUtils.js
 #endif
    content/global/filepicker.properties
    content/global/globalOverlay.js
    content/global/mozilla.xhtml
    content/global/process-content.js
+   content/global/l20n-chrome-html.js
    content/global/l20n-chrome-xul.js
    content/global/l20n-perf-monitor.js
    content/global/resetProfile.css
    content/global/resetProfile.js
    content/global/resetProfile.xul
    content/global/resetProfileProgress.xul
    content/global/select-child.js
    content/global/TopLevelVideoDocument.js
@@ -108,9 +109,9 @@ toolkit.jar:
    content/global/bindings/videocontrols.css   (widgets/videocontrols.css)
 *  content/global/bindings/wizard.xml          (widgets/wizard.xml)
 #ifdef XP_MACOSX
    content/global/macWindowMenu.js
 #endif
    content/global/svg/svgBindings.xml          (/layout/svg/resources/content/svgBindings.xml)
    content/global/gmp-sources/eme-adobe.json   (gmp-sources/eme-adobe.json)
    content/global/gmp-sources/openh264.json    (gmp-sources/openh264.json)
-   content/global/gmp-sources/widevinecdm.json (gmp-sources/widevinecdm.json)
\ No newline at end of file
+   content/global/gmp-sources/widevinecdm.json (gmp-sources/widevinecdm.json)
new file mode 100644
--- /dev/null
+++ b/toolkit/content/l20n-chrome-html.js
@@ -0,0 +1,1383 @@
+{
+
+function getDirection(code) {
+  const tag = code.split('-')[0];
+  return ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(tag) >= 0 ?
+    'rtl' : 'ltr';
+}
+
+const observerConfig = {
+  attributes: true,
+  characterData: false,
+  childList: true,
+  subtree: true,
+  attributeFilter: ['data-l10n-id', 'data-l10n-args', 'data-l10n-bundle']
+};
+
+/**
+ * The `LocalizationObserver` class is responsible for localizing DOM trees.
+ * It also implements the iterable protocol which allows iterating over and
+ * retrieving available `Localization` objects.
+ *
+ * Each `document` will have its corresponding `LocalizationObserver` instance
+ * created automatically on startup, as `document.l10n`.
+ */
+class LocalizationObserver {
+  /**
+   * @returns {LocalizationObserver}
+   */
+  constructor() {
+    this.localizations = new Map();
+    this.roots = new WeakMap();
+    this.observer = new MutationObserver(
+      mutations => this.translateMutations(mutations)
+    );
+  }
+
+  /**
+   * Test if the `Localization` object with a given name already exists.
+   *
+   * ```javascript
+   * if (document.l10n.has('extra')) {
+   *   const extraLocalization = document.l10n.get('extra');
+   * }
+   * ```
+   * @param   {string} name - key for the object
+   * @returns {boolean}
+   */
+  has(name) {
+    return this.localizations.has(name);
+  }
+
+  /**
+   * Retrieve a reference to the `Localization` object by name.
+   *
+   * ```javascript
+   * const mainLocalization = document.l10n.get('main');
+   * const extraLocalization = document.l10n.get('extra');
+   * ```
+   *
+   * @param   {string}        name - key for the object
+   * @returns {Localization}
+   */
+  get(name) {
+    return this.localizations.get(name);
+  }
+
+  /**
+   * Sets a reference to the `Localization` object by name.
+   *
+   * ```javascript
+   * const loc = new Localization();
+   * document.l10n.set('extra', loc);
+   * ```
+   *
+   * @param   {string}       name - key for the object
+   * @param   {Localization} value - `Localization` object
+   * @returns {LocalizationObserver}
+   */
+  set(name, value) {
+    this.localizations.set(name, value);
+    return this;
+  }
+
+  *[Symbol.iterator]() {
+    yield* this.localizations;
+  }
+
+  handleEvent() {
+    return this.requestLanguages();
+  }
+
+  /**
+   * Trigger the language negotation process with an array of language codes.
+   * Returns a promise with the negotiated array of language objects as above.
+   *
+   * ```javascript
+   * document.l10n.requestLanguages(['de-DE', 'de', 'en-US']);
+   * ```
+   *
+   * @param   {Array<string>} requestedLangs - array of requested languages
+   * @returns {Promise<Array<string>>}
+   */
+  requestLanguages(requestedLangs) {
+    const localizations = Array.from(this.localizations.values());
+    return Promise.all(
+      localizations.map(l10n => l10n.requestLanguages(requestedLangs))
+    ).then(
+      () => this.translateAllRoots()
+    )
+  }
+
+  /**
+   * Set the `data-l10n-id` and `data-l10n-args` attributes on DOM elements.
+   * L20n 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
+   * document.l10n.setAttributes(
+   *   document.querySelector('#welcome'), 'hello', { who: 'world' }
+   * );
+   * ```
+   *
+   * This will set the following attributes on the `#welcome` element.  L20n's
+   * 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
+   * ```
+   */
+  setAttributes(element, id, args) {
+    element.setAttribute('data-l10n-id', id);
+    if (args) {
+      element.setAttribute('data-l10n-args', JSON.stringify(args));
+    }
+    return element;
+  }
+
+  /**
+   * Get the `data-l10n-*` attributes from DOM elements.
+   *
+   * ```javascript
+   * document.l10n.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('data-l10n-id'),
+      args: JSON.parse(element.getAttribute('data-l10n-args'))
+    };
+  }
+
+  /**
+   * Add a new root to the list of observed ones.
+   *
+   * @param {Element}      root - Root to observe.
+   * @param {Localization} l10n - `Localization` object
+   */
+  observeRoot(root, l10n = this.get('main')) {
+    if (!this.roots.has(l10n)) {
+      this.roots.set(l10n, new Set());
+    }
+    this.roots.get(l10n).add(root);
+    this.observer.observe(root, observerConfig);
+  }
+
+  /**
+   * Remove a root from the list of observed ones.
+   * If the root is the last to be associated with a given `Localization` object
+   * the `Localization` object association will also be removed.
+   *
+   * Returns `true` if the root was the last one associated with at least
+   * one `Localization` object.
+   *
+   * @param   {Element} root - Root to disconnect.
+   * @returns {boolean}
+   */
+  disconnectRoot(root) {
+    let wasLast = false;
+
+    this.pause();
+    for (const [name, l10n] of this.localizations) {
+      const roots = this.roots.get(l10n);
+      if (roots && roots.has(root)) {
+        roots.delete(root);
+        if (roots.size === 0) {
+          wasLast = true;
+          this.localizations.delete(name);
+          this.roots.delete(l10n);
+        }
+      }
+    }
+    this.resume();
+
+    return wasLast;
+  }
+
+  /**
+   * Pauses the `MutationObserver`
+   */
+  pause() {
+    this.observer.disconnect();
+  }
+
+  /**
+   * Resumes the `MutationObserver`
+   */
+  resume() {
+    for (const l10n of this.localizations.values()) {
+      if (this.roots.has(l10n)) {
+        for (const root of this.roots.get(l10n)) {
+          this.observer.observe(root, observerConfig)
+        }
+      }
+    }
+  }
+
+  /**
+   * Triggers translation of all roots associated with the
+   * `LocalizationObserver`.
+   *
+   * Returns a `Promise` which is resolved once all translations are
+   * completed.
+   *
+   * @returns {Promise}
+   */
+  translateAllRoots() {
+    const localizations = Array.from(this.localizations.values());
+    return Promise.all(
+      localizations.map(
+        l10n => this.translateRoots(l10n)
+      )
+    );
+  }
+
+  translateRoots(l10n) {
+    if (!this.roots.has(l10n)) {
+      return Promise.resolve();
+    }
+
+    const roots = Array.from(this.roots.get(l10n));
+    return Promise.all(
+      roots.map(root => this.translateRoot(root, l10n))
+    );
+  }
+
+  translateRoot(root, l10n) {
+    function setLangs() {
+      return l10n.interactive.then(bundles => {
+        const langs = bundles.map(bundle => bundle.lang);
+        const wasLocalizedBefore = root.hasAttribute('langs');
+
+        root.setAttribute('langs', langs.join(' '));
+        root.setAttribute('lang', langs[0]);
+        root.setAttribute('dir', getDirection(langs[0]));
+
+        if (wasLocalizedBefore) {
+          root.dispatchEvent(new CustomEvent('DOMRetranslated', {
+            bubbles: false,
+            cancelable: false,
+          }));
+        }
+      });
+    }
+
+    return this.translateRootContent(root).then(setLangs);
+  }
+
+  translateMutations(mutations) {
+    for (const mutation of mutations) {
+      switch (mutation.type) {
+        case 'attributes':
+          this.translateElement(mutation.target);
+          break;
+        case 'childList':
+          for (const addedNode of mutation.addedNodes) {
+            if (addedNode.nodeType === addedNode.ELEMENT_NODE) {
+              if (addedNode.childElementCount) {
+                this.translateFragment(addedNode);
+              } else if (addedNode.hasAttribute('data-l10n-id')) {
+                this.translateElement(addedNode);
+              }
+            }
+          }
+          break;
+      }
+    }
+  }
+
+  /**
+   * Translate a DOM node or fragment asynchronously.
+   *
+   * You can manually trigger translation (or re-translation) of a DOM fragment
+   * with `translateFragment`.  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 - DOMFragment to be translated
+   * @returns {Promise}
+   */
+  translateFragment(frag) {
+    return Promise.all(
+      this.groupTranslatablesByLocalization(frag).map(
+        elemsWithL10n => this.translateElements(
+          elemsWithL10n[0], elemsWithL10n[1]
+        )
+      )
+    );
+  }
+
+  translateElements(l10n, elements) {
+    if (!elements.length) {
+      return [];
+    }
+
+    const keys = elements.map(this.getKeysForElement);
+    return l10n.formatEntities(keys).then(
+      translations => this.applyTranslations(l10n, elements, translations)
+    );
+  }
+
+  /**
+   * Translates a single DOM node asynchronously.
+   *
+   * Returns a `Promise` that gets resolved once the translation is complete.
+   *
+   * @param   {Element} element - HTML element to be translated
+   * @returns {Promise}
+   */
+  translateElement(element) {
+    const l10n = this.get(element.getAttribute('data-l10n-bundle') || 'main');
+    return l10n.formatEntities([this.getKeysForElement(element)]).then(
+      translations => this.applyTranslations(l10n, [element], translations)
+    );
+  }
+
+  applyTranslations(l10n, elements, translations) {
+    this.pause();
+    for (let i = 0; i < elements.length; i++) {
+      l10n.overlayElement(elements[i], translations[i]);
+    }
+    this.resume();
+  }
+
+  groupTranslatablesByLocalization(frag) {
+    const elemsWithL10n = [];
+    for (const loc of this.localizations) {
+      elemsWithL10n.push(
+        [loc[1], this.getTranslatables(frag, loc[0])]
+      );
+    }
+    return elemsWithL10n;
+  }
+
+  getTranslatables(element, bundleName) {
+    const query = bundleName === 'main' ?
+      '[data-l10n-bundle="main"], [data-l10n-id]:not([data-l10n-bundle])' :
+      `[data-l10n-bundle=${bundleName}]`;
+    const nodes = Array.from(element.querySelectorAll(query));
+
+    if (typeof element.hasAttribute === 'function' &&
+        element.hasAttribute('data-l10n-id')) {
+      const elemBundleName = element.getAttribute('data-l10n-bundle');
+      if (elemBundleName === null || elemBundleName === bundleName) {
+        nodes.push(element);
+      }
+    }
+
+    return nodes;
+  }
+
+  getKeysForElement(element) {
+    return [
+      element.getAttribute('data-l10n-id'),
+      // In XUL documents missing attributes return `''` here which breaks
+      // JSON.parse.  HTML documents return `null`.
+      JSON.parse(element.getAttribute('data-l10n-args') || null)
+    ];
+  }
+}
+
+function markEnd() {
+  performance.mark('l20n: end translateRootContent');
+  performance.measure(
+    'l20n: translateRootContent',
+    'l20n: start translateRootContent',
+    'l20n: end translateRootContent'
+  );
+}
+
+/**
+ * The `ChromeLocalizationObserver` is an extension of a `LocalizationObserver`
+ * class which additionally collects all XBL binding nodes for translation.
+ *
+ * This API is useful for chrome-privileged HTML and XUL in Gecko.
+ */
+class ChromeLocalizationObserver extends LocalizationObserver {
+  translateRootContent(root) {
+    performance.mark('l20n: start translateRootContent');
+    const anonChildren = document.getAnonymousNodes(root);
+    if (!anonChildren) {
+      return this.translateFragment(root).then(markEnd);
+    }
+
+    return Promise.all(
+      [root, ...anonChildren].map(node => this.translateFragment(node))
+    ).then(markEnd);
+  }
+}
+
+/**
+ * An `L10nError` with information about language and entity ID in which
+ * the error happened.
+ */
+class L10nError extends Error {
+  constructor(message, id, lang) {
+    super();
+    this.name = 'L10nError';
+    this.message = message;
+    this.id = id;
+    this.lang = lang;
+  }
+}
+
+/**
+ * @private
+ *
+ * This function is an inner function for `Localization.formatWithFallback`.
+ *
+ * It takes a `MessageContext`, list of l10n-ids and a method to be used for
+ * key resolution (either `valueFromContext` or `entityFromContext`) and
+ * optionally a value returned from `keysFromContext` executed against
+ * another `MessageContext`.
+ *
+ * The idea here is that if the previous `MessageContext` did not resolve
+ * all keys, we're calling this function with the next context to resolve
+ * the remaining ones.
+ *
+ * In the function, we loop oer `keys` and check if we have the `prev`
+ * passed and if it has an error entry for the position we're in.
+ *
+ * If it doesn't, it means that we have a good translation for this key and
+ * we return it. If it does, we'll try to resolve the key using the passed
+ * `MessageContext`.
+ *
+ * In the end, we return an Object with resolved translations, errors and
+ * a boolean indicating if there were any errors found.
+ *
+ * The translations are either strings, if the method is `valueFromContext`
+ * or objects with value and attributes if the method is `entityFromContext`.
+ *
+ * See `Localization.formatWithFallback` for more info on how this is used.
+ *
+ * @param {MessageContext} ctx
+ * @param {Array<string>}  keys
+ * @param {Function}       method
+ * @param {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<string>|Array<{value: string, attrs: Object}>}} prev
+ *
+ * @returns {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<string>|Array<{value: string, attrs: Object}>}}
+ */
+function keysFromContext(method, sanitizeArgs, ctx, keys, prev) {
+  const entityErrors = [];
+  const current = {
+    errors: new Array(keys.length),
+    hasErrors: false
+  };
+
+  current.translations = keys.map((key, i) => {
+    if (prev && !prev.errors[i]) {
+      // Use a previously formatted good value if there were no errors
+      return prev.translations[i];
+    }
+
+    const args = sanitizeArgs(key[1]);
+    const translation = method(ctx, entityErrors, key[0], args);
+    if (entityErrors.length) {
+      current.errors[i] = entityErrors.slice();
+      entityErrors.length = 0;
+      if (!current.hasErrors) {
+        current.hasErrors = true;
+      }
+    }
+
+    return translation;
+  });
+
+  return current;
+}
+
+/**
+ * @private
+ *
+ * This function is passed as a method to `keysFromContext` and resolve
+ * a value of a single L10n Entity using provided `MessageContext`.
+ *
+ * If the function fails to retrieve the entity, it will return an ID of it.
+ * If formatting fails, it will return a partially resolved entity.
+ *
+ * In both cases, an error is being added to the errors array.
+ *
+ * @param   {MessageContext} ctx
+ * @param   {Array<Error>}   errors
+ * @param   {string}         id
+ * @param   {Object}         args
+ * @returns {string}
+ */
+function valueFromContext(ctx, errors, id, args) {
+  const entity = ctx.messages.get(id);
+
+  if (entity === undefined) {
+    errors.push(new L10nError(`Unknown entity: ${id}`));
+    return id;
+  }
+
+  return ctx.format(entity, args, errors);
+}
+
+/**
+ * @private
+ *
+ * This function is passed as a method to `keysFromContext` and resolve
+ * a single L10n Entity using provided `MessageContext`.
+ *
+ * The function will return an object with a value and attributes of the
+ * entity.
+ *
+ * If the function fails to retrieve the entity, the value is set to the ID of
+ * an entity, and attrs to `null`. If formatting fails, it will return
+ * a partially resolved value and attributes.
+ *
+ * In both cases, an error is being added to the errors array.
+ *
+ * @param   {MessageContext} ctx
+ * @param   {Array<Error>}   errors
+ * @param   {String}         id
+ * @param   {Object}         args
+ * @returns {Object}
+ */
+function entityFromContext(ctx, errors, id, args) {
+  const entity = ctx.messages.get(id);
+
+  if (entity === undefined) {
+    errors.push(new L10nError(`Unknown entity: ${id}`));
+    return { value: id, attrs: null };
+  }
+
+  const formatted = {
+    value: ctx.format(entity, args, errors),
+    attrs: null,
+  };
+
+  if (entity.traits) {
+    formatted.attrs = Object.create(null);
+    for (let i = 0, trait; (trait = entity.traits[i]); i++) {
+      const attr = ctx.format(trait, args, errors);
+      if (attr !== null) {
+        formatted.attrs[trait.key.name] = attr;
+      }
+    }
+  }
+
+  return formatted;
+}
+
+const properties = new WeakMap();
+const contexts = new WeakMap();
+
+/**
+ * The `Localization` class is responsible for fetching resources and
+ * formatting translations.
+ *
+ * It implements the fallback strategy in case of errors encountered during the
+ * formatting of translations.
+ *
+ * In HTML and XUL, l20n.js will create an instance of `Localization` for the
+ * default set of `<link rel="localization">` elements.  You can get
+ * a reference to it via:
+ *
+ *     const localization = document.l10n.get('main');
+ *
+ * Different names can be specified via the `name` attribute on the `<link>`
+ * elements.  One `document` can have more than one `Localization` instance,
+ * but one `Localization` instance can only be assigned to a single `document`.
+ *
+ * `HTMLLocalization` and `XULLocalization` extend `Localization` and provide
+ * `document`-specific methods for sanitizing translations containing markup
+ * before they're inserted into the DOM.
+ */
+class Localization {
+
+  /**
+   * Create an instance of the `Localization` class.
+   *
+   * The instance's configuration is provided by two runtime-dependent
+   * functions passed to the constructor.
+   *
+   * The `requestBundles` function takes an array of language codes and returns
+   * a Promise of an array of lazy `ResourceBundle` instances.  The
+   * `Localization` instance will imediately call the `fetch` method of the
+   * first bundle returned by `requestBundles` and may call `fetch` on
+   * subsequent bundles in fallback scenarios.
+   *
+   * The array of bundles is the de-facto current fallback chain of languages
+   * and fetch locations.
+   *
+   * The `createContext` function takes a language code and returns an instance
+   * of `Intl.MessageContext`.  Since it's also provided to the constructor by
+   * the runtime it may pass runtime-specific `functions` to the
+   * `MessageContext` instances it creates.
+   *
+   * @param   {Function}     requestBundles
+   * @param   {Function}     createContext
+   * @returns {Localization}
+   */
+  constructor(requestBundles, createContext) {
+    const createHeadContext =
+      bundles => createHeadContextWith(createContext, bundles);
+
+    // Keep `requestBundles` and `createHeadContext` private.
+    properties.set(this, {
+      requestBundles, createHeadContext
+    });
+
+    /**
+     * A Promise which resolves when the `Localization` instance has fetched
+     * and parsed all localization resources in the user's first preferred
+     * language (if available).
+     *
+     *     localization.interactive.then(callback);
+     */
+    this.interactive = requestBundles().then(
+      // Create a `MessageContext` for the first bundle right away.
+      bundles => createHeadContext(bundles).then(
+        // Force `this.interactive` to resolve to the list of bundles.
+        () => bundles
+      )
+    );
+  }
+
+  /**
+   * Initiate the change of the currently negotiated languages.
+   *
+   * `requestLanguages` takes an array of language codes representing user's
+   * updated language preferences.
+   *
+   * @param   {Array<string>}     requestedLangs
+   * @returns {Promise<Array<ResourceBundle>>}
+   */
+  requestLanguages(requestedLangs) {
+    const { requestBundles, createHeadContext } = properties.get(this);
+
+    // Assign to `this.interactive` to make all translations requested after
+    // the language change request come from the new fallback chain.
+    return this.interactive = Promise.all(
+      // Get the current bundles to be able to compare them to the new result
+      // of the language negotiation.
+      [this.interactive, requestBundles(requestedLangs)]
+    ).then(([oldBundles, newBundles]) => {
+      if (equal(oldBundles, newBundles)) {
+        return oldBundles;
+      }
+
+      return createHeadContext(newBundles).then(
+        () => newBundles
+      )
+    });
+  }
+
+  /**
+   * Format translations and handle fallback if needed.
+   *
+   * Format translations for `keys` from `MessageContext` instances
+   * corresponding to the current bundles.  In case of errors, fetch the next
+   * bundle in the fallback chain, create a context for it, and recursively
+   * call `formatWithFallback` again.
+   *
+   * @param   {Array<ResourceBundle>} bundles - Current bundles.
+   * @param   {Array<Array>}          keys    - Translation keys to format.
+   * @param   {Function}              method  - Formatting function.
+   * @param   {Array<string>}         [prev]  - Previous translations.
+   * @returns {Array<string> | Promise<Array<string>>}
+   * @private
+   */
+  formatWithFallback(bundles, ctx, keys, method, prev) {
+    // If a context for the head bundle doesn't exist we've reached the last
+    // bundle in the fallback chain.  This is the end condition which returns
+    // the translations formatted during the previous (recursive) calls to
+    // `formatWithFallback`.
+    if (!ctx && prev) {
+      return prev.translations;
+    }
+
+    const current = method(ctx, keys, prev);
+
+    // `hasErrors` is a flag set by `keysFromContext` to notify about errors
+    // during the formatting.  We can't just check the `length` of the `errors`
+    // property because it is fixed and equal to the length of `keys`.
+    if (!current.hasErrors) {
+      return current.translations;
+    }
+
+    // In Gecko `console` needs to imported explicitly.
+    if (typeof console !== 'undefined') {
+      // The `errors` property is an array of arrays, each containing all
+      // errors encountered for the translation at the same position in `keys`.
+      // If there were no errors for a given translation, `errors` will contain
+      // an `undefined` instead of the array of errors.  Most translations are
+      // simple string which don't produce errors.
+      current.errors.forEach(
+        errs => errs ? errs.forEach(
+          e => console.warn(e) // eslint-disable-line no-console
+        ) : null
+      );
+    }
+
+    // At this point we need to fetch the next bundle in the fallback chain and
+    // create a `MessageContext` instance for it.
+    const tailBundles = bundles.slice(1);
+    const { createHeadContext } = properties.get(this);
+
+    return createHeadContext(tailBundles).then(
+      next => this.formatWithFallback(
+        tailBundles, next, keys, method, current
+      )
+    );
+  }
+
+  /**
+   * Format translations into {value, attrs} objects.
+   *
+   * This is an internal method used by `LocalizationObserver` instances.  The
+   * fallback logic is the same as in `formatValues` but the argument type is
+   * stricter (an array of arrays) and it returns {value, attrs} objects which
+   * are suitable for the translation of DOM elements.
+   *
+   *     document.l10n.formatEntities([j
+   *       ['hello', { who: 'Mary' }],
+   *       ['welcome', undefined]
+   *     ]).then(console.log);
+   *
+   *     // [
+   *     //   { value: 'Hello, Mary!', attrs: null },
+   *     //   { value: 'Welcome!', attrs: { title: 'Hello' } }
+   *     // ]
+   *
+   * Returns a Promise resolving to an array of the translation strings.
+   *
+   * @param   {Array<Array>} keys
+   * @returns {Promise<Array<{value: string, attrs: Object}>>}
+   * @private
+   */
+  formatEntities(keys) {
+    return this.interactive.then(
+      bundles => this.formatWithFallback(
+        bundles, contexts.get(bundles[0]), keys, entitiesFromContext
+      )
+    );
+  }
+
+  /**
+   * Retrieve translations corresponding to the passed keys.
+   *
+   * A generalized version of `Localization.formatValue`.  Keys can either be
+   * simple string identifiers or `[id, args]` arrays.
+   *
+   *     document.l10n.formatValues(
+   *       ['hello', { who: 'Mary' }],
+   *       ['hello', { who: 'John' }],
+   *       'welcome'
+   *     ).then(console.log);
+   *
+   *     // ['Hello, Mary!', 'Hello, John!', 'Welcome!']
+   *
+   * Returns a Promise resolving to an array of the translation strings.
+   *
+   * @param   {...(Array | string)} keys
+   * @returns {Promise<Array<string>>}
+   */
+  formatValues(...keys) {
+    // Convert string keys into arrays that `formatWithFallback` expects.
+    const keyTuples = keys.map(
+      key => Array.isArray(key) ? key : [key, null]
+    );
+    return this.interactive.then(
+      bundles => this.formatWithFallback(
+        bundles, contexts.get(bundles[0]), keyTuples, valuesFromContext
+      )
+    );
+  }
+
+  /**
+   * Retrieve the translation corresponding to the `id` identifier.
+   *
+   * If passed, `args` is a simple hash object with a list of variables that
+   * will be interpolated in the value of the translation.
+   *
+   *     localization.formatValue(
+   *       'hello', { who: 'world' }
+   *     ).then(console.log);
+   *
+   *     // 'Hello, world!'
+   *
+   * Returns a Promise resolving to the translation string.
+   *
+   * Use this sparingly for one-off messages which don't need to be
+   * retranslated when the user changes their language preferences, e.g. in
+   * notifications.
+   *
+   * @param   {string}  id     - Identifier of the translation to format
+   * @param   {Object}  [args] - Optional external arguments
+   * @returns {Promise<string>}
+   */
+  formatValue(id, args) {
+    return this.formatValues([id, args]).then(
+      ([val]) => val
+    );
+  }
+
+}
+
+/**
+ * Create a `MessageContext` for the first bundle in the fallback chain.
+ *
+ * Fetches the bundle's resources and creates a context from them.
+ *
+ * @param   {Array<ResourceBundle>} bundle
+ * @param   {Function}              createContext
+ * @returns {Promise<MessageContext>}
+ * @private
+ */
+function createHeadContextWith(createContext, bundles) {
+  const [bundle] = bundles;
+
+  if (!bundle) {
+    return Promise.resolve(null);
+  }
+
+  return bundle.fetch().then(resources => {
+    const ctx = createContext(bundle.lang);
+    resources
+      // Filter out resources which failed to load correctly (e.g. 404).
+      .filter(res => res !== null)
+      .forEach(res => ctx.addMessages(res));
+    // Save the reference to the context.
+    contexts.set(bundle, ctx);
+    return ctx;
+  });
+}
+
+/**
+ *
+ * Test if two fallback chains are functionally the same.
+ *
+ * @param   {Array<ResourceBundle>} bundles1
+ * @param   {Array<ResourceBundle>} bundles2
+ * @returns {boolean}
+ * @private
+ */
+function equal(bundles1, bundles2) {
+  return bundles1.length === bundles2.length &&
+    bundles1.every(({lang}, i) => lang === bundles2[i].lang);
+}
+
+// A regexp to sanitize HTML tags and entities.
+const reHtml = /[&<>]/g;
+const htmlEntities = {
+  '&': '&amp;',
+  '<': '&lt;',
+  '>': '&gt;',
+};
+
+// Unicode bidi isolation characters.
+const FSI = '\u2068';
+const PDI = '\u2069';
+
+/**
+ * Sanitize string-typed arguments.
+ *
+ * Escape HTML tags and entities and wrap values in the Unicode Isolation Marks
+ * (FSI and PDI) to ensure the proper directionality of the interpolated text.
+ *
+ * @param   {Object} args
+ * @returns {Object}
+ * @private
+ */
+function sanitizeArgs(args) {
+  for (const name in args) {
+    const arg = args[name];
+    if (typeof arg === 'string') {
+      const value = arg.replace(reHtml, match => htmlEntities[match]);
+      args[name] = `${FSI}${value}${PDI}`;
+    }
+  }
+  return args;
+}
+
+/**
+ * A bound version of `keysFromContext` using `entityFromContext`.
+ *
+ * @param {MessageContext} ctx
+ * @param {Array<Array>}   keys
+ * @param {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<{value: string, attrs: Object}>
+ * }} prev
+ * @returns {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<{value: string, attrs: Object}>
+ * }}
+ * @private
+ */
+function entitiesFromContext(ctx, keys, prev) {
+  return keysFromContext(entityFromContext, sanitizeArgs, ctx, keys, prev);
+}
+
+/**
+ * A bound version of `keysFromContext` using `valueFromContext`.
+ *
+ * @param {MessageContext} ctx
+ * @param {Array<Array>}   keys
+ * @param {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<string>}} prev
+ * @returns {{
+ *   errors: Array<Error>,
+ *   hasErrors: boolean,
+ *   translations: Array<string>}}
+ * @private
+ */
+function valuesFromContext(ctx, keys, prev) {
+  return keysFromContext(valueFromContext, sanitizeArgs, ctx, keys, prev);
+}
+
+// Match the opening angle bracket (<) in HTML tags, and HTML entities like
+// &amp;, &#0038;, &#x0026;.
+const reOverlay = /<|&#?\w+;/;
+
+/**
+ * Overlay translation onto a DOM element.
+ *
+ * @param   {Localization} l10n
+ * @param   {Element}      element
+ * @param   {string}       translation
+ * @private
+ */
+function overlayElement(l10n, element, translation) {
+  const value = translation.value;
+
+  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 start with an inert template element and move its children into
+      // `element` but such that `element`'s own children are not replaced.
+      const tmpl = element.ownerDocument.createElementNS(
+        'http://www.w3.org/1999/xhtml', 'template');
+      tmpl.innerHTML = value;
+      // Overlay the node with the DocumentFragment.
+      overlay(l10n, element, tmpl.content);
+    }
+  }
+
+  for (const key in translation.attrs) {
+    if (l10n.isAttrAllowed({ name: key }, element)) {
+      element.setAttribute(key, translation.attrs[key]);
+    }
+  }
+}
+
+// The goal of overlay is to move the children of `translationElement`
+// into `sourceElement` such that `sourceElement`'s own children are not
+// replaced, but only have their text nodes and their attributes modified.
+//
+// We want to make it possible for localizers to apply text-level semantics to
+// the translations and make use of HTML entities. At the same time, we
+// don't trust translations so we need to filter unsafe elements and
+// attributes out and we don't want to break the Web by replacing elements to
+// which third-party code might have created references (e.g. two-way
+// bindings in MVC frameworks).
+function overlay(l10n, sourceElement, translationElement) {
+  const result = translationElement.ownerDocument.createDocumentFragment();
+  let k, attr;
+
+  // Take one node from translationElement at a time and check it against
+  // the allowed list or try to match it with a corresponding element
+  // in the source.
+  let childElement;
+  while ((childElement = translationElement.childNodes[0])) {
+    translationElement.removeChild(childElement);
+
+    if (childElement.nodeType === childElement.TEXT_NODE) {
+      result.appendChild(childElement);
+      continue;
+    }
+
+    const index = getIndexOfType(childElement);
+    const sourceChild = getNthElementOfType(sourceElement, childElement, index);
+    if (sourceChild) {
+      // There is a corresponding element in the source, let's use it.
+      overlay(l10n, sourceChild, childElement);
+      result.appendChild(sourceChild);
+      continue;
+    }
+
+    if (l10n.isElementAllowed(childElement)) {
+      const sanitizedChild = childElement.ownerDocument.createElement(
+        childElement.nodeName);
+      overlay(l10n, sanitizedChild, childElement);
+      result.appendChild(sanitizedChild);
+      continue;
+    }
+
+    // Otherwise just take this child's textContent.
+    result.appendChild(
+      translationElement.ownerDocument.createTextNode(
+        childElement.textContent));
+  }
+
+  // Clear `sourceElement` and append `result` which by this time contains
+  // `sourceElement`'s original children, overlayed with translation.
+  sourceElement.textContent = '';
+  sourceElement.appendChild(result);
+
+  // If we're overlaying a nested element, translate the allowed
+  // attributes; top-level attributes are handled in `overlayElement`.
+  // XXX Attributes previously set here for another language should be
+  // cleared if a new language doesn't use them; https://bugzil.la/922577
+  if (translationElement.attributes) {
+    for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
+      if (l10n.isAttrAllowed(attr, sourceElement)) {
+        sourceElement.setAttribute(attr.name, attr.value);
+      }
+    }
+  }
+}
+
+// Get n-th immediate child of context that is of the same type as element.
+// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
+// 1) :scope is widely supported in more browsers and 2) it works with
+// DocumentFragments.
+function getNthElementOfType(context, element, index) {
+  let nthOfType = 0;
+  for (let i = 0, child; (child = context.children[i]); i++) {
+    if (child.nodeType === child.ELEMENT_NODE &&
+        child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
+      if (nthOfType === index) {
+        return child;
+      }
+      nthOfType++;
+    }
+  }
+  return null;
+}
+
+// Get the index of the element among siblings of the same type.
+function getIndexOfType(element) {
+  let index = 0;
+  let child;
+  while ((child = element.previousElementSibling)) {
+    if (child.tagName === element.tagName) {
+      index++;
+    }
+  }
+  return index;
+}
+
+const ns = 'http://www.w3.org/1999/xhtml';
+
+const allowed = {
+  elements: [
+    'a', 'em', 'strong', 'small', 's', 'cite', 'q', 'dfn', 'abbr', 'data',
+    'time', 'code', 'var', 'samp', 'kbd', 'sub', 'sup', 'i', 'b', 'u',
+    'mark', 'ruby', 'rt', 'rp', 'bdi', 'bdo', 'span', 'br', 'wbr'
+  ],
+  attributes: {
+    global: ['title', 'aria-label', 'aria-valuetext', 'aria-moz-hint'],
+    a: ['download'],
+    area: ['download', 'alt'],
+    // value is special-cased in isAttrAllowed
+    input: ['alt', 'placeholder'],
+    menuitem: ['label'],
+    menu: ['label'],
+    optgroup: ['label'],
+    option: ['label'],
+    track: ['label'],
+    img: ['alt'],
+    textarea: ['placeholder'],
+    th: ['abbr']
+  }
+};
+
+/**
+ * The HTML-specific Localization class.
+ *
+ * @extends Localization
+ *
+ */
+class HTMLLocalization extends Localization {
+  /**
+   * Overlay a DOM element using markup from a translation.
+   *
+   * @param {Element} element
+   * @param {string}  translation
+   * @private
+   */
+  overlayElement(element, translation) {
+    return overlayElement(this, element, translation);
+  }
+
+  /**
+   * Check if element is allowed in this `Localization`'s document namespace.
+   *
+   * 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
+   */
+  isElementAllowed(element) {
+    // XXX The allowed list should be amendable; https://bugzil.la/922573.
+    return allowed.elements.indexOf(element.tagName.toLowerCase()) !== -1;
+  }
+
+  /**
+   * 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.
+   *
+   * @param   {{name: string}} attr
+   * @param   {Element}        element
+   * @returns {boolean}
+   * @private
+   */
+  isAttrAllowed(attr, element) {
+    // Bail if it isn't even an HTML element.
+    if (element.namespaceURI !== ns) {
+      return false;
+    }
+
+    const attrName = attr.name.toLowerCase();
+    const tagName = element.tagName.toLowerCase();
+
+    // Is it a globally safe attribute?
+    if (allowed.attributes.global.indexOf(attrName) !== -1) {
+      return true;
+    }
+
+    // Are there no allowed attributes for this element?
+    if (!allowed.attributes[tagName]) {
+      return false;
+    }
+
+    // Is it allowed on this element?
+    // XXX The allowed list should be amendable; https://bugzil.la/922573
+    if (allowed.attributes[tagName].indexOf(attrName) !== -1) {
+      return true;
+    }
+
+    // Special case for value on inputs with type button, reset, submit
+    if (tagName === 'input' && attrName === 'value') {
+      const type = element.type.toLowerCase();
+      if (type === 'submit' || type === 'button' || type === 'reset') {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+}
+
+class ChromeResourceBundle {
+  constructor(lang, resources) {
+    this.lang = lang;
+    this.loaded = false;
+    this.resources = resources;
+
+    const data = Object.keys(resources).map(
+      resId => resources[resId].data
+    );
+
+    if (data.every(d => d !== null)) {
+      this.loaded = Promise.resolve(data);
+    }
+  }
+
+  fetch() {
+    if (!this.loaded) {
+      this.loaded = Promise.all(
+        Object.keys(this.resources).map(resId => {
+          const { source, locale } = this.resources[resId];
+          return L10nRegistry.fetchResource(source, resId, locale);
+        })
+      );
+    }
+
+    return this.loaded;
+  }
+}
+
+// A document.ready shim
+// https://github.com/whatwg/html/issues/127
+function documentReady() {
+  if (document.readyState !== 'loading') {
+    return Promise.resolve();
+  }
+
+  return new Promise(resolve => {
+    document.addEventListener('readystatechange', function onrsc() {
+      document.removeEventListener('readystatechange', onrsc);
+      resolve();
+    });
+  });
+}
+
+function getResourceLinks(head) {
+  return Array.prototype.map.call(
+    head.querySelectorAll('link[rel="localization"]'),
+    el => [el.getAttribute('href'), el.getAttribute('name') || 'main']
+  ).reduce(
+    (seq, [href, name]) => seq.set(name, (seq.get(name) || []).concat(href)),
+    new Map()
+  );
+}
+
+// create nsIObserver's observe method bound to a LocalizationObserver obs
+function createObserve(obs) {
+  return function observe(subject, topic, data) {
+    switch (topic) {
+      case 'language-registry-update': {
+        this.requestLanguages();
+        return obs.translateRoots(this);
+      }
+      case 'language-registry-incremental': {
+        const { resId, lang, messages } = JSON.parse(data);
+        return this.interactive.then(bundles => {
+          const bundle = bundles[0];
+          if (resId in bundle.resources && bundle.locale === lang) {
+            // just overwrite any existing messages in the first bundle
+            const ctx = contexts.get(bundles[0]);
+            ctx.addMessages(messages);
+            obs.translateRoots(this);
+          }
+          return bundles;
+        });
+      }
+      default: {
+        throw new Error(`Unknown topic: ${topic}`);
+      }
+    }
+  }
+}
+
+Components.utils.import('resource://gre/modules/Services.jsm');
+Components.utils.import('resource://gre/modules/L10nRegistry.jsm');
+Components.utils.import('resource://gre/modules/IntlMessageContext.jsm');
+
+// List of functions passed to `MessageContext` that will be available from
+// within the localization entities.
+//
+// Example use (in FTL):
+//
+// open-settings = {OS() ->
+//   [mac] Open Preferences
+//  *[other] Open Settings
+// }
+const functions = {
+  OS: function() {
+    switch (Services.appinfo.OS) {
+      case 'WINNT':
+        return 'win';
+      case 'Linux':
+        return 'lin';
+      case 'Darwin':
+        return 'mac';
+      case 'Android':
+        return 'android';
+      default:
+        return 'other';
+    }
+  }
+};
+
+// This function is provided to the constructor of `Localization` object and is
+// used to create new `MessageContext` objects for a given `lang` with selected
+// builtin functions.
+function createContext(lang) {
+  return new MessageContext(lang, { functions });
+}
+
+// Following is the initial running code of l20n.js
+
+// We create a new  `ChromeLocalizationObserver` and define an event listener
+// for `languagechange` on it.
+document.l10n = new ChromeLocalizationObserver();
+window.addEventListener('languagechange', document.l10n);
+
+// Next, we collect all l10n resource links, create new `Localization` objects
+// and bind them to the `LocalizationObserver` instance.
+for (const [name, resIds] of getResourceLinks(document.head)) {
+  if (!document.l10n.has(name)) {
+    createLocalization(name, resIds);
+  }
+}
+
+function createLocalization(name, resIds) {
+  // This function is called by `Localization` class to retrieve an array of
+  // `ResourceBundle`s. In chrome-privileged setup we use the `L10nRegistry` to
+  // get this array.
+  function requestBundles(requestedLangs = navigator.languages) {
+    return L10nRegistry.getResources(requestedLangs, resIds).then(
+      ({bundles}) => bundles.map(
+        bundle => new ChromeResourceBundle(bundle.locale, bundle.resources)
+      )
+    );
+  }
+
+  const l10n = new HTMLLocalization(requestBundles, createContext);
+
+  // This creates nsIObserver's observe method bound to `LocalizationObserver`
+  l10n.observe = createObserve(document.l10n);
+
+  // This adds observer handlers on our custom events that will be triggered
+  // when L10nRegistry notifies on resource updates.
+  window.addEventListener('pageshow', () => {
+    Services.obs.addObserver(l10n, 'language-registry-update', false);
+    Services.obs.addObserver(l10n, 'language-registry-incremental', false);
+  });
+
+  window.addEventListener('pagehide', () => {
+    Services.obs.removeObserver(l10n, 'language-registry-update');
+    Services.obs.removeObserver(l10n, 'language-registry-incremental');
+  });
+
+  document.l10n.set(name, l10n);
+
+  if (name === 'main') {
+    // When document is ready, we trigger it's localization and initialize
+    // `MutationObserver` on the root.
+    documentReady().then(() => {
+      const rootElem = document.documentElement;
+      document.l10n.observeRoot(rootElem, l10n);
+      document.l10n.translateRoot(rootElem, l10n);
+    });
+  }
+}
+
+}
--- a/toolkit/content/l20n-chrome-xul.js
+++ b/toolkit/content/l20n-chrome-xul.js
@@ -1259,18 +1259,18 @@ function createObserve(obs) {
     }
   }
 }
 
 Components.utils.import('resource://gre/modules/Services.jsm');
 Components.utils.import('resource://gre/modules/L10nRegistry.jsm');
 Components.utils.import('resource://gre/modules/IntlMessageContext.jsm');
 
-// List of functions passed to `MessageContext` that will be available
-// from within the localization entities.
+// List of functions passed to `MessageContext` that will be available from
+// within the localization entities.
 //
 // Example use (in FTL):
 //
 // open-settings = {OS() ->
 //   [mac] Open Preferences
 //  *[other] Open Settings
 // }
 const functions = {
@@ -1285,73 +1285,72 @@ const functions = {
       case 'Android':
         return 'android';
       default:
         return 'other';
     }
   }
 };
 
-// This function is provided to the constructor of `Localization` object
-// and is used to create new `MessageContext` objects for a given `lang`
-// with selected builtin functions.
-
+// This function is provided to the constructor of `Localization` object and is
+// used to create new `MessageContext` objects for a given `lang` with selected
+// builtin functions.
 function createContext(lang) {
   return new MessageContext(lang, { functions });
 }
 
-// This is the initial running code of l20n.js
-// We create a new  `ChromeLocalizationObserver` and define an event
-// listener for `languagechange` on it.
+// Following is the initial running code of l20n.js
+
+// We create a new  `ChromeLocalizationObserver` and define an event listener
+// for `languagechange` on it.
 document.l10n = new ChromeLocalizationObserver();
 window.addEventListener('languagechange', document.l10n);
 
-// Next, we collect all l10n resource links, create new `Localization`
-// objects and bind them to the `LocalizationObserver` instance.
+// Next, we collect all l10n resource links, create new `Localization` objects
+// and bind them to the `LocalizationObserver` instance.
 for (const [name, resIds] of getResourceLinks(document)) {
   if (!document.l10n.has(name)) {
     createLocalization(name, resIds);
   }
 }
 
 function createLocalization(name, resIds) {
-  // This function is called by `Localization` class to
-  // retrieve `ResourceBundle`.
-  //
-  // In chrome-privileged setup we're using `L10nRegistry` for that.
+  // This function is called by `Localization` class to retrieve an array of
+  // `ResourceBundle`s. In chrome-privileged setup we use the `L10nRegistry` to
+  // get this array.
   function requestBundles(requestedLangs = navigator.languages) {
     return L10nRegistry.getResources(requestedLangs, resIds).then(
       ({bundles}) => bundles.map(
         bundle => new ChromeResourceBundle(bundle.locale, bundle.resources)
       )
     );
   }
 
   const l10n = new XULLocalization(requestBundles, createContext);
 
   // This creates nsIObserver's observe method bound to `LocalizationObserver`
   l10n.observe = createObserve(document.l10n);
 
-  // This adds observer handlers on our custom events that will
-  // be triggered when L10nRegistry notifies on resource updates.
+  // This adds observer handlers on our custom events that will be triggered
+  // when L10nRegistry notifies on resource updates.
   window.addEventListener('load', () => {
     Services.obs.addObserver(l10n, 'language-registry-update', false);
     Services.obs.addObserver(l10n, 'language-registry-incremental', false);
   });
 
   window.addEventListener('unload', () => {
     Services.obs.removeObserver(l10n, 'language-registry-update');
     Services.obs.removeObserver(l10n, 'language-registry-incremental');
   });
 
   document.l10n.set(name, l10n);
 
   if (name === 'main') {
-    // When document is ready, we trigger it's localization and
-    // initialize `MutationObserver` on the root.
+    // When document is ready, we trigger it's localization and initialize
+    // `MutationObserver` on the root.
     XULDocumentReady().then(() => {
       const rootElem = document.documentElement;
       document.l10n.observeRoot(rootElem, l10n);
       document.l10n.translateRoot(rootElem, l10n);
     });
   }
 }