Bug 1308893 - Move the overlay logic into the bindings. r=gandalf f=Pike
authorStaś Małolepszy <stas@mozilla.com>
Mon, 17 Oct 2016 16:18:49 +0200
changeset 428988 e7b1b4df6d8b736516cbd0464abeecbc5a2d9018
parent 428987 4756eb1a2f82a6fb2e7ab5e3708d14aa76816076
child 428989 8c8266b3c23446275dc503366fd739ba3bc4834a
push id33449
push userzbraniecki@mozilla.com
push dateMon, 24 Oct 2016 21:39:41 +0000
reviewersgandalf
bugs1308893
milestone52.0a1
Bug 1308893 - Move the overlay logic into the bindings. r=gandalf f=Pike
toolkit/content/l20n-chrome-html.js
toolkit/content/l20n-chrome-xul.js
--- a/toolkit/content/l20n-chrome-html.js
+++ b/toolkit/content/l20n-chrome-html.js
@@ -1,16 +1,252 @@
 {
 
 function getDirection(code) {
   const tag = code.split('-')[0];
   return ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(tag) >= 0 ?
     'rtl' : 'ltr';
 }
 
+// Match the opening angle bracket (<) in HTML tags, and HTML entities like
+// &amp;, &#0038;, &#x0026;.
+const reOverlay = /<|&#?\w+;/;
+
+// XXX The allowed list should be amendable; https://bugzil.la/922573.
+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 isAttrAllowed
+    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}      element
+ * @param   {string}       translation
+ * @private
+ */
+function overlayElement(element, translation) {
+  const value = translation.value;
+
+  if (typeof value === 'string') {
+    if (!reOverlay.test(value)) {
+      // If the translation doesn't contain any markup skip the overlay logic.
+      element.textContent = value;
+    } else {
+      // Else start with an inert template element and move its children into
+      // `element` but such that `element`'s own children are not replaced.
+      const tmpl = element.ownerDocument.createElementNS(
+        'http://www.w3.org/1999/xhtml', 'template');
+      tmpl.innerHTML = value;
+      // Overlay the node with the DocumentFragment.
+      overlay(element, tmpl.content);
+    }
+  }
+
+  for (const key in translation.attrs) {
+    if (isAttrAllowed({ name: key }, element)) {
+      element.setAttribute(key, translation.attrs[key]);
+    }
+  }
+}
+
+// The goal of overlay is to move the children of `translationElement`
+// into `sourceElement` such that `sourceElement`'s own children are not
+// replaced, but only have their text nodes and their attributes modified.
+//
+// We want to make it possible for localizers to apply text-level semantics to
+// the translations and make use of HTML entities. At the same time, we
+// don't trust translations so we need to filter unsafe elements and
+// attributes out and we don't want to break the Web by replacing elements to
+// which third-party code might have created references (e.g. two-way
+// bindings in MVC frameworks).
+function overlay(sourceElement, translationElement) {
+  const result = translationElement.ownerDocument.createDocumentFragment();
+  let k, attr;
+
+  // Take one node from translationElement at a time and check it against
+  // the allowed list or try to match it with a corresponding element
+  // in the source.
+  let childElement;
+  while ((childElement = translationElement.childNodes[0])) {
+    translationElement.removeChild(childElement);
+
+    if (childElement.nodeType === childElement.TEXT_NODE) {
+      result.appendChild(childElement);
+      continue;
+    }
+
+    const index = getIndexOfType(childElement);
+    const sourceChild = getNthElementOfType(sourceElement, childElement, index);
+    if (sourceChild) {
+      // There is a corresponding element in the source, let's use it.
+      overlay(sourceChild, childElement);
+      result.appendChild(sourceChild);
+      continue;
+    }
+
+    if (isElementAllowed(childElement)) {
+      const sanitizedChild = childElement.ownerDocument.createElement(
+        childElement.nodeName);
+      overlay(sanitizedChild, childElement);
+      result.appendChild(sanitizedChild);
+      continue;
+    }
+
+    // Otherwise just take this child's textContent.
+    result.appendChild(
+      translationElement.ownerDocument.createTextNode(
+        childElement.textContent));
+  }
+
+  // Clear `sourceElement` and append `result` which by this time contains
+  // `sourceElement`'s original children, overlayed with translation.
+  sourceElement.textContent = '';
+  sourceElement.appendChild(result);
+
+  // If we're overlaying a nested element, translate the allowed
+  // attributes; top-level attributes are handled in `overlayElement`.
+  // XXX Attributes previously set here for another language should be
+  // cleared if a new language doesn't use them; https://bugzil.la/922577
+  if (translationElement.attributes) {
+    for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
+      if (isAttrAllowed(attr, sourceElement)) {
+        sourceElement.setAttribute(attr.name, attr.value);
+      }
+    }
+  }
+}
+
+/**
+ * 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];
+  if (!allowed) {
+    return false;
+  }
+
+  return allowed.indexOf(element.tagName.toLowerCase()) !== -1;
+}
+
+/**
+ * Check if attribute is allowed for the given element.
+ *
+ * This method is used by the sanitizer when the translation markup contains
+ * DOM attributes, or when the translation has traits which map to DOM
+ * attributes.
+ *
+ * @param   {{name: string}} attr
+ * @param   {Element}        element
+ * @returns {boolean}
+ * @private
+ */
+function isAttrAllowed(attr, element) {
+  const allowed = ALLOWED_ATTRIBUTES[element.namespaceURI];
+  if (!allowed) {
+    return false;
+  }
+
+  const attrName = attr.name.toLowerCase();
+  const elemName = element.tagName.toLowerCase();
+
+  // Is it a globally safe attribute?
+  if (allowed.global.indexOf(attrName) !== -1) {
+    return true;
+  }
+
+  // Are there no allowed attributes for this element?
+  if (!allowed[elemName]) {
+    return false;
+  }
+
+  // Is it allowed on this element?
+  if (allowed[elemName].indexOf(attrName) !== -1) {
+    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;
+}
+
+// Get n-th immediate child of context that is of the same type as element.
+// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
+// 1) :scope is widely supported in more browsers and 2) it works with
+// DocumentFragments.
+function getNthElementOfType(context, element, index) {
+  let nthOfType = 0;
+  for (let i = 0, child; (child = context.children[i]); i++) {
+    if (child.nodeType === child.ELEMENT_NODE &&
+        child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
+      if (nthOfType === index) {
+        return child;
+      }
+      nthOfType++;
+    }
+  }
+  return null;
+}
+
+// Get the index of the element among siblings of the same type.
+function getIndexOfType(element) {
+  let index = 0;
+  let child;
+  while ((child = element.previousElementSibling)) {
+    if (child.tagName === element.tagName) {
+      index++;
+    }
+  }
+  return index;
+}
+
 const observerConfig = {
   attributes: true,
   characterData: false,
   childList: true,
   subtree: true,
   attributeFilter: ['data-l10n-id', 'data-l10n-args', 'data-l10n-bundle']
 };
 
@@ -332,39 +568,39 @@ class LocalizationObserver {
 
   translateElements(l10n, elements) {
     if (!elements.length) {
       return [];
     }
 
     const keys = elements.map(this.getKeysForElement);
     return l10n.formatEntities(keys).then(
-      translations => this.applyTranslations(l10n, elements, translations)
+      translations => this.applyTranslations(elements, translations)
     );
   }
 
   /**
    * Translates a single DOM node asynchronously.
    *
    * Returns a `Promise` that gets resolved once the translation is complete.
    *
    * @param   {Element} element - HTML element to be translated
    * @returns {Promise}
    */
   translateElement(element) {
     const l10n = this.get(element.getAttribute('data-l10n-bundle') || 'main');
     return l10n.formatEntities([this.getKeysForElement(element)]).then(
-      translations => this.applyTranslations(l10n, [element], translations)
+      translations => this.applyTranslations([element], translations)
     );
   }
 
-  applyTranslations(l10n, elements, translations) {
+  applyTranslations(elements, translations) {
     this.pause();
     for (let i = 0; i < elements.length; i++) {
-      l10n.overlayElement(elements[i], translations[i]);
+      overlayElement(elements[i], translations[i]);
     }
     this.resume();
   }
 
   groupTranslatablesByLocalization(frag) {
     const elemsWithL10n = [];
     for (const loc of this.localizations) {
       elemsWithL10n.push(
@@ -604,20 +840,16 @@ const contexts = new WeakMap();
  * default set of `<link rel="localization">` elements.  You can get
  * a reference to it via:
  *
  *     const localization = document.l10n.get('main');
  *
  * Different names can be specified via the `name` attribute on the `<link>`
  * elements.  One `document` can have more than one `Localization` instance,
  * but one `Localization` instance can only be assigned to a single `document`.
- *
- * `HTMLLocalization` and `XULLocalization` extend `Localization` and provide
- * `document`-specific methods for sanitizing translations containing markup
- * before they're inserted into the DOM.
  */
 class Localization {
 
   /**
    * Create an instance of the `Localization` class.
    *
    * The instance's configuration is provided by two runtime-dependent
    * functions passed to the constructor.
@@ -957,258 +1189,16 @@ function entitiesFromContext(ctx, keys, 
  *   hasErrors: boolean,
  *   translations: Array<string>}}
  * @private
  */
 function valuesFromContext(ctx, keys, prev) {
   return keysFromContext(valueFromContext, sanitizeArgs, ctx, keys, prev);
 }
 
-// Match the opening angle bracket (<) in HTML tags, and HTML entities like
-// &amp;, &#0038;, &#x0026;.
-const reOverlay = /<|&#?\w+;/;
-
-/**
- * Overlay translation onto a DOM element.
- *
- * @param   {Localization} l10n
- * @param   {Element}      element
- * @param   {string}       translation
- * @private
- */
-function overlayElement(l10n, element, translation) {
-  const value = translation.value;
-
-  if (typeof value === 'string') {
-    if (!reOverlay.test(value)) {
-      // If the translation doesn't contain any markup skip the overlay logic.
-      element.textContent = value;
-    } else {
-      // Else start with an inert template element and move its children into
-      // `element` but such that `element`'s own children are not replaced.
-      const tmpl = element.ownerDocument.createElementNS(
-        'http://www.w3.org/1999/xhtml', 'template');
-      tmpl.innerHTML = value;
-      // Overlay the node with the DocumentFragment.
-      overlay(l10n, element, tmpl.content);
-    }
-  }
-
-  for (const key in translation.attrs) {
-    if (l10n.isAttrAllowed({ name: key }, element)) {
-      element.setAttribute(key, translation.attrs[key]);
-    }
-  }
-}
-
-// The goal of overlay is to move the children of `translationElement`
-// into `sourceElement` such that `sourceElement`'s own children are not
-// replaced, but only have their text nodes and their attributes modified.
-//
-// We want to make it possible for localizers to apply text-level semantics to
-// the translations and make use of HTML entities. At the same time, we
-// don't trust translations so we need to filter unsafe elements and
-// attributes out and we don't want to break the Web by replacing elements to
-// which third-party code might have created references (e.g. two-way
-// bindings in MVC frameworks).
-function overlay(l10n, sourceElement, translationElement) {
-  const result = translationElement.ownerDocument.createDocumentFragment();
-  let k, attr;
-
-  // Take one node from translationElement at a time and check it against
-  // the allowed list or try to match it with a corresponding element
-  // in the source.
-  let childElement;
-  while ((childElement = translationElement.childNodes[0])) {
-    translationElement.removeChild(childElement);
-
-    if (childElement.nodeType === childElement.TEXT_NODE) {
-      result.appendChild(childElement);
-      continue;
-    }
-
-    const index = getIndexOfType(childElement);
-    const sourceChild = getNthElementOfType(sourceElement, childElement, index);
-    if (sourceChild) {
-      // There is a corresponding element in the source, let's use it.
-      overlay(l10n, sourceChild, childElement);
-      result.appendChild(sourceChild);
-      continue;
-    }
-
-    if (l10n.isElementAllowed(childElement)) {
-      const sanitizedChild = childElement.ownerDocument.createElement(
-        childElement.nodeName);
-      overlay(l10n, sanitizedChild, childElement);
-      result.appendChild(sanitizedChild);
-      continue;
-    }
-
-    // Otherwise just take this child's textContent.
-    result.appendChild(
-      translationElement.ownerDocument.createTextNode(
-        childElement.textContent));
-  }
-
-  // Clear `sourceElement` and append `result` which by this time contains
-  // `sourceElement`'s original children, overlayed with translation.
-  sourceElement.textContent = '';
-  sourceElement.appendChild(result);
-
-  // If we're overlaying a nested element, translate the allowed
-  // attributes; top-level attributes are handled in `overlayElement`.
-  // XXX Attributes previously set here for another language should be
-  // cleared if a new language doesn't use them; https://bugzil.la/922577
-  if (translationElement.attributes) {
-    for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
-      if (l10n.isAttrAllowed(attr, sourceElement)) {
-        sourceElement.setAttribute(attr.name, attr.value);
-      }
-    }
-  }
-}
-
-// Get n-th immediate child of context that is of the same type as element.
-// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
-// 1) :scope is widely supported in more browsers and 2) it works with
-// DocumentFragments.
-function getNthElementOfType(context, element, index) {
-  let nthOfType = 0;
-  for (let i = 0, child; (child = context.children[i]); i++) {
-    if (child.nodeType === child.ELEMENT_NODE &&
-        child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
-      if (nthOfType === index) {
-        return child;
-      }
-      nthOfType++;
-    }
-  }
-  return null;
-}
-
-// Get the index of the element among siblings of the same type.
-function getIndexOfType(element) {
-  let index = 0;
-  let child;
-  while ((child = element.previousElementSibling)) {
-    if (child.tagName === element.tagName) {
-      index++;
-    }
-  }
-  return index;
-}
-
-const ns = 'http://www.w3.org/1999/xhtml';
-
-const allowed = {
-  elements: [
-    'a', 'em', 'strong', 'small', 's', 'cite', 'q', 'dfn', 'abbr', 'data',
-    'time', 'code', 'var', 'samp', 'kbd', 'sub', 'sup', 'i', 'b', 'u',
-    'mark', 'ruby', 'rt', 'rp', 'bdi', 'bdo', 'span', 'br', 'wbr'
-  ],
-  attributes: {
-    global: ['title', 'aria-label', 'aria-valuetext', 'aria-moz-hint'],
-    a: ['download'],
-    area: ['download', 'alt'],
-    // value is special-cased in isAttrAllowed
-    input: ['alt', 'placeholder'],
-    menuitem: ['label'],
-    menu: ['label'],
-    optgroup: ['label'],
-    option: ['label'],
-    track: ['label'],
-    img: ['alt'],
-    textarea: ['placeholder'],
-    th: ['abbr']
-  }
-};
-
-/**
- * The HTML-specific Localization class.
- *
- * @extends Localization
- *
- */
-class HTMLLocalization extends Localization {
-  /**
-   * Overlay a DOM element using markup from a translation.
-   *
-   * @param {Element} element
-   * @param {string}  translation
-   * @private
-   */
-  overlayElement(element, translation) {
-    return overlayElement(this, element, translation);
-  }
-
-  /**
-   * Check if element is allowed in this `Localization`'s document namespace.
-   *
-   * This method is used by the sanitizer when the translation markup contains
-   * an element which is not present in the source code.
-   *
-   * @param   {Element} element
-   * @returns {boolean}
-   * @private
-   */
-  isElementAllowed(element) {
-    // XXX The allowed list should be amendable; https://bugzil.la/922573.
-    return allowed.elements.indexOf(element.tagName.toLowerCase()) !== -1;
-  }
-
-  /**
-   * Check if attribute is allowed for the given element.
-   *
-   * This method is used by the sanitizer when the translation markup contains
-   * DOM attributes, or when the translation has traits which map to DOM
-   * attributes.
-   *
-   * @param   {{name: string}} attr
-   * @param   {Element}        element
-   * @returns {boolean}
-   * @private
-   */
-  isAttrAllowed(attr, element) {
-    // Bail if it isn't even an HTML element.
-    if (element.namespaceURI !== ns) {
-      return false;
-    }
-
-    const attrName = attr.name.toLowerCase();
-    const tagName = element.tagName.toLowerCase();
-
-    // Is it a globally safe attribute?
-    if (allowed.attributes.global.indexOf(attrName) !== -1) {
-      return true;
-    }
-
-    // Are there no allowed attributes for this element?
-    if (!allowed.attributes[tagName]) {
-      return false;
-    }
-
-    // Is it allowed on this element?
-    // XXX The allowed list should be amendable; https://bugzil.la/922573
-    if (allowed.attributes[tagName].indexOf(attrName) !== -1) {
-      return true;
-    }
-
-    // Special case for value on inputs with type button, reset, submit
-    if (tagName === 'input' && attrName === 'value') {
-      const type = element.type.toLowerCase();
-      if (type === 'submit' || type === 'button' || type === 'reset') {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-}
-
 class ChromeResourceBundle {
   constructor(lang, resources) {
     this.lang = lang;
     this.loaded = false;
     this.resources = resources;
 
     const data = Object.keys(resources).map(
       resId => resources[resId].data
@@ -1317,17 +1307,17 @@ function createLocalization(name, resIds
   function requestBundles(requestedLangs = navigator.languages) {
     return L10nRegistry.getResources(requestedLangs, resIds).then(
       ({bundles}) => bundles.map(
         bundle => new ChromeResourceBundle(bundle.locale, bundle.resources)
       )
     );
   }
 
-  const l10n = new HTMLLocalization(requestBundles, createContext);
+  const l10n = new Localization(requestBundles, createContext);
   document.l10n.set(name, l10n);
 
   if (name === 'main') {
     // When document is ready, we trigger it's localization and initialize
     // `MutationObserver` on the root.
     documentReady().then(() => {
       const rootElem = document.documentElement;
       document.l10n.observeRoot(rootElem, l10n);
--- a/toolkit/content/l20n-chrome-xul.js
+++ b/toolkit/content/l20n-chrome-xul.js
@@ -1,16 +1,252 @@
 {
 
 function getDirection(code) {
   const tag = code.split('-')[0];
   return ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(tag) >= 0 ?
     'rtl' : 'ltr';
 }
 
+// Match the opening angle bracket (<) in HTML tags, and HTML entities like
+// &amp;, &#0038;, &#x0026;.
+const reOverlay = /<|&#?\w+;/;
+
+// XXX The allowed list should be amendable; https://bugzil.la/922573.
+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 isAttrAllowed
+    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}      element
+ * @param   {string}       translation
+ * @private
+ */
+function overlayElement(element, translation) {
+  const value = translation.value;
+
+  if (typeof value === 'string') {
+    if (!reOverlay.test(value)) {
+      // If the translation doesn't contain any markup skip the overlay logic.
+      element.textContent = value;
+    } else {
+      // Else start with an inert template element and move its children into
+      // `element` but such that `element`'s own children are not replaced.
+      const tmpl = element.ownerDocument.createElementNS(
+        'http://www.w3.org/1999/xhtml', 'template');
+      tmpl.innerHTML = value;
+      // Overlay the node with the DocumentFragment.
+      overlay(element, tmpl.content);
+    }
+  }
+
+  for (const key in translation.attrs) {
+    if (isAttrAllowed({ name: key }, element)) {
+      element.setAttribute(key, translation.attrs[key]);
+    }
+  }
+}
+
+// The goal of overlay is to move the children of `translationElement`
+// into `sourceElement` such that `sourceElement`'s own children are not
+// replaced, but only have their text nodes and their attributes modified.
+//
+// We want to make it possible for localizers to apply text-level semantics to
+// the translations and make use of HTML entities. At the same time, we
+// don't trust translations so we need to filter unsafe elements and
+// attributes out and we don't want to break the Web by replacing elements to
+// which third-party code might have created references (e.g. two-way
+// bindings in MVC frameworks).
+function overlay(sourceElement, translationElement) {
+  const result = translationElement.ownerDocument.createDocumentFragment();
+  let k, attr;
+
+  // Take one node from translationElement at a time and check it against
+  // the allowed list or try to match it with a corresponding element
+  // in the source.
+  let childElement;
+  while ((childElement = translationElement.childNodes[0])) {
+    translationElement.removeChild(childElement);
+
+    if (childElement.nodeType === childElement.TEXT_NODE) {
+      result.appendChild(childElement);
+      continue;
+    }
+
+    const index = getIndexOfType(childElement);
+    const sourceChild = getNthElementOfType(sourceElement, childElement, index);
+    if (sourceChild) {
+      // There is a corresponding element in the source, let's use it.
+      overlay(sourceChild, childElement);
+      result.appendChild(sourceChild);
+      continue;
+    }
+
+    if (isElementAllowed(childElement)) {
+      const sanitizedChild = childElement.ownerDocument.createElement(
+        childElement.nodeName);
+      overlay(sanitizedChild, childElement);
+      result.appendChild(sanitizedChild);
+      continue;
+    }
+
+    // Otherwise just take this child's textContent.
+    result.appendChild(
+      translationElement.ownerDocument.createTextNode(
+        childElement.textContent));
+  }
+
+  // Clear `sourceElement` and append `result` which by this time contains
+  // `sourceElement`'s original children, overlayed with translation.
+  sourceElement.textContent = '';
+  sourceElement.appendChild(result);
+
+  // If we're overlaying a nested element, translate the allowed
+  // attributes; top-level attributes are handled in `overlayElement`.
+  // XXX Attributes previously set here for another language should be
+  // cleared if a new language doesn't use them; https://bugzil.la/922577
+  if (translationElement.attributes) {
+    for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
+      if (isAttrAllowed(attr, sourceElement)) {
+        sourceElement.setAttribute(attr.name, attr.value);
+      }
+    }
+  }
+}
+
+/**
+ * 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];
+  if (!allowed) {
+    return false;
+  }
+
+  return allowed.indexOf(element.tagName.toLowerCase()) !== -1;
+}
+
+/**
+ * Check if attribute is allowed for the given element.
+ *
+ * This method is used by the sanitizer when the translation markup contains
+ * DOM attributes, or when the translation has traits which map to DOM
+ * attributes.
+ *
+ * @param   {{name: string}} attr
+ * @param   {Element}        element
+ * @returns {boolean}
+ * @private
+ */
+function isAttrAllowed(attr, element) {
+  const allowed = ALLOWED_ATTRIBUTES[element.namespaceURI];
+  if (!allowed) {
+    return false;
+  }
+
+  const attrName = attr.name.toLowerCase();
+  const elemName = element.tagName.toLowerCase();
+
+  // Is it a globally safe attribute?
+  if (allowed.global.indexOf(attrName) !== -1) {
+    return true;
+  }
+
+  // Are there no allowed attributes for this element?
+  if (!allowed[elemName]) {
+    return false;
+  }
+
+  // Is it allowed on this element?
+  if (allowed[elemName].indexOf(attrName) !== -1) {
+    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;
+}
+
+// Get n-th immediate child of context that is of the same type as element.
+// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
+// 1) :scope is widely supported in more browsers and 2) it works with
+// DocumentFragments.
+function getNthElementOfType(context, element, index) {
+  let nthOfType = 0;
+  for (let i = 0, child; (child = context.children[i]); i++) {
+    if (child.nodeType === child.ELEMENT_NODE &&
+        child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
+      if (nthOfType === index) {
+        return child;
+      }
+      nthOfType++;
+    }
+  }
+  return null;
+}
+
+// Get the index of the element among siblings of the same type.
+function getIndexOfType(element) {
+  let index = 0;
+  let child;
+  while ((child = element.previousElementSibling)) {
+    if (child.tagName === element.tagName) {
+      index++;
+    }
+  }
+  return index;
+}
+
 const observerConfig = {
   attributes: true,
   characterData: false,
   childList: true,
   subtree: true,
   attributeFilter: ['data-l10n-id', 'data-l10n-args', 'data-l10n-bundle']
 };
 
@@ -332,39 +568,39 @@ class LocalizationObserver {
 
   translateElements(l10n, elements) {
     if (!elements.length) {
       return [];
     }
 
     const keys = elements.map(this.getKeysForElement);
     return l10n.formatEntities(keys).then(
-      translations => this.applyTranslations(l10n, elements, translations)
+      translations => this.applyTranslations(elements, translations)
     );
   }
 
   /**
    * Translates a single DOM node asynchronously.
    *
    * Returns a `Promise` that gets resolved once the translation is complete.
    *
    * @param   {Element} element - HTML element to be translated
    * @returns {Promise}
    */
   translateElement(element) {
     const l10n = this.get(element.getAttribute('data-l10n-bundle') || 'main');
     return l10n.formatEntities([this.getKeysForElement(element)]).then(
-      translations => this.applyTranslations(l10n, [element], translations)
+      translations => this.applyTranslations([element], translations)
     );
   }
 
-  applyTranslations(l10n, elements, translations) {
+  applyTranslations(elements, translations) {
     this.pause();
     for (let i = 0; i < elements.length; i++) {
-      l10n.overlayElement(elements[i], translations[i]);
+      overlayElement(elements[i], translations[i]);
     }
     this.resume();
   }
 
   groupTranslatablesByLocalization(frag) {
     const elemsWithL10n = [];
     for (const loc of this.localizations) {
       elemsWithL10n.push(
@@ -604,20 +840,16 @@ const contexts = new WeakMap();
  * default set of `<link rel="localization">` elements.  You can get
  * a reference to it via:
  *
  *     const localization = document.l10n.get('main');
  *
  * Different names can be specified via the `name` attribute on the `<link>`
  * elements.  One `document` can have more than one `Localization` instance,
  * but one `Localization` instance can only be assigned to a single `document`.
- *
- * `HTMLLocalization` and `XULLocalization` extend `Localization` and provide
- * `document`-specific methods for sanitizing translations containing markup
- * before they're inserted into the DOM.
  */
 class Localization {
 
   /**
    * Create an instance of the `Localization` class.
    *
    * The instance's configuration is provided by two runtime-dependent
    * functions passed to the constructor.
@@ -957,234 +1189,16 @@ function entitiesFromContext(ctx, keys, 
  *   hasErrors: boolean,
  *   translations: Array<string>}}
  * @private
  */
 function valuesFromContext(ctx, keys, prev) {
   return keysFromContext(valueFromContext, sanitizeArgs, ctx, keys, prev);
 }
 
-// Match the opening angle bracket (<) in HTML tags, and HTML entities like
-// &amp;, &#0038;, &#x0026;.
-const reOverlay = /<|&#?\w+;/;
-
-/**
- * Overlay translation onto a DOM element.
- *
- * @param   {Localization} l10n
- * @param   {Element}      element
- * @param   {string}       translation
- * @private
- */
-function overlayElement(l10n, element, translation) {
-  const value = translation.value;
-
-  if (typeof value === 'string') {
-    if (!reOverlay.test(value)) {
-      // If the translation doesn't contain any markup skip the overlay logic.
-      element.textContent = value;
-    } else {
-      // Else start with an inert template element and move its children into
-      // `element` but such that `element`'s own children are not replaced.
-      const tmpl = element.ownerDocument.createElementNS(
-        'http://www.w3.org/1999/xhtml', 'template');
-      tmpl.innerHTML = value;
-      // Overlay the node with the DocumentFragment.
-      overlay(l10n, element, tmpl.content);
-    }
-  }
-
-  for (const key in translation.attrs) {
-    if (l10n.isAttrAllowed({ name: key }, element)) {
-      element.setAttribute(key, translation.attrs[key]);
-    }
-  }
-}
-
-// The goal of overlay is to move the children of `translationElement`
-// into `sourceElement` such that `sourceElement`'s own children are not
-// replaced, but only have their text nodes and their attributes modified.
-//
-// We want to make it possible for localizers to apply text-level semantics to
-// the translations and make use of HTML entities. At the same time, we
-// don't trust translations so we need to filter unsafe elements and
-// attributes out and we don't want to break the Web by replacing elements to
-// which third-party code might have created references (e.g. two-way
-// bindings in MVC frameworks).
-function overlay(l10n, sourceElement, translationElement) {
-  const result = translationElement.ownerDocument.createDocumentFragment();
-  let k, attr;
-
-  // Take one node from translationElement at a time and check it against
-  // the allowed list or try to match it with a corresponding element
-  // in the source.
-  let childElement;
-  while ((childElement = translationElement.childNodes[0])) {
-    translationElement.removeChild(childElement);
-
-    if (childElement.nodeType === childElement.TEXT_NODE) {
-      result.appendChild(childElement);
-      continue;
-    }
-
-    const index = getIndexOfType(childElement);
-    const sourceChild = getNthElementOfType(sourceElement, childElement, index);
-    if (sourceChild) {
-      // There is a corresponding element in the source, let's use it.
-      overlay(l10n, sourceChild, childElement);
-      result.appendChild(sourceChild);
-      continue;
-    }
-
-    if (l10n.isElementAllowed(childElement)) {
-      const sanitizedChild = childElement.ownerDocument.createElement(
-        childElement.nodeName);
-      overlay(l10n, sanitizedChild, childElement);
-      result.appendChild(sanitizedChild);
-      continue;
-    }
-
-    // Otherwise just take this child's textContent.
-    result.appendChild(
-      translationElement.ownerDocument.createTextNode(
-        childElement.textContent));
-  }
-
-  // Clear `sourceElement` and append `result` which by this time contains
-  // `sourceElement`'s original children, overlayed with translation.
-  sourceElement.textContent = '';
-  sourceElement.appendChild(result);
-
-  // If we're overlaying a nested element, translate the allowed
-  // attributes; top-level attributes are handled in `overlayElement`.
-  // XXX Attributes previously set here for another language should be
-  // cleared if a new language doesn't use them; https://bugzil.la/922577
-  if (translationElement.attributes) {
-    for (k = 0, attr; (attr = translationElement.attributes[k]); k++) {
-      if (l10n.isAttrAllowed(attr, sourceElement)) {
-        sourceElement.setAttribute(attr.name, attr.value);
-      }
-    }
-  }
-}
-
-// Get n-th immediate child of context that is of the same type as element.
-// XXX Use querySelector(':scope > ELEMENT:nth-of-type(index)'), when:
-// 1) :scope is widely supported in more browsers and 2) it works with
-// DocumentFragments.
-function getNthElementOfType(context, element, index) {
-  let nthOfType = 0;
-  for (let i = 0, child; (child = context.children[i]); i++) {
-    if (child.nodeType === child.ELEMENT_NODE &&
-        child.tagName.toLowerCase() === element.tagName.toLowerCase()) {
-      if (nthOfType === index) {
-        return child;
-      }
-      nthOfType++;
-    }
-  }
-  return null;
-}
-
-// Get the index of the element among siblings of the same type.
-function getIndexOfType(element) {
-  let index = 0;
-  let child;
-  while ((child = element.previousElementSibling)) {
-    if (child.tagName === element.tagName) {
-      index++;
-    }
-  }
-  return index;
-}
-
-const ns = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul';
-
-const allowed = {
-  attributes: {
-    global: [
-      'accesskey', 'aria-label', 'aria-valuetext', 'aria-moz-hint', 'label'
-    ],
-    key: ['key', 'keycode'],
-    textbox: ['placeholder'],
-    toolbarbutton: ['tooltiptext'],
-  }
-};
-
-/**
- * The XUL-specific Localization class.
- *
- * @extends Localization
- *
- */
-class XULLocalization extends Localization {
-  /**
-   * Overlay a DOM element using markup from a translation.
-   *
-   * @param {Element} element
-   * @param {string}  translation
-   * @private
-   */
-  overlayElement(element, translation) {
-    return overlayElement(this, element, translation);
-  }
-
-  /**
-   * Check if element is allowed in this `Localization`'s document namespace.
-   *
-   * Always returns `false` for XUL elements.
-   *
-   * @returns {boolean}
-   * @private
-   */
-  isElementAllowed() {
-    return false;
-  }
-
-  /**
-   * Check if attribute is allowed for the given element.
-   *
-   * This method is used by the sanitizer when the translation markup contains
-   * DOM attributes, or when the translation has traits which map to DOM
-   * attributes.
-   *
-   * @param   {{name: string}} attr
-   * @param   {Element}        element
-   * @returns {boolean}
-   * @private
-   */
-  isAttrAllowed(attr, element) {
-    // Bail if it isn't even a XUL element.
-    if (element.namespaceURI !== ns) {
-      return false;
-    }
-
-    const tagName = element.localName;
-    const attrName = attr.name;
-
-    // Is it a globally safe attribute?
-    if (allowed.attributes.global.indexOf(attrName) !== -1) {
-      return true;
-    }
-
-    // Are there no allowed attributes for this element?
-    if (!allowed.attributes[tagName]) {
-      return false;
-    }
-
-    // Is it allowed on this element?
-    // XXX The allowed list should be amendable; https://bugzil.la/922573
-    if (allowed.attributes[tagName].indexOf(attrName) !== -1) {
-      return true;
-    }
-
-    return false;
-  }
-}
-
 class ChromeResourceBundle {
   constructor(lang, resources) {
     this.lang = lang;
     this.loaded = false;
     this.resources = resources;
 
     const data = Object.keys(resources).map(
       resId => resources[resId].data
@@ -1291,17 +1305,17 @@ function createLocalization(name, resIds
   function requestBundles(requestedLangs = navigator.languages) {
     return L10nRegistry.getResources(requestedLangs, resIds).then(
       ({bundles}) => bundles.map(
         bundle => new ChromeResourceBundle(bundle.locale, bundle.resources)
       )
     );
   }
 
-  const l10n = new XULLocalization(requestBundles, createContext);
+  const l10n = new Localization(requestBundles, createContext);
   document.l10n.set(name, l10n);
 
   if (name === 'main') {
     // When document is ready, we trigger it's localization and initialize
     // `MutationObserver` on the root.
     XULDocumentReady().then(() => {
       const rootElem = document.documentElement;
       document.l10n.observeRoot(rootElem, l10n);