Bug 1347799 - Add DOMLocalization module for the new Localization API. r=mossop
authorZibi Braniecki <zbraniecki@mozilla.com>
Fri, 25 Aug 2017 11:16:30 -0700
changeset 378325 4e0649ecbe504a7fcdabcce54f342cd6318e6c04
parent 378324 3f97c8d93c68d532657666a2c228f87a845d4a07
child 378326 c49c61106fe2785de6e90d0df85bff35fd92b14d
push id32428
push userarchaeopteryx@coole-files.de
push dateSat, 02 Sep 2017 08:52:28 +0000
treeherdermozilla-central@b01a7e57425b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop
bugs1347799
milestone57.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1347799 - Add DOMLocalization module for the new Localization API. r=mossop MozReview-Commit-ID: Kw0U6I0E94F
browser/base/content/test/static/browser_all_files_referenced.js
intl/l10n/DOMLocalization.jsm
intl/l10n/moz.build
intl/l10n/test/chrome.ini
intl/l10n/test/dom/test_domloc.xul
intl/l10n/test/dom/test_domloc_connectRoot.html
intl/l10n/test/dom/test_domloc_disconnectRoot.html
intl/l10n/test/dom/test_domloc_getAttributes.html
intl/l10n/test/dom/test_domloc_mutations.html
intl/l10n/test/dom/test_domloc_overlay.html
intl/l10n/test/dom/test_domloc_overlay_missing_children.html
intl/l10n/test/dom/test_domloc_overlay_repeated.html
intl/l10n/test/dom/test_domloc_setAttributes.html
intl/l10n/test/dom/test_domloc_translateElement.html
intl/l10n/test/dom/test_domloc_translateFragment.html
intl/l10n/test/dom/test_domloc_translateRoots.html
intl/l10n/test/test_domlocalization.js
intl/l10n/test/xpcshell.ini
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -118,17 +118,17 @@ var whitelist = [
   // browser/extensions/pdfjs/content/web/viewer.js#7450
   {file: "resource://pdf.js/web/debugger.js"},
 
   // These are used in content processes. They are actually referenced.
   {file: "resource://shield-recipe-client-content/shield-content-frame.js"},
   {file: "resource://shield-recipe-client-content/shield-content-process.js"},
 
   // New L10n API that is not yet used in production
-  {file: "resource://gre/modules/Localization.jsm"},
+  {file: "resource://gre/modules/DOMLocalization.jsm"},
 
   // Starting from here, files in the whitelist are bugs that need fixing.
   // Bug 1339424 (wontfix?)
   {file: "chrome://browser/locale/taskbar.properties",
    platforms: ["linux", "macosx"]},
   // Bug 1316187
   {file: "chrome://global/content/customizeToolbar.xul"},
   // Bug 1343837
new file mode 100644
--- /dev/null
+++ b/intl/l10n/DOMLocalization.jsm
@@ -0,0 +1,574 @@
+/* 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@0.4.1 */
+
+const { Localization } =
+  Components.utils.import("resource://gre/modules/Localization.jsm", {});
+
+// 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)
+    ];
+  }
+}
+
+this.DOMLocalization = DOMLocalization;
+this.EXPORTED_SYMBOLS = ['DOMLocalization'];
--- a/intl/l10n/moz.build
+++ b/intl/l10n/moz.build
@@ -1,15 +1,18 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
+    'DOMLocalization.jsm',
     'L10nRegistry.jsm',
     'Localization.jsm',
     'MessageContext.jsm',
 ]
 
 XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini']
 
