Bug 1347799 - Add DOMLocalization module for the new Localization API. r?mossop draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Fri, 25 Aug 2017 11:16:30 -0700
changeset 656765 dde5f7507bbd24844688cf18ef048f9eaef3a3ed
parent 656346 04b6be50a2526c7a26a63715f441c47e1aa1f9be
child 729226 c127d8d092e281736c39b82c8cfca86b0cd8158e
push id77306
push userbmo:gandalf@aviary.pl
push dateThu, 31 Aug 2017 16:07:18 +0000
reviewersmossop
bugs1347799
milestone57.0a1
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
@@ -121,17 +121,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]