// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- /* 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/. */ const { utils: Cu, interfaces: Ci, classes: Cc } = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", "resource://gre/modules/Deprecated.jsm"); const NS_XHTML = "http://www.w3.org/1999/xhtml"; const VIEW_SOURCE_CSS = "resource://gre-resources/viewsource.css"; const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; // These are markers used to delimit the selection during processing. They // are removed from the final rendering. // We use noncharacter Unicode codepoints to minimize the risk of clashing // with anything that might legitimately be present in the document. // U+FDD0..FDEF const MARK_SELECTION_START = "\uFDD0"; const MARK_SELECTION_END = "\uFDEF"; const FRAME_SCRIPT = "chrome://global/content/viewSource-content.js"; this.EXPORTED_SYMBOLS = ["ViewSourceBrowser"]; // Keep a set of browsers we've seen before, so we can load our frame script as // needed into any new ones. let gKnownBrowsers = new WeakSet(); /** * ViewSourceBrowser manages the view source from the chrome side. * It's companion frame script, viewSource-content.js, needs to be loaded as a * frame script into the browser being managed. * * For a view source window using viewSource.xul, the script viewSource.js in * the window extends an instance of this with more window specific functions. * The page script takes care of loading the companion frame script. * * For a view source tab (or some other non-window case), an instance of this is * created by viewSourceUtils.js to wrap the . The frame script will * be loaded by this module at construction time. */ this.ViewSourceBrowser = function ViewSourceBrowser(aBrowser) { this._browser = aBrowser; this.init(); } ViewSourceBrowser.prototype = { /** * The that will be displaying the view source content. */ get browser() { return this._browser; }, /** * Holds the value of the last line found via the "Go to line" * command, to pre-populate the prompt the next time it is * opened. */ lastLineFound: null, /** * These are the messages that ViewSourceBrowser will listen for * from the frame script it injects. Any message names added here * will automatically have ViewSourceBrowser listen for those messages, * and remove the listeners on teardown. */ messages: [ "ViewSource:PromptAndGoToLine", "ViewSource:GoToLine:Success", "ViewSource:GoToLine:Failed", "ViewSource:StoreWrapping", "ViewSource:StoreSyntaxHighlighting", ], /** * This should be called as soon as the script loads. When this function * executes, we can assume the DOM content has not yet loaded. */ init() { this.messages.forEach((msgName) => { this.mm.addMessageListener(msgName, this); }); // If we have a known already, load the frame script here. This // is not true for the window case, as the element does not exist until the // XUL document loads. For that case, the frame script is loaded by // viewSource.js. if (this._browser) { this.loadFrameScript(); } }, /** * This should be called when the window is closing. This function should * clean up event and message listeners. */ uninit() { this.messages.forEach((msgName) => { this.mm.removeMessageListener(msgName, this); }); }, /** * For a new browser we've not seen before, load the frame script. */ loadFrameScript() { if (!gKnownBrowsers.has(this.browser)) { gKnownBrowsers.add(this.browser); this.mm.loadFrameScript(FRAME_SCRIPT, false); } }, /** * Anything added to the messages array will get handled here, and should * get dispatched to a specific function for the message name. */ receiveMessage(message) { let data = message.data; switch(message.name) { case "ViewSource:PromptAndGoToLine": this.promptAndGoToLine(); break; case "ViewSource:GoToLine:Success": this.onGoToLineSuccess(data.lineNumber); break; case "ViewSource:GoToLine:Failed": this.onGoToLineFailed(); break; case "ViewSource:StoreWrapping": this.storeWrapping(data.state); break; case "ViewSource:StoreSyntaxHighlighting": this.storeSyntaxHighlighting(data.state); break; } }, /** * Getter for the message manager of the view source browser. */ get mm() { return this.browser.messageManager; }, /** * Send a message to the view source browser. */ sendAsyncMessage(...args) { this.browser.messageManager.sendAsyncMessage(...args); }, /** * Getter for the nsIWebNavigation of the view source browser. */ get webNav() { return this.browser.webNavigation; }, /** * Getter for whether long lines should be wrapped. */ get wrapLongLines() { return Services.prefs.getBoolPref("view_source.wrap_long_lines"); }, /** * A getter for the view source string bundle. */ get bundle() { if (this._bundle) { return this._bundle; } return this._bundle = Services.strings.createBundle(BUNDLE_URL); }, /** * Loads the source for a URL while applying some optional features if * enabled. * * For the viewSource.xul window, this is called by onXULLoaded above. * For view source in a specific browser, this is manually called after * this object is constructed. * * This takes a single object argument containing: * * URL (required): * A string URL for the page we'd like to view the source of. * browser: * The browser containing the document that we would like to view the * source of. This argument is optional if outerWindowID is not passed. * outerWindowID (optional): * The outerWindowID of the content window containing the document that * we want to view the source of. This is the only way of attempting to * load the source out of the network cache. * lineNumber (optional): * The line number to focus on once the source is loaded. */ loadViewSource({ URL, browser, outerWindowID, lineNumber }) { if (!URL) { throw new Error("Must supply a URL when opening view source."); } if (browser) { // If we're dealing with a remote browser, then the browser // for view source needs to be remote as well. this.updateBrowserRemoteness(browser.isRemoteBrowser); } else { if (outerWindowID) { throw new Error("Must supply the browser if passing the outerWindowID"); } } this.sendAsyncMessage("ViewSource:LoadSource", { URL, outerWindowID, lineNumber }); }, /** * Updates the "remote" attribute of the view source browser. This * will remove the browser from the DOM, and then re-add it in the * same place it was taken from. * * @param shouldBeRemote * True if the browser should be made remote. If the browsers * remoteness already matches this value, this function does * nothing. */ updateBrowserRemoteness(shouldBeRemote) { if (this.browser.isRemoteBrowser != shouldBeRemote) { // In this base case, where we are handed a someone else is // managing, we don't know for sure that it's safe to toggle remoteness. // For view source in a window, this is overridden to actually do the // flip if needed. throw new Error("View source browser's remoteness mismatch"); } }, /** * Load the view source browser from a selection in some document. * * @param selection * A Selection object for the content of interest. */ loadViewSourceFromSelection(selection) { var range = selection.getRangeAt(0); var ancestorContainer = range.commonAncestorContainer; var doc = ancestorContainer.ownerDocument; var startContainer = range.startContainer; var endContainer = range.endContainer; var startOffset = range.startOffset; var endOffset = range.endOffset; // let the ancestor be an element var Node = doc.defaultView.Node; if (ancestorContainer.nodeType == Node.TEXT_NODE || ancestorContainer.nodeType == Node.CDATA_SECTION_NODE) ancestorContainer = ancestorContainer.parentNode; // for selectAll, let's use the entire document, including ... // @see nsDocumentViewer::SelectAll() for how selectAll is implemented try { if (ancestorContainer == doc.body) ancestorContainer = doc.documentElement; } catch (e) { } // each path is a "child sequence" (a.k.a. "tumbler") that // descends from the ancestor down to the boundary point var startPath = this._getPath(ancestorContainer, startContainer); var endPath = this._getPath(ancestorContainer, endContainer); // clone the fragment of interest and reset everything to be relative to it // note: it is with the clone that we operate/munge from now on. Also note // that we clone into a data document to prevent images in the fragment from // loading and the like. The use of importNode here, as opposed to adoptNode, // is _very_ important. // XXXbz wish there were a less hacky way to create an untrusted document here var isHTML = (doc.createElement("div").tagName == "DIV"); var dataDoc = isHTML ? ancestorContainer.ownerDocument.implementation.createHTMLDocument("") : ancestorContainer.ownerDocument.implementation.createDocument("", "", null); ancestorContainer = dataDoc.importNode(ancestorContainer, true); startContainer = ancestorContainer; endContainer = ancestorContainer; // Only bother with the selection if it can be remapped. Don't mess with // leaf elements (such as ) that secretly use anynomous content // for their display appearance. var canDrawSelection = ancestorContainer.hasChildNodes(); var tmpNode; if (canDrawSelection) { var i; for (i = startPath ? startPath.length-1 : -1; i >= 0; i--) { startContainer = startContainer.childNodes.item(startPath[i]); } for (i = endPath ? endPath.length-1 : -1; i >= 0; i--) { endContainer = endContainer.childNodes.item(endPath[i]); } // add special markers to record the extent of the selection // note: |startOffset| and |endOffset| are interpreted either as // offsets in the text data or as child indices (see the Range spec) // (here, munging the end point first to keep the start point safe...) if (endContainer.nodeType == Node.TEXT_NODE || endContainer.nodeType == Node.CDATA_SECTION_NODE) { // do some extra tweaks to try to avoid the view-source output to look like // ...]... or ...]... (where ']' marks the end of the selection). // To get a neat output, the idea here is to remap the end point from: // 1. ...]... to ...]... // 2. ...]... to ...]... if ((endOffset > 0 && endOffset < endContainer.data.length) || !endContainer.parentNode || !endContainer.parentNode.parentNode) endContainer.insertData(endOffset, MARK_SELECTION_END); else { tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); endContainer = endContainer.parentNode; if (endOffset === 0) endContainer.parentNode.insertBefore(tmpNode, endContainer); else endContainer.parentNode.insertBefore(tmpNode, endContainer.nextSibling); } } else { tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); endContainer.insertBefore(tmpNode, endContainer.childNodes.item(endOffset)); } if (startContainer.nodeType == Node.TEXT_NODE || startContainer.nodeType == Node.CDATA_SECTION_NODE) { // do some extra tweaks to try to avoid the view-source output to look like // ...[... or ...[... (where '[' marks the start of the selection). // To get a neat output, the idea here is to remap the start point from: // 1. ...[... to ...[... // 2. ...[... to ...[... if ((startOffset > 0 && startOffset < startContainer.data.length) || !startContainer.parentNode || !startContainer.parentNode.parentNode || startContainer != startContainer.parentNode.lastChild) startContainer.insertData(startOffset, MARK_SELECTION_START); else { tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); startContainer = startContainer.parentNode; if (startOffset === 0) startContainer.parentNode.insertBefore(tmpNode, startContainer); else startContainer.parentNode.insertBefore(tmpNode, startContainer.nextSibling); } } else { tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); startContainer.insertBefore(tmpNode, startContainer.childNodes.item(startOffset)); } } // now extract and display the syntax highlighted source tmpNode = dataDoc.createElementNS(NS_XHTML, "div"); tmpNode.appendChild(ancestorContainer); // Tell content to draw a selection after the load below if (canDrawSelection) { this.sendAsyncMessage("ViewSource:ScheduleDrawSelection"); } // all our content is held by the data:URI and URIs are internally stored as utf-8 (see nsIURI.idl) var loadFlags = Components.interfaces.nsIWebNavigation.LOAD_FLAGS_NONE; var referrerPolicy = Components.interfaces.nsIHttpChannel.REFERRER_POLICY_DEFAULT; this.webNav.loadURIWithOptions((isHTML ? "view-source:data:text/html;charset=utf-8," : "view-source:data:application/xml;charset=utf-8,") + encodeURIComponent(tmpNode.innerHTML), loadFlags, null, referrerPolicy, // referrer null, null, // postData, headers Services.io.newURI(doc.baseURI, null, null)); }, /** * A helper to get a path like FIXptr, but with an array instead of the * "tumbler" notation. * See FIXptr: http://lists.w3.org/Archives/Public/www-xml-linking-comments/2001AprJun/att-0074/01-NOTE-FIXptr-20010425.htm */ _getPath(ancestor, node) { var n = node; var p = n.parentNode; if (n == ancestor || !p) return null; var path = new Array(); if (!path) return null; do { for (var i = 0; i < p.childNodes.length; i++) { if (p.childNodes.item(i) == n) { path.push(i); break; } } n = p; p = n.parentNode; } while (n != ancestor && p); return path; }, /** * Load the view source browser from a fragment of some document, as in * markups such as MathML where reformatting the output is helpful. * * @param aNode * Some element within the fragment of interest. * @param aContext * A string denoting the type of fragment. Currently, "mathml" is the * only accepted value. */ loadViewSourceFromFragment(node, context) { var Node = node.ownerDocument.defaultView.Node; this._lineCount = 0; this._startTargetLine = 0; this._endTargetLine = 0; this._targetNode = node; if (this._targetNode && this._targetNode.nodeType == Node.TEXT_NODE) this._targetNode = this._targetNode.parentNode; // walk up the tree to the top-level element (e.g., , ) var topTag; if (context == "mathml") topTag = "math"; else throw "not reached"; var topNode = this._targetNode; while (topNode && topNode.localName != topTag) topNode = topNode.parentNode; if (!topNode) return; // serialize var title = this.bundle.GetStringFromName("viewMathMLSourceTitle"); var wrapClass = this.wrapLongLines ? ' class="wrap"' : ''; var source = '' + '' + '' + title + '' + '' + '' + '' + '' + '
'
    + this._getOuterMarkup(topNode, 0)
    + '
' ; // end // display this.browser.loadURI("data:text/html;charset=utf-8," + encodeURIComponent(source)); }, _getInnerMarkup(node, indent) { var str = ''; for (var i = 0; i < node.childNodes.length; i++) { str += this._getOuterMarkup(node.childNodes.item(i), indent); } return str; }, _getOuterMarkup(node, indent) { var Node = node.ownerDocument.defaultView.Node; var newline = ""; var padding = ""; var str = ""; if (node == this._targetNode) { this._startTargetLine = this._lineCount; str += '
';
    }

    switch (node.nodeType) {
    case Node.ELEMENT_NODE: // Element
      // to avoid the wide gap problem, '\n' is not emitted on the first
      // line and the lines before & after the 
...
if (this._lineCount > 0 && this._lineCount != this._startTargetLine && this._lineCount != this._endTargetLine) { newline = "\n"; } this._lineCount++; for (var k = 0; k < indent; k++) { padding += " "; } str += newline + padding + '<' + node.nodeName + ''; for (var i = 0; i < node.attributes.length; i++) { var attr = node.attributes.item(i); if (attr.nodeName.match(/^[-_]moz/)) { continue; } str += ' ' + attr.nodeName + '="' + this._unicodeToEntity(attr.nodeValue) + '"'; } if (!node.hasChildNodes()) { str += "/>"; } else { str += ">"; var oldLine = this._lineCount; str += this._getInnerMarkup(node, indent + 2); if (oldLine == this._lineCount) { newline = ""; padding = ""; } else { newline = (this._lineCount == this._endTargetLine) ? "" : "\n"; this._lineCount++; } str += newline + padding + '</' + node.nodeName + '>'; } break; case Node.TEXT_NODE: // Text var tmp = node.nodeValue; tmp = tmp.replace(/(\n|\r|\t)+/g, " "); tmp = tmp.replace(/^ +/, ""); tmp = tmp.replace(/ +$/, ""); if (tmp.length != 0) { str += '' + this._unicodeToEntity(tmp) + ''; } break; default: break; } if (node == this._targetNode) { this._endTargetLine = this._lineCount; str += '
';
    }
    return str;
  },

  _unicodeToEntity(text) {
    const charTable = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;'
    };

    function charTableLookup(letter) {
      return charTable[letter];
    }

    function convertEntity(letter) {
      try {
        var unichar = this._entityConverter
                          .ConvertToEntity(letter, entityVersion);
        var entity = unichar.substring(1); // extract '&'
        return '&' + entity + '';
      } catch (ex) {
        return letter;
      }
    }

    if (!this._entityConverter) {
      try {
        this._entityConverter = Cc["@mozilla.org/intl/entityconverter;1"]
                                  .createInstance(Ci.nsIEntityConverter);
      } catch(e) { }
    }

    const entityVersion = Ci.nsIEntityConverter.entityW3C;

    var str = text;

    // replace chars in our charTable
    str = str.replace(/[<>&"]/g, charTableLookup);

    // replace chars > 0x7f via nsIEntityConverter
    str = str.replace(/[^\0-\u007f]/g, convertEntity);

    return str;
  },

  /**
   * Opens the "Go to line" prompt for a user to hop to a particular line
   * of the source code they're viewing. This will keep prompting until the
   * user either cancels out of the prompt, or enters a valid line number.
   */
  promptAndGoToLine() {
    let input = { value: this.lastLineFound };
    let window = Services.wm.getMostRecentWindow(null);

    let ok = Services.prompt.prompt(
        window,
        this.bundle.GetStringFromName("goToLineTitle"),
        this.bundle.GetStringFromName("goToLineText"),
        input,
        null,
        {value:0});

    if (!ok)
      return;

    let line = parseInt(input.value, 10);

    if (!(line > 0)) {
      Services.prompt.alert(window,
                            this.bundle.GetStringFromName("invalidInputTitle"),
                            this.bundle.GetStringFromName("invalidInputText"));
      this.promptAndGoToLine();
    } else {
      this.goToLine(line);
    }
  },

  /**
   * Go to a particular line of the source code. This act is asynchronous.
   *
   * @param lineNumber
   *        The line number to try to go to to.
   */
  goToLine(lineNumber) {
    this.sendAsyncMessage("ViewSource:GoToLine", { lineNumber });
  },

  /**
   * Called when the frame script reports that a line was successfully gotten
   * to.
   *
   * @param lineNumber
   *        The line number that we successfully got to.
   */
  onGoToLineSuccess(lineNumber) {
    // We'll pre-populate the "Go to line" prompt with this value the next
    // time it comes up.
    this.lastLineFound = lineNumber;
  },

  /**
   * Called when the frame script reports that we failed to go to a particular
   * line. This informs the user that their selection was likely out of range,
   * and then reprompts the user to try again.
   */
  onGoToLineFailed() {
    let window = Services.wm.getMostRecentWindow(null);
    Services.prompt.alert(window,
                          this.bundle.GetStringFromName("outOfRangeTitle"),
                          this.bundle.GetStringFromName("outOfRangeText"));
    this.promptAndGoToLine();
  },

  /**
   * Update the wrapping pref based on the child's current state.
   * @param state
   *        Whether wrapping is currently enabled in the child.
   */
  storeWrapping(state) {
    Services.prefs.setBoolPref("view_source.wrap_long_lines", state);
  },

  /**
   * Update the syntax highlighting pref based on the child's current state.
   * @param state
   *        Whether syntax highlighting is currently enabled in the child.
   */
  storeSyntaxHighlighting(state) {
    Services.prefs.setBoolPref("view_source.syntax_highlight", state);
  },

};

/**
 * Helper to decide if a URI maps to view source content.
 * @param uri
 *        String containing the URI
 */
ViewSourceBrowser.isViewSource = function(uri) {
  return uri.startsWith("view-source:") ||
         (uri.startsWith("data:") && uri.includes("MathML"));
};