Bug 1368899 - Refactor the JSON Viewer stream converter to avoid quirks mode. r=Honza
authorOriol <oriol-bugzilla@hotmail.com>
Tue, 30 May 2017 20:23:00 -0400
changeset 400891 1d284663f05ddfc94fed8c44b552a383b9168610
parent 400890 c77e79c09f1e6adf173e09ef829c96e507dbbb0d
child 400892 ecad8cb9f20c5c4a3e768ebb96ec52917474680d
push id57
push userfmarier@mozilla.com
push dateSat, 24 Jun 2017 00:05:50 +0000
reviewersHonza
bugs1368899
milestone55.0a1
Bug 1368899 - Refactor the JSON Viewer stream converter to avoid quirks mode. r=Honza
devtools/client/jsonview/converter-child.js
devtools/client/jsonview/css/general.css
devtools/client/jsonview/css/main.css
--- a/devtools/client/jsonview/converter-child.js
+++ b/devtools/client/jsonview/converter-child.js
@@ -46,125 +46,195 @@ Converter.prototype = {
   },
 
   /**
    * This component works as such:
    * 1. asyncConvertData captures the listener
    * 2. onStartRequest fires, initializes stuff, modifies the listener
    *    to match our output type
    * 3. onDataAvailable spits it back to the listener
-   * 4. onStopRequest spits it back to the listener and initializes
-        the JSON Viewer
+   * 4. onStopRequest spits it back to the listener
    * 5. convert does nothing, it's just the synchronous version
    *    of asyncConvertData
    */
   convert: function (fromStream, fromType, toType, ctx) {
     return fromStream;
   },
 
   asyncConvertData: function (fromType, toType, listener, ctx) {
     this.listener = listener;
   },
 
   onDataAvailable: function (request, context, inputStream, offset, count) {
     this.listener.onDataAvailable(...arguments);
   },
 
   onStartRequest: function (request, context) {
-    this.channel = request;
+    // Set the content type to HTML in order to parse the doctype, styles
+    // and scripts, but later a <plaintext> element will switch the tokenizer
+    // to the plaintext state in order to parse the JSON.
+    request.QueryInterface(Ci.nsIChannel);
+    request.contentType = "text/html";
 
-    // Let "save as" save the original JSON, not the viewer.
-    // To save with the proper extension we need the original content type,
-    // which has been replaced by application/vnd.mozilla.json.view
-    let originalType;
-    if (request instanceof Ci.nsIHttpChannel) {
-      try {
-        let header = request.getResponseHeader("Content-Type");
-        originalType = header.split(";")[0];
-      } catch (err) {
-        // Handled below
-      }
-    } else {
-      let uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
-      let match = uri.match(/^data:(.*?)[,;]/);
-      if (match) {
-        originalType = match[1];
-      }
-    }
-    const JSON_TYPES = ["application/json", "application/manifest+json"];
-    if (!JSON_TYPES.includes(originalType)) {
-      originalType = JSON_TYPES[0];
-    }
-    request.QueryInterface(Ci.nsIWritablePropertyBag);
-    request.setProperty("contentType", originalType);
+    // JSON enforces UTF-8 charset (see bug 741776).
+    request.contentCharset = "UTF-8";
 
-    // Parse source as JSON. This is like text/plain, but enforcing
-    // UTF-8 charset (see bug 741776).
-    request.QueryInterface(Ci.nsIChannel);
-    request.contentType = JSON_TYPES[0];
-    this.charset = request.contentCharset = "UTF-8";
+    // Changing the content type breaks saving functionality. Fix it.
+    fixSave(request);
 
     // Because content might still have a reference to this window,
     // force setting it to a null principal to avoid it being same-
     // origin with (other) content.
     request.loadInfo.resetPrincipalToInheritToNullPrincipal();
 
+    // Start the request.
     this.listener.onStartRequest(request, context);
+
+    // Initialize stuff.
+    let win = NetworkHelper.getWindowForRequest(request);
+    exportData(win, request);
+    win.addEventListener("DOMContentLoaded", event => {
+      win.addEventListener("contentMessage", onContentMessage, false, true);
+    }, {once: true});
+
+    // Insert the initial HTML code.
+    let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+                      .createInstance(Ci.nsIScriptableUnicodeConverter);
+    converter.charset = "UTF-8";
+    let stream = converter.convertToInputStream(initialHTML(win.document));
+    this.listener.onDataAvailable(request, context, stream, 0, stream.available());
   },
 
   onStopRequest: function (request, context, statusCode) {
-    let Locale = {
-      $STR: key => {
-        try {
-          return jsonViewStrings.GetStringFromName(key);
-        } catch (err) {
-          console.error(err);
-          return undefined;
-        }
-      }
-    };
-
-    let headers = {
-      response: [],
-      request: []
-    };
-    // The request doesn't have to be always nsIHttpChannel
-    // (e.g. in case of data: URLs)
-    if (request instanceof Ci.nsIHttpChannel) {
-      request.visitResponseHeaders({
-        visitHeader: function (name, value) {
-          headers.response.push({name: name, value: value});
-        }
-      });
-      request.visitRequestHeaders({
-        visitHeader: function (name, value) {
-          headers.request.push({name: name, value: value});
-        }
-      });
-    }
-
-    let win = NetworkHelper.getWindowForRequest(request);
-    JsonViewUtils.exportIntoContentScope(win, Locale, "Locale");
-    JsonViewUtils.exportIntoContentScope(win, headers, "headers");
-
-    win.addEventListener("DOMContentLoaded", event => {
-      win.addEventListener("contentMessage",
-        onContentMessage.bind(this), false, true);
-      loadJsonViewer(win.document);
-    }, {once: true});
-
-    this.listener.onStopRequest(this.channel, context, statusCode);
+    this.listener.onStopRequest(request, context, statusCode);
     this.listener = null;
   }
 };
 
