Bug 1407418: Localize non-privileged content with Fluent. draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Mon, 11 Dec 2017 14:17:25 -0800
changeset 710794 7446554e371409cab9d8d78363960258c4fbece6
parent 710166 fc68e1a602bf81601fe51f94450e7a0a3668499f
child 743651 7d0451f8bcaae30be940d163be9cde0be743033d
push id92899
push userbmo:gandalf@aviary.pl
push dateTue, 12 Dec 2017 00:18:42 +0000
bugs1407418
milestone59.0a1
Bug 1407418: Localize non-privileged content with Fluent. MozReview-Commit-ID: 5y3yKEmKgkT
browser/base/content/aboutRobots.xhtml
browser/base/content/browser.js
browser/locales/en-US/browser/aboutRobots.ftl
intl/l10n/jar.mn
intl/l10n/l10n-framescript.js
intl/l10n/l10n-unpriv.js
--- a/browser/base/content/aboutRobots.xhtml
+++ b/browser/base/content/aboutRobots.xhtml
@@ -50,43 +50,46 @@
         position: absolute;
       }
 
       body[dir=rtl] #icon,
       body[dir=rtl] #errorPageContainer:before {
         transform: scaleX(-1);
       }
     ]]></style>
+
+    <link rel="localization" href="browser/aboutRobots.ftl"/>
+    <script type="text/javascript" src="chrome://global/content/l10n-unpriv.js"></script>
   </head>
 
   <body dir="&locale.dir;">
 
     <!-- PAGE CONTAINER (for styling purposes only) -->
     <div id="errorPageContainer">
 
       <!-- Error Title -->
       <div id="errorTitle">
-        <h1 id="errorTitleText">&robots.errorTitleText;</h1>
+        <h1 id="errorTitleText" data-l10n-id="welcome-header"></h1>
       </div>
 
       <!-- LONG CONTENT (the section most likely to require scrolling) -->
       <div id="errorLongContent">
 
         <!-- Short Description -->
         <div id="errorShortDesc">
-          <p id="errorShortDescText">&robots.errorShortDescText;</p>
+          <p id="errorShortDescText" data-l10n-id="header-desc"></p>
         </div>
 
         <!-- Long Description (Note: See netError.dtd for used XHTML tags) -->
         <div id="errorLongDesc">
           <ul>