+MOCHITEST_CHROME_MANIFESTS += ['test/chrome.ini']
+
 FINAL_LIBRARY = 'xul'
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/chrome.ini
@@ -0,0 +1,12 @@
+[dom/test_domloc_getAttributes.html]
+[dom/test_domloc_setAttributes.html]
+[dom/test_domloc_translateElement.html]
+[dom/test_domloc_translateFragment.html]
+[dom/test_domloc_connectRoot.html]
+[dom/test_domloc_disconnectRoot.html]
+[dom/test_domloc_translateRoots.html]
+[dom/test_domloc_mutations.html]
+[dom/test_domloc_overlay.html]
+[dom/test_domloc_overlay_repeated.html]
+[dom/test_domloc_overlay_missing_children.html]
+[dom/test_domloc.xul]
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc.xul
@@ -0,0 +1,66 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css"
+                 type="text/css"?>
+
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+        title="Testing DOMLocalization in XUL environment">
+
+  <script type="application/javascript"
+          src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+  <script type="application/javascript">
+  <![CDATA[
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * generateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages(`
+file-menu
+    .label = File
+    .accesskey = F
+new-tab
+    .label = New Tab
+    .accesskey = N
+`);
+    yield mc;
+  }
+
+  SimpleTest.waitForExplicitFinish();
+
+
+  const domLoc = new DOMLocalization(
+    window,
+    [],
+    generateMessages
+  );
+
+  async function foo() {
+    domLoc.connectRoot(document);
+    await domLoc.translateRoots();
+
+    is(document.getElementById('file-menu').getAttribute('label'), 'File');
+    is(document.getElementById('file-menu').getAttribute('accesskey'), 'F');
+
+    is(document.getElementById('new-tab').getAttribute('label'), 'New Tab');
+    is(document.getElementById('new-tab').getAttribute('accesskey'), 'N');
+    SimpleTest.finish();
+  }
+
+  window.onload = foo;
+
+  ]]>
+  </script>
+
+  <menubar id="main-menubar">
+    <menu id="file-menu" data-l10n-id="file-menu">
+      <menupopup id="menu_FilePopup">
+        <menuitem id="new-tab" data-l10n-id="new-tab">
+        </menuitem>
+      </menupopup>
+    </menu>
+  </menubar>
+</window>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_connectRoot.html
@@ -0,0 +1,39 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.connectRoot</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+
+    const frag = document.querySelectorAll('div')[0];
+    domLoc.connectRoot(frag);
+
+    is(domLoc.roots.has(frag), true);
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <div>
+  </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_disconnectRoot.html
@@ -0,0 +1,41 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.disconnectRoot</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const frag = document.querySelectorAll('div')[0];
+
+    domLoc.connectRoot(frag);
+    is(domLoc.roots.has(frag), true);
+
+    domLoc.disconnectRoot(frag);
+    is(domLoc.roots.has(frag), false);
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <div>
+  </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_getAttributes.html
@@ -0,0 +1,54 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.getAttributes</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {}
+
+  window.onload = function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const p1 = document.querySelectorAll('p')[0];
+    const p2 = document.querySelectorAll('p')[1];
+    const p3 = document.querySelectorAll('p')[2];
+    const attrs1 = domLoc.getAttributes(p1);
+    const attrs2 = domLoc.getAttributes(p2);
+    const attrs3 = domLoc.getAttributes(p3);
+    isDeeply(attrs1, {
+      id: null,
+      args: null
+    });
+    isDeeply(attrs2, {
+      id: "id1",
+      args: null
+    });
+    isDeeply(attrs3, {
+      id: "id2",
+      args: {
+        userName: "John"
+      }
+    });
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <p />
+  <p data-l10n-id="id1" />
+  <p data-l10n-id="id2" data-l10n-args='{"userName": "John"}' />
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_mutations.html
@@ -0,0 +1,57 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization's MutationObserver</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Hello World');
+    mc.addMessages('title2 = Hello Another World');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const h1 = document.querySelectorAll('h1')[0];
+
+    domLoc.connectRoot(document.body);
+
+    await domLoc.translateRoots();
+
+    is(h1.textContent, "Hello World");
+
+
+    const mo = new MutationObserver(function onMutations(mutations) {
+      is(h1.textContent, "Hello Another World");
+      mo.disconnect();
+      SimpleTest.finish();
+    });
+
+    mo.observe(h1, { childList: true, characterData: true });
+
+    domLoc.setAttributes(h1, 'title2');
+  };
+  </script>
+</head>
+<body>
+  <div>
+    <h1 data-l10n-id="title"></h1>
+  </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_overlay.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization's DOMOverlay functionality</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = <strong>Hello</strong> World');
+    mc.addMessages('title2 = This is <a>a link</a>!');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const p1 = document.querySelectorAll('p')[0];
+    const p2 = document.querySelectorAll('p')[1];
+    const a = p2.querySelector('a');
+    a.addEventListener('click', function() {
+      SimpleTest.finish();
+    });
+
+    await domLoc.translateFragment(document.body);
+
+
+    is(p1.querySelector('strong').textContent, "Hello");
+
+    is(p2.querySelector('a').getAttribute('href'), "http://www.mozilla.org");
+    is(p2.querySelector('a').textContent, "a link");
+
+    a.click();
+  };
+  </script>
+</head>
+<body>
+  <p data-l10n-id="title" />
+  <p data-l10n-id="title2">
+    <a href="http://www.mozilla.org"></a>
+  </p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_overlay_missing_children.html
@@ -0,0 +1,54 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization's DOMOverlay functionality</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Visit <a>Mozilla</a> or <a>Firefox</a> website!');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    await domLoc.translateFragment(document.body);
+
+    const p1 = document.querySelectorAll('p')[0];
+    const linkList = p1.querySelectorAll('a');
+
+
+    is(linkList[0].getAttribute('href'), 'http://www.mozilla.org');
+    is(linkList[0].textContent, 'Mozilla');
+    is(linkList[1].getAttribute('href'), 'http://www.firefox.com');
+    is(linkList[1].textContent, 'Firefox');
+
+    is(linkList.length, 2, "There should be exactly two links in the result.");
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <p data-l10n-id="title">
+    <a href="http://www.mozilla.org"></a>
+    <a href="http://www.firefox.com"></a>
+    <a href="http://www.w3.org"></a>
+  </p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_overlay_repeated.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization's DOMOverlay functionality</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Visit <a>Mozilla</a> or <a>Firefox</a> website!');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    await domLoc.translateFragment(document.body);
+
+    const p1 = document.querySelectorAll('p')[0];
+    const linkList = p1.querySelectorAll('a');
+
+
+    is(linkList[0].getAttribute('href'), 'http://www.mozilla.org');
+    is(linkList[0].textContent, 'Mozilla');
+    is(linkList[1].getAttribute('href'), 'http://www.firefox.com');
+    is(linkList[1].textContent, 'Firefox');
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <p data-l10n-id="title">
+    <a href="http://www.mozilla.org"></a>
+    <a href="http://www.firefox.com"></a>
+  </p>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_setAttributes.html
@@ -0,0 +1,40 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.setAttributes</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {}
+
+  window.onload = function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const p1 = document.querySelectorAll('p')[0];
+
+    domLoc.setAttributes(p1, 'title');
+    is(p1.getAttribute('data-l10n-id'), 'title');
+
+    domLoc.setAttributes(p1, 'title2', {userName: "John"});
+    is(p1.getAttribute('data-l10n-id'), 'title2');
+    is(p1.getAttribute('data-l10n-args'), JSON.stringify({userName: "John"}));
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <p />
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_translateElement.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.translateElement</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Hello World');
+    mc.addMessages('link\n    .title = Click me');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const p1 = document.querySelectorAll('p')[0];
+    const link1 = document.querySelectorAll('a')[0];
+
+    await domLoc.translateElement(p1);
+    is(p1.textContent, "Hello World");
+
+    await domLoc.translateElement(link1);
+    is(link1.getAttribute('title'), "Click me");
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <p data-l10n-id="title" />
+  <a data-l10n-id="link" />
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_translateFragment.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.translateFragment</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Hello World');
+    mc.addMessages('subtitle = Welcome to Fluent');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const frag = document.querySelectorAll('div')[0];
+    const h1 = document.querySelectorAll('h1')[0];
+    const p1 = document.querySelectorAll('p')[0];
+
+    await domLoc.translateFragment(frag);
+    is(h1.textContent, "Hello World");
+    is(p1.textContent, "Welcome to Fluent");
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <div>
+    <h1 data-l10n-id="title" />
+    <p data-l10n-id="subtitle" />
+  </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/dom/test_domloc_translateRoots.html
@@ -0,0 +1,56 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Test DOMLocalization.prototype.translateRoots</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+  <script type="application/javascript">
+  "use strict";
+  const { DOMLocalization } =
+    Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+  const { MessageContext } =
+    Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  function * mockGenerateMessages(locales, resourceIds) {
+    const mc = new MessageContext(locales);
+    mc.addMessages('title = Hello World');
+    mc.addMessages('title2 = Hello Another World');
+    yield mc;
+  }
+
+  window.onload = async function () {
+    SimpleTest.waitForExplicitFinish();
+
+    const domLoc = new DOMLocalization(
+      window,
+      [],
+      mockGenerateMessages
+    );
+
+    const frag1 = document.querySelectorAll('div')[0];
+    const frag2 = document.querySelectorAll('div')[1];
+    const h1 = document.querySelectorAll('h1')[0];
+    const h2 = document.querySelectorAll('h2')[0];
+
+    domLoc.connectRoot(frag1);
+    domLoc.connectRoot(frag2);
+
+    await domLoc.translateRoots();
+
+    is(h1.textContent, "Hello World");
+    is(h2.textContent, "Hello Another World");
+
+    SimpleTest.finish();
+  };
+  </script>
+</head>
+<body>
+  <div>
+    <h1 data-l10n-id="title"></h1>
+  </div>
+  <div>
+    <h2 data-l10n-id="title2"></h2>
+  </div>
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/test_domlocalization.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { DOMLocalization } =
+  Components.utils.import("resource://gre/modules/DOMLocalization.jsm", {});
+
+add_task(function test_methods_presence() {
+  equal(typeof DOMLocalization.prototype.getAttributes, "function");
+  equal(typeof DOMLocalization.prototype.setAttributes, "function");
+  equal(typeof DOMLocalization.prototype.translateElement, "function");
+  equal(typeof DOMLocalization.prototype.translateFragment, "function");
+  equal(typeof DOMLocalization.prototype.connectRoot, "function");
+  equal(typeof DOMLocalization.prototype.disconnectRoot, "function");
+  equal(typeof DOMLocalization.prototype.translateRoots, "function");
+});
--- a/intl/l10n/test/xpcshell.ini
+++ b/intl/l10n/test/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head =
 
+[test_domlocalization.js]
 [test_l10nregistry.js]
 [test_localization.js]
 [test_messagecontext.js]