+// Lets "save as" save the original JSON, not the viewer.
+// To save with the proper extension we need the original content type,
+// which has been replaced by application/vnd.mozilla.json.view
+function fixSave(request) {
+  let originalType;
+  if (request instanceof Ci.nsIHttpChannel) {
+    try {
+      let header = request.getResponseHeader("Content-Type");
+      originalType = header.split(";")[0];
+    } catch (err) {
+      // Handled below
+    }
+  } else {
+    let uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
+    let match = uri.match(/^data:(.*?)[,;]/);
+    if (match) {
+      originalType = match[1];
+    }
+  }
+  const JSON_TYPES = ["application/json", "application/manifest+json"];
+  if (!JSON_TYPES.includes(originalType)) {
+    originalType = JSON_TYPES[0];
+  }
+  request.QueryInterface(Ci.nsIWritablePropertyBag);
+  request.setProperty("contentType", originalType);
+}
+
+// Exports variables that will be accessed by the non-privileged scripts.
+function exportData(win, request) {
+  let Locale = {
+    $STR: key => {
+      try {
+        return jsonViewStrings.GetStringFromName(key);
+      } catch (err) {
+        console.error(err);
+        return undefined;
+      }
+    }
+  };
+  JsonViewUtils.exportIntoContentScope(win, Locale, "Locale");
+
+  let headers = {
+    response: [],
+    request: []
+  };
+  // The request doesn't have to be always nsIHttpChannel
+  // (e.g. in case of data: URLs)
+  if (request instanceof Ci.nsIHttpChannel) {
+    request.visitResponseHeaders({
+      visitHeader: function (name, value) {
+        headers.response.push({name: name, value: value});
+      }
+    });
+    request.visitRequestHeaders({
+      visitHeader: function (name, value) {
+        headers.request.push({name: name, value: value});
+      }
+    });
+  }
+  JsonViewUtils.exportIntoContentScope(win, headers, "headers");
+}
+
+// Serializes a qualifiedName and an optional set of attributes into an HTML
+// start tag. Be aware qualifiedName and attribute names are not validated.
+// Attribute values are escaped with escapingString algorithm in attribute mode
+// (https://html.spec.whatwg.org/multipage/syntax.html#escapingString).
+function startTag(qualifiedName, attributes = {}) {
+  return Object.entries(attributes).reduce(function (prev, [attr, value]) {
+    return prev + " " + attr + "=\"" +
+      value.replace(/&/g, "&amp;")
+           .replace(/\u00a0/g, "&nbsp;")
+           .replace(/"/g, "&quot;") +
+      "\"";
+  }, "<" + qualifiedName) + ">";
+}
+
+// Builds an HTML string that will be used to load stylesheets and scripts,
+// and switch the parser to plaintext state.
+function initialHTML(doc) {
+  let os;
+  let platform = Services.appinfo.OS;
+  if (platform.startsWith("WINNT")) {
+    os = "win";
+  } else if (platform.startsWith("Darwin")) {
+    os = "mac";
+  } else {
+    os = "linux";
+  }
+
+  let base = doc.createElement("base");
+  base.href = "resource://devtools/client/jsonview/";
+
+  let style = doc.createElement("link");
+  style.rel = "stylesheet";
+  style.type = "text/css";
+  style.href = "css/main.css";
+
+  let script = doc.createElement("script");
+  script.src = "lib/require.js";
+  script.dataset.main = "viewer-config";
+  script.defer = true;
+
+  let head = doc.createElement("head");
+  head.append(base, style, script);
+
+  return "<!DOCTYPE html>\n" +
+    startTag("html", {
+      "platform": os,
+      "class": "theme-" + JsonViewUtils.getCurrentTheme(),
+      "dir": Services.locale.isAppLocaleRTL ? "rtl" : "ltr"
+    }) +
+    head.outerHTML +
+    startTag("body") +
+    startTag("div", {"id": "content"}) +
+    startTag("plaintext", {"id": "json"});
+}
+
 // Chrome <-> Content communication
 function onContentMessage(e) {
   // Do not handle events from different documents.
-  let win = NetworkHelper.getWindowForRequest(this.channel);
+  let win = this;
   if (win != e.target) {
     return;
   }
 
   let value = e.detail.value;
   switch (e.detail.type) {
     case "copy":
       copyString(win, value);
@@ -178,63 +248,16 @@ function onContentMessage(e) {
       // The window ID is needed when the JSON Viewer is inside an iframe.
       let windowID = win.QueryInterface(Ci.nsIInterfaceRequestor)
         .getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
       childProcessMessageManager.sendAsyncMessage(
         "devtools:jsonview:save", {url: value, windowID: windowID});
   }
 }
 
-// Loads the JSON Viewer into a text/plain document
-function loadJsonViewer(doc) {
-  function addStyleSheet(url) {
-    let link = doc.createElement("link");
-    link.rel = "stylesheet";
-    link.type = "text/css";
-    link.href = url;
-    doc.head.appendChild(link);
-  }
-
-  let os;
-  let platform = Services.appinfo.OS;
-  if (platform.startsWith("WINNT")) {
-    os = "win";
-  } else if (platform.startsWith("Darwin")) {
-    os = "mac";
-  } else {
-    os = "linux";
-  }
-
-  doc.documentElement.setAttribute("platform", os);
-  doc.documentElement.dataset.contentType = doc.contentType;
-  doc.documentElement.classList.add("theme-" + JsonViewUtils.getCurrentTheme());
-  doc.documentElement.dir = Services.locale.isAppLocaleRTL ? "rtl" : "ltr";
-
-  let base = doc.createElement("base");
-  base.href = "resource://devtools/client/jsonview/";
-  doc.head.appendChild(base);
-
-  addStyleSheet("../themes/variables.css");
-  addStyleSheet("../themes/common.css");
-  addStyleSheet("../themes/toolbars.css");
-  addStyleSheet("css/main.css");
-
-  let json = doc.querySelector("pre");
-  json.id = "json";
-  let content = doc.createElement("div");
-  content.id = "content";
-  content.appendChild(json);
-  doc.body.appendChild(content);
-
-  let script = doc.createElement("script");
-  script.src = "lib/require.js";
-  script.dataset.main = "viewer-config";
-  doc.body.appendChild(script);
-}
-
 function copyHeaders(win, headers) {
   let value = "";
   let eol = (Services.appinfo.OS !== "WINNT") ? "\n" : "\r\n";
 
   let responseHeaders = headers.response;
   for (let i = 0; i < responseHeaders.length; i++) {
     let header = responseHeaders[i];
     value += header.name + ": " + header.value + eol;
--- a/devtools/client/jsonview/css/general.css
+++ b/devtools/client/jsonview/css/general.css
@@ -28,28 +28,22 @@ pre {
 }
 
 #content {
   display: flow-root;
 }
 
 #json {
   margin: 8px;
+  white-space: pre-wrap;
 }
 
 /******************************************************************************/
 /* Dark Theme */
 
 body.theme-dark {
   color: var(--theme-body-color);
   background-color: var(--theme-body-background);
 }
 
 .theme-dark pre {
   background-color: var(--theme-body-background);
 }
-
-/******************************************************************************/
-/* Fixes for quirks mode */
-
-table {
-  font: inherit;
-}
--- a/devtools/client/jsonview/css/main.css
+++ b/devtools/client/jsonview/css/main.css
@@ -1,14 +1,17 @@
 /* vim:set ts=2 sw=2 sts=2 et: */
 /* 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/. */
 
-@import "resource://devtools/client/shared/components/reps/reps.css";
+@import "resource://devtools/client/themes/variables.css";
+@import "resource://devtools/client/themes/common.css";
+@import "resource://devtools/client/themes/toolbars.css";
+
 @import "resource://devtools/client/shared/components/tree/tree-view.css";
 @import "resource://devtools/client/shared/components/tabs/tabs.css";
 
 @import "general.css";
 @import "search-box.css";
 @import "toolbar.css";
 @import "json-panel.css";
 @import "text-panel.css";
@@ -48,9 +51,8 @@
 /******************************************************************************/
 /* Theme Firebug */
 
 /* JSON View is using bigger font-size for the main tabs so,
   let's overwrite the default value. */
 .theme-firebug .tabs .tabs-navigation {
   font-size: 14px;
 }
-