-            <li>&robots.errorLongDesc1;</li>
-            <li>&robots.errorLongDesc2;</li>
-            <li>&robots.errorLongDesc3;</li>
-            <li>&robots.errorLongDesc4;</li>
+            <li data-l10n-id="long-desc1"></li>
+            <li data-l10n-id="long-desc2"></li>
+            <li data-l10n-id="long-desc3"></li>
+            <li data-l10n-id="long-desc4"></li>
           </ul>
         </div>
 
         <!-- Short Description -->
         <div id="errorTrailerDesc">
           <p id="errorTrailerDescText">&robots.errorTrailerDescText;</p>
         </div>
 
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -1244,16 +1244,17 @@ var gBrowserInit = {
     CaptivePortalWatcher.init();
     ZoomUI.init(window);
 
     let mm = window.getGroupMessageManager("browsers");
     mm.loadFrameScript("chrome://browser/content/tab-content.js", true);
     mm.loadFrameScript("chrome://browser/content/content.js", true);
     mm.loadFrameScript("chrome://browser/content/content-UITour.js", true);
     mm.loadFrameScript("chrome://global/content/manifestMessages.js", true);
+    mm.loadFrameScript("chrome://global/content/l10n-framescript.js", true);
 
     window.messageManager.addMessageListener("Browser:LoadURI", RedirectLoad);
 
     if (!gMultiProcessBrowser) {
       // There is a Content:Click message manually sent from content.
       Services.els.addSystemEventListener(gBrowser, "click", contentAreaClick, true);
     }
 
new file mode 100644
--- /dev/null
+++ b/browser/locales/en-US/browser/aboutRobots.ftl
@@ -0,0 +1,8 @@
+welcome-header = [FTL] Welcome Humans!
+
+header-desc = [FTL] We have come to visit you in peace and with goodwill!
+
+long-desc1 = [FTL] Robots may not injure a human being or, through inaction, allow a human being to come to harm.
+long-desc2 = [FTL] Robots have seen things you people wouldn’t believe.
+long-desc3 = [FTL] Robots are Your Plastic Pal Who’s Fun To Be With.<Paste>
+long-desc4 = [FTL] Robots have shiny metal posteriors which should not be bitten.
--- a/intl/l10n/jar.mn
+++ b/intl/l10n/jar.mn
@@ -1,2 +1,4 @@
 toolkit.jar:
   content/global/l10n.js
+  content/global/l10n-unpriv.js
+  content/global/l10n-framescript.js
new file mode 100644
--- /dev/null
+++ b/intl/l10n/l10n-framescript.js
@@ -0,0 +1,35 @@
+{
+  const { Localization } =
+    Components.utils.import("resource://gre/modules/Localization.jsm", {});
+
+  const localizations = new Map(); 
+
+  const LocalizeListener = {
+    async handleEvent(event) {
+      let locId = event.detail.resourceIds.join('|');
+
+      let l10n;
+
+      if (localizations.has(locId)) {
+        l10n = localizations.get(locId);
+      } else {
+        l10n = new Localization(event.detail.resourceIds);
+        localizations.set(locId, l10n);
+      }
+
+      let response = event.detail.call === 'formatValues' ? 
+        await l10n.formatValues(event.detail.ids) :
+        await l10n.formatMessages(event.detail.ids);
+
+      let respEvent = new content.document.defaultView.CustomEvent("localizeResponse", {
+        bubbles: true,
+        detail: Components.utils.cloneInto({
+          requestId: event.detail.requestId,
+          response
+        }, content.document.defaultView)
+      });
+      content.document.dispatchEvent(respEvent);
+    }
+  }
+  addEventListener("localize", LocalizeListener, false, true);
+}
new file mode 100644
--- /dev/null
+++ b/intl/l10n/l10n-unpriv.js
@@ -0,0 +1,644 @@
+{
+  class Localization {
+    constructor(resourceIds) {
+      this.resourceIds = resourceIds;
+      this.transactions = new Map();
+
+      document.addEventListener("localizeResponse", this);
+    }
+
+    formatValues(ids) {
+      return this._sendRequest(ids, 'formatValues');
+    }
+
+    formatMessages(ids) {
+      return this._sendRequest(ids, 'formatMessages');
+    }
+
+    async _sendRequest(ids, name) {
+      let requestId = `abf67s`;
+
+      let promise = new Promise((resolve, reject) => {
+        this.transactions.set(requestId, resolve);
+      });
+
+      const event = new CustomEvent("localize", {
+        bubbles: true,
+        detail: {
+          resourceIds: this.resourceIds,
+          call: name,
+          requestId,
+          ids
+        }
+      });
+      document.dispatchEvent(event);
+
+      let translations = await promise;
+      return translations;
+    }
+
+    handleEvent(event) {
+      this.transactions.get(event.detail.requestId)(event.detail.response);
+      this.transactions.delete(event.detail.requestId);
+    }
+  }
+
+  // Match the opening angle bracket (<) in HTML tags, and HTML entities like
+  // &amp;, &#0038;, &#x0026;.
+  const reOverlay = /<|&#?\w+;/;
+
+  /**
+   * The list of elements that are allowed to be inserted into a localization.
+   *
+   * Source: https://www.w3.org/TR/html5/text-level-semantics.html
+   */
+  const ALLOWED_ELEMENTS = {
+    'http://www.w3.org/1999/xhtml': [
+      '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'
+    ],
+  };
+
+  const ALLOWED_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 isAttrNameAllowed
+      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'
+      ],
+      key: ['key', 'keycode'],
+      textbox: ['placeholder'],
+      toolbarbutton: ['tooltiptext'],
+    }
+  };
+
+
+  /**
+   * Overlay translation onto a DOM element.
+   *
+   * @param   {Element} targetElement
+   * @param   {string|Object} translation
+   * @private
+   */
+  function overlayElement(targetElement, 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.
+        targetElement.textContent = value;
+      } else {
+        // Else parse the translation's HTML using an inert template element,
+        // sanitize it and replace the targetElement's content.
+        const templateElement = targetElement.ownerDocument.createElementNS(
+          'http://www.w3.org/1999/xhtml', 'template');
+        templateElement.innerHTML = value;
+        targetElement.appendChild(
+          // The targetElement will be cleared at the end of sanitization.
+          sanitizeUsing(templateElement.content, targetElement)
+        );
+      }
+    }
+
+    if (translation.attrs === null) {
+      return;
+    }
+
+    const explicitlyAllowed = targetElement.hasAttribute('data-l10n-attrs')
+      ? targetElement.getAttribute('data-l10n-attrs')
+        .split(',').map(i => i.trim())
+      : null;
+
+    for (const [name, val] of translation.attrs) {
+      if (isAttrNameAllowed(name, targetElement, explicitlyAllowed)) {
+        targetElement.setAttribute(name, val);
+      }
+    }
+  }
+
+  /**
+   * Sanitize `translationFragment` using `sourceElement` to add functional
+   * HTML attributes to children.  `sourceElement` will have all its child nodes
+   * removed.
+   *
+   * The sanitization is conducted according to the following rules:
+   *
+   *   - Allow text nodes.
+   *   - Replace forbidden children with their textContent.
+   *   - Remove forbidden attributes from allowed children.
+   *
+   * Additionally when a child of the same type is present in `sourceElement` its
+   * attributes will be merged into the translated child.  Whitelisted attributes
+   * of the translated child will then overwrite the ones present in the source.
+   *
+   * The overlay logic is subject to the following limitations:
+   *
+   *   - Children are always cloned.  Event handlers attached to them are lost.
+   *   - Nested HTML in source and in translations is not supported.
+   *   - Multiple children of the same type will be matched in order.
+   *
+   * @param {DocumentFragment} translationFragment
+   * @param {Element} sourceElement
+   * @private
+   */
+  function sanitizeUsing(translationFragment, sourceElement) {
+    // Take one node from translationFragment at a time and check it against
+    // the allowed list or try to match it with a corresponding element
+    // in the source.
+    for (const childNode of translationFragment.childNodes) {
+
+      if (childNode.nodeType === childNode.TEXT_NODE) {
+        continue;
+      }
+
+      // If the child is forbidden just take its textContent.
+      if (!isElementAllowed(childNode)) {
+        const text = translationFragment.ownerDocument.createTextNode(
+          childNode.textContent
+        );
+        translationFragment.replaceChild(text, childNode);
+        continue;
+      }
+
+
+      // If a child of the same type exists in sourceElement, use it as the base
+      // for the resultChild.  This also removes the child from sourceElement.
+      const sourceChild = shiftNamedElement(sourceElement, childNode.localName);
+
+      const mergedChild = sourceChild
+        // Shallow-clone the sourceChild to remove all childNodes.
+        ? sourceChild.cloneNode(false)
+        // Create a fresh element as a way to remove all forbidden attributes.
+        : childNode.ownerDocument.createElement(childNode.localName);
+
+      // Explicitly discard nested HTML by serializing childNode to a TextNode.
+      mergedChild.textContent = childNode.textContent;
+
+      for (const attr of Array.from(childNode.attributes)) {
+        if (isAttrNameAllowed(attr.name, childNode)) {
+          mergedChild.setAttribute(attr.name, attr.value);
+        }
+      }
+
+      translationFragment.replaceChild(mergedChild, childNode);
+    }
+
+    // SourceElement might have been already modified by shiftNamedElement.
+    // Let's clear it to make sure other code doesn't rely on random leftovers.
+    sourceElement.textContent = '';
+
+    return translationFragment;
+  }
+
+  /**
+   * 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 = ALLOWED_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 isAttrNameAllowed(name, element, explicitlyAllowed = null) {
+    if (explicitlyAllowed && explicitlyAllowed.includes(name)) {
+      return true;
+    }
+
+    const allowed = ALLOWED_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;
+  }
+
+  /**
+   * Remove and return the first child of the given type.
+   *
+   * @param {DOMFragment} element
+   * @param {string}      localName
+   * @returns {Element | null}
+   * @private
+   */
+  function shiftNamedElement(element, localName) {
+    for (const child of element.children) {
+      if (child.localName === localName) {
+        element.removeChild(child);
+        return child;
+      }
+    }
+    return null;
+  }
+
+  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 {Window}           windowElement
+     * @param {Array<String>}    resourceIds      - List of resource IDs
+     * @param {Function}         generateMessages - Function that returns a
+     *                                              generator over MessageContexts
+     * @returns {DOMLocalization}
+     */
+    constructor(windowElement, resourceIds, generateMessages) {
+      super(resourceIds, generateMessages);
+
+      // A Set of DOM trees observed by the `MutationObserver`.
+      this.roots = new Set();
+      this.mutationObserver = new windowElement.MutationObserver(
+        mutations => this.translateMutations(mutations)
+      );
+
+      this.observerConfig = {
+        attribute: true,
+        characterData: false,
+        childList: true,
+        subtree: true,
+        attributeFilter: [L10NID_ATTR_NAME, L10NARGS_ATTR_NAME]
+      };
+    }
+
+    onLanguageChange() {
+      super.onLanguageChange();
+      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) {
+      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.');
+        }
+      }
+
+      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 and resume the mutation observer to stop observing `root`.
+      this.pauseObserving();
+      this.resumeObserving();
+
+      return this.roots.size === 0;
+    }
+
+    /**
+     * Translate all roots associated with this `DOMLocalization`.
+     *
+     * @returns {Promise}
+     */
+    translateRoots() {
+      const roots = Array.from(this.roots);
+      return Promise.all(
+        roots.map(root => this.translateFragment(root))
+      );
+    }
+
+    /**
+     * Pauses the `MutationObserver`.
+     *
+     * @private
+     */
+    pauseObserving() {
+      this.translateMutations(this.mutationObserver.takeRecords());
+      this.mutationObserver.disconnect();
+    }
+
+    /**
+     * Resumes the `MutationObserver`.
+     *
+     * @private
+     */
+    resumeObserving() {
+      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':
+            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(L10NID_ATTR_NAME)) {
+                  this.translateElement(addedNode);
+                }
+              }
+            }
+            break;
+        }
+      }
+    }
+
+    /**
+     * 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}
+     */
+    async translateFragment(frag) {
+      const elements = this.getTranslatables(frag);
+      if (!elements.length) {
+        return undefined;
+      }
+
+      const keys = elements.map(this.getKeysForElement);
+      const translations = await this.formatMessages(keys);
+      return this.applyTranslations(elements, translations);
+    }
+
+    /**
+     * Translate a single DOM element asynchronously.
+     *
+     * Returns a `Promise` that gets resolved once the translation is complete.
+     *
+     * @param   {Element} element - HTML element to be translated
+     * @returns {Promise}
+     */
+    async translateElement(element) {
+      const translations =
+        await this.formatMessages([this.getKeysForElement(element)]);
+      return this.applyTranslations([element], 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++) {
+        overlayElement(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 {Array<string, Object>}
+     * @private
+     */
+    getKeysForElement(element) {
+      return [
+        element.getAttribute(L10NID_ATTR_NAME),
+        JSON.parse(element.getAttribute(L10NARGS_ATTR_NAME) || null)
+      ];
+    }
+  }
+
+
+  // l10n.js
+
+  /**
+   * Polyfill for document.ready polyfill.
+   * See: https://github.com/whatwg/html/issues/127 for details.
+   *
+   * @returns {Promise}
+   */
+  function documentReady() {
+    const rs = document.readyState;
+    if (rs === 'interactive' || rs === 'completed') {
+      return Promise.resolve();
+    }
+
+    return new Promise(
+      resolve => document.addEventListener(
+        'readystatechange', resolve, { once: true }
+      )
+    );
+  }
+
+  /**
+   * Scans the `elem` for links with localization resources.
+   *
+   * @param {Element} elem
+   * @returns {Array<string>}
+   */
+  function getResourceLinks(elem) {
+    return Array.from(elem.querySelectorAll('link[rel="localization"]')).map(
+      el => el.getAttribute('href')
+    );
+  }
+
+  const resourceIds = getResourceLinks(document.head || document);
+
+  document.l10n = new DOMLocalization(window, resourceIds);
+
+  // trigger first context to be fetched eagerly
+  // document.l10n.ctxs.touchNext();
+
+  document.l10n.ready = documentReady().then(() => {
+    // document.l10n.registerObservers();
+    // window.addEventListener('unload', () => {
+    //   document.l10n.unregisterObservers();
+    // });
+    document.l10n.connectRoot(document.documentElement);
+    return document.l10n.translateRoots();
+  });
+}