Source: src/js/views/MarkdownView.js

define([
  "jquery",
  "underscore",
  "backbone",
  "showdown",
  "text!templates/markdown.html",
  "text!templates/loading.html",
], function ($, _, Backbone, showdown, markdownTemplate, LoadingTemplate) {
  /**
   * @class MarkdownView
   * @classdesc A view of markdown content rendered into HTML with optional table of contents
   * @classcategory Views
   * @extends Backbone.View
   * @constructor
   */
  var MarkdownView = Backbone.View.extend(
    /** @lends MarkdownView.prototype */ {
      /**
       * The HTML classes to use for this view's element
       * @type {string}
       */
      className: "markdown",

      /**
       * The type of View this is
       * @type {string}
       * @readonly
       */
      type: "markdown",

      /**
       * Renders the compiled template into HTML
       * @type {UnderscoreTemplate}
       */
      template: _.template(markdownTemplate),
      loadingTemplate: _.template(LoadingTemplate),

      /**
       * Markdown to render into HTML
       * @type {string}
       */
      markdown: "",

      /**
       * An array of literature cited
       * @type {Array}
       */
      citations: [],

      /**
       * Indicates whether or not to render a table of contents for this view.
       * If set to true, a table of contents will be shown if there two or more
       * top-level headers are rendered from the markdown.
       * @type {boolean}
       */
      showTOC: false,

      /**
       * The events this view will listen to and the associated function to
       * call.
       * @type {Object}
       */
      events: {},

      /**
       * Initialize is executed when a new MarkdownView is created.
       * @param {Object} options - A literal object with options to pass to the view
       */
      initialize: function (options) {
        // highlightStyle = the name of the code syntax highlight style we
        // want to use for showdown's highlight extension.
        this.highlightStyle = "atom-one-light";

        if (typeof options !== "undefined") {
          this.markdown = options.markdown || "";
          this.citations = options.citations || [];
          this.showTOC = options.showTOC || false;
        }
      },

      /**
       * render - Renders the MarkdownView; converts markdown to HTML and
       * displays it.
       */
      render: function () {
        // Show a loading message while we render the markdown to HTML
        this.$el.html(
          this.loadingTemplate({
            msg: "Retrieving content...",
          }),
        );

        // Once required extensions are tested for and loaded, convert and
        // append markdown
        this.stopListening();
        this.listenTo(
          this,
          "requiredExtensionsLoaded",
          function (SDextensions) {
            var converter = new showdown.Converter({
              metadata: true,
              simplifiedAutoLink: true,
              customizedHeaderId: true,
              parseImgDimension: true,
              tables: true,
              tablesHeaderId: true,
              strikethrough: true,
              tasklists: true,
              emoji: true,
              extensions: SDextensions,
            });

            // If there are citations in the markdown text, add it to the markdown
            // so it gets rendered.
            if (
              _.contains(SDextensions, "showdown-citation") &&
              this.citations.length
            ) {
              // Put the bibtex into the markdown so it can be processed by
              // the showdown-citations extension.
              this.markdown =
                this.markdown + "\n<bibtex>" + this.citations + "</bibtex>";
            }

            try {
              // Use the Showdown converter to make HTML from the Markdown string
              htmlFromMD = converter.makeHtml(this.markdown);
            } catch (e) {
              // If there was a Showdown error, show an error message instead of the Markdown preview.
              //Create a temporary div to hold the error message
              var errorMsgTempContainer = document.createElement("div");
              //Create the error message
              MetacatUI.appView.showAlert(
                "This content can't be displayed.",
                "alert-error",
                errorMsgTempContainer,
                {
                  remove: false,
                },
              );
              // Get the inner HTML of the temporary div
              htmlFromMD = errorMsgTempContainer.innerHTML;
            }

            this.$el.html(this.template({ markdown: htmlFromMD }));

            if (this.showTOC) {
              this.renderTOC();
            }

            this.trigger("mdRendered");
          },
        );

        // Detect which extensions we'll need
        this.listRequiredExtensions(this.markdown);

        return this;
      },

      /**
       * listRequiredExtensions - test which extensions are needed, then load
       * them
       *
       * @param  {string} markdown - The markdown string before it's converted
       * into HTML
       */
      listRequiredExtensions: function (markdown) {
        var view = this;

        // SDextensions lists the desired order* of all potentailly required showdown extensions (* order matters! )
        var SDextensions = [
          "xssfilter",
          "katex",
          "highlight",
          "docbook",
          "showdown-htags",
          "bootstrap",
          "footnotes",
          "showdown-citation",
          "showdown-images",
        ];

        var numTestsTodo = SDextensions.length;

        // Each time an extension is tested for (and loaded if required),
        // updateExtensionList is called. When all tests are completed
        // (numTestsTodo == 0), an event is triggered. When this event is
        // triggered, markdown is converted and appended (see render)
        var updateExtensionList = function (extensionName, required) {
          numTestsTodo = numTestsTodo - 1;

          if (required == false) {
            var n = SDextensions.indexOf(extensionName);
            SDextensions.splice(n, 1);
          }

          if (numTestsTodo == 0) {
            view.trigger("requiredExtensionsLoaded", SDextensions);
          }
        };

        // ================================================================
        // Regular expressions used to test whether showdown
        // extensions are required.
        // NOTE: These expressions test the *markdown* and *not* the HTML

        var regexHighlight = new RegExp("`.*`"), // too general?
          regexDocbook = new RegExp(
            "<(title|citetitle|emphasis|para|ulink|literallayout|itemizedlist|orderedlist|listitem|subscript|superscript).*>",
          ),
          regexFootnotes1 = /^\[\^([\d\w]+)\]:( |\n)((.+\n)*.+)$/m,
          regexFootnotes2 = /^\[\^([\d\w]+)\]:\s*((\n+(\s{2,4}|\t).+)+)$/m,
          regexFootnotes3 = /\[\^([\d\w]+)\]/m,
          // test for all of the math/katex delimiters
          regexKatex = new RegExp(
            "\\$\\$.*\\$\\$|\\~.*\\~|\\$.*\\$|```asciimath.*```|```latex.*```",
          ),
          regexCitation = /\[@.+\]/;
        // test for any <h.> tags
        (regexHtags = new RegExp("#\\s")), (regexImages = /!\[.*\]\(\S+\)/);

        // ================================================================
        // Test for and load each as required each showdown extension

        // --- Test for XSS --- //

        // There is no test for the xss filter because it should always be
        // included. It's included via the updateExtensionList function for
        // consistency with the other, optional extensions.
        require(["showdownXssFilter"], function (showdownXss) {
          updateExtensionList("xssfilter", (required = true));
        });

        // --- Test for katex --- //

        if (regexKatex.test(markdown)) {
          require([
            "showdownKatex",
            "text!" +
              MetacatUI.root +
              "/components/showdown/extensions/showdown-katex/katex.min.css",
          ], function (showdownKatex, showdownKatexCss) {
            // custom config needed for katex
            var katex = showdownKatex({
              delimiters: [
                { left: "$", right: "$", display: false },
                { left: "$$", right: "$$", display: false },
                { left: "~", right: "~", display: false },
              ],
            });
            // Add CSS required to render katex math symbols correctly
            MetacatUI.appModel.addCSS(showdownKatexCss, "showdownKatex");
            // Because custom config, register katex with showdown
            showdown.extension("katex", katex);
            updateExtensionList("katex", (required = true));
          });
        } else {
          updateExtensionList("katex", (required = false));
        }

        // --- Test for highlight --- //

        if (regexHighlight.test(markdown)) {
          require([
            "showdownHighlight",
            "text!" +
              MetacatUI.root +
              "/components/showdown/extensions/showdown-highlight/styles/atom-one-light.css",
          ], function (showdownHighlight, showdownHighlightCss) {
            updateExtensionList("highlight", (required = true));
            // CSS needed for highlight
            MetacatUI.appModel.addCSS(
              showdownHighlightCss,
              "showdownHighlight",
            );
          });
        } else {
          updateExtensionList("highlight", (required = false));
        }

        // --- Test for docbooks --- //

        if (regexDocbook.test(markdown)) {
          require(["showdownDocbook"], function (showdownDocbook) {
            updateExtensionList("docbook", (required = true));
          });
        } else {
          updateExtensionList("docbook", (required = false));
        }

        // --- Test for htag --- //

        if (regexHtags.test(markdown)) {
          require(["showdownHtags"], function (showdownHtags) {
            updateExtensionList("showdown-htags", (required = true));
          });
        } else {
          updateExtensionList("showdown-htags", (required = false));
        }

        // --- Test for bootstrap --- //
        // The custom bootstrap library is small and only adds some classes
        // for tables and images, and maybe other HTML elements in the future.
        // Testing for tables in markdown using regular expressions isn't
        // straight forward. Better to just load this extension whether or
        // not it's required.
        require(["showdownBootstrap"], function (showdownBootstrap) {
          updateExtensionList("bootstrap", (required = true));
        });

        // --- Test for footnotes --- //

        if (
          regexFootnotes1.test(markdown) ||
          regexFootnotes2.test(markdown) ||
          regexFootnotes3.test(markdown)
        ) {
          require(["showdownFootnotes"], function (showdownFootnotes) {
            updateExtensionList("footnotes", (required = true));
          });
        } else {
          updateExtensionList("footnotes", (required = false));
        }

        // --- Test for citations --- //

        // showdownCitation throws error...
        if (regexCitation.test(markdown)) {
          require(["showdownCitation"], function (showdownCitation) {
            updateExtensionList("showdown-citation", (required = true));
          });
        } else {
          updateExtensionList("showdown-citation", (required = false));
        }

        // --- Test for images --- //
        if (regexImages.test(markdown)) {
          require(["showdownImages"], function (showdownImages) {
            updateExtensionList("showdown-images", (required = true));
          });
        } else {
          updateExtensionList("showdown-images", (required = false));
        }
      },

      /**
       * Renders a table of contents (a TOCView) that links to different sections of the MarkdownView
       */
      renderTOC: function () {
        if (this.showTOC === false) {
          return;
        }

        var view = this;

        require(["views/TOCView"], function (TOCView) {
          //Create a table of contents view
          view.tocView = new TOCView({
            contentEl: view.el,
            className: "toc toc-view",
            addScrollspy: true,
            affix: true,
          });

          view.tocView.render();

          // If more than one link was created in the TOCView, add it to this
          // view. Limit to `.desktop` items (i.e. exclude .mobile items) so
          // that the length isn't doubled
          if (view.tocView.$el.find(".desktop li").length > 1) {
            $(view.tocView.el).insertBefore(view.$el);
            // Make a two-column layout
            view.tocView.$el.addClass("span3");
            view.$el.addClass("span9");
          }

          view.tocView.setAffix();
        });
      },

      /**
       * onClose - Close and destroy the view
       */
      onClose: function () {
        // Remove for the DOM, stop listening
        this.remove();
        // Remove appended html
        this.$el.html("");
      },
    },
  );

  return MarkdownView;
});