1
0
Fork 0
why-cant-we-deploy-today/plugin/markdown/plugin.js

519 lines
15 KiB
JavaScript
Executable File

/*!
* The reveal.js markdown plugin. Handles parsing of
* markdown inside of presentations as well as loading
* of external markdown documents.
*/
import { marked } from "marked";
const DEFAULT_SLIDE_SEPARATOR = "\r?\n---\r?\n",
DEFAULT_NOTES_SEPARATOR = "notes?:",
DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = "\\.element\\s*?(.+?)$",
DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = "\\.slide:\\s*?(\\S.+?)$";
const SCRIPT_END_PLACEHOLDER = "__SCRIPT_END__";
const CODE_LINE_NUMBER_REGEX = /\[([\s\d,|-]*)\]/;
const HTML_ESCAPE_MAP = {
"&": "&",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
const Plugin = () => {
// The reveal.js instance this plugin is attached to
let deck;
/**
* Retrieves the markdown contents of a slide section
* element. Normalizes leading tabs/whitespace.
*/
function getMarkdownFromSlide(section) {
// look for a <script> or <textarea data-template> wrapper
var template =
section.querySelector("[data-template]") ||
section.querySelector("script");
// strip leading whitespace so it isn't evaluated as code
var text = (template || section).textContent;
// restore script end tags
text = text.replace(new RegExp(SCRIPT_END_PLACEHOLDER, "g"), "</script>");
var leadingWs = text.match(/^\n?(\s*)/)[1].length,
leadingTabs = text.match(/^\n?(\t*)/)[1].length;
if (leadingTabs > 0) {
text = text.replace(
new RegExp("\\n?\\t{" + leadingTabs + "}(.*)", "g"),
function (m, p1) {
return "\n" + p1;
}
);
} else if (leadingWs > 1) {
text = text.replace(
new RegExp("\\n? {" + leadingWs + "}(.*)", "g"),
function (m, p1) {
return "\n" + p1;
}
);
}
return text;
}
/**
* Given a markdown slide section element, this will
* return all arguments that aren't related to markdown
* parsing. Used to forward any other user-defined arguments
* to the output markdown slide.
*/
function getForwardedAttributes(section) {
var attributes = section.attributes;
var result = [];
for (var i = 0, len = attributes.length; i < len; i++) {
var name = attributes[i].name,
value = attributes[i].value;
// disregard attributes that are used for markdown loading/parsing
if (/data\-(markdown|separator|vertical|notes)/gi.test(name)) continue;
if (value) {
result.push(name + '="' + value + '"');
} else {
result.push(name);
}
}
return result.join(" ");
}
/**
* Inspects the given options and fills out default
* values for what's not defined.
*/
function getSlidifyOptions(options) {
options = options || {};
options.separator = options.separator || DEFAULT_SLIDE_SEPARATOR;
options.notesSeparator = options.notesSeparator || DEFAULT_NOTES_SEPARATOR;
options.attributes = options.attributes || "";
return options;
}
/**
* Helper function for constructing a markdown slide.
*/
function createMarkdownSlide(content, options) {
options = getSlidifyOptions(options);
var notesMatch = content.split(new RegExp(options.notesSeparator, "mgi"));
if (notesMatch.length === 2) {
content =
notesMatch[0] +
'<aside class="notes">' +
marked(notesMatch[1].trim()) +
"</aside>";
}
// prevent script end tags in the content from interfering
// with parsing
content = content.replace(/<\/script>/g, SCRIPT_END_PLACEHOLDER);
return '<script type="text/template">' + content + "</script>";
}
/**
* Parses a data string into multiple slides based
* on the passed in separator arguments.
*/
function slidify(markdown, options) {
options = getSlidifyOptions(options);
var separatorRegex = new RegExp(
options.separator +
(options.verticalSeparator ? "|" + options.verticalSeparator : ""),
"mg"
),
horizontalSeparatorRegex = new RegExp(options.separator);
var matches,
lastIndex = 0,
isHorizontal,
wasHorizontal = true,
content,
sectionStack = [];
// iterate until all blocks between separators are stacked up
while ((matches = separatorRegex.exec(markdown))) {
var notes = null;
// determine direction (horizontal by default)
isHorizontal = horizontalSeparatorRegex.test(matches[0]);
if (!isHorizontal && wasHorizontal) {
// create vertical stack
sectionStack.push([]);
}
// pluck slide content from markdown input
content = markdown.substring(lastIndex, matches.index);
if (isHorizontal && wasHorizontal) {
// add to horizontal stack
sectionStack.push(content);
} else {
// add to vertical stack
sectionStack[sectionStack.length - 1].push(content);
}
lastIndex = separatorRegex.lastIndex;
wasHorizontal = isHorizontal;
}
// add the remaining slide
(wasHorizontal ? sectionStack : sectionStack[sectionStack.length - 1]).push(
markdown.substring(lastIndex)
);
var markdownSections = "";
// flatten the hierarchical stack, and insert <section data-markdown> tags
for (var i = 0, len = sectionStack.length; i < len; i++) {
// vertical
if (sectionStack[i] instanceof Array) {
markdownSections += "<section " + options.attributes + ">";
sectionStack[i].forEach(function (child) {
markdownSections +=
"<section data-markdown>" +
createMarkdownSlide(child, options) +
"</section>";
});
markdownSections += "</section>";
} else {
markdownSections +=
"<section " +
options.attributes +
" data-markdown>" +
createMarkdownSlide(sectionStack[i], options) +
"</section>";
}
}
return markdownSections;
}
/**
* Parses any current data-markdown slides, splits
* multi-slide markdown into separate sections and
* handles loading of external markdown.
*/
function processSlides(scope) {
return new Promise(function (resolve) {
var externalPromises = [];
[].slice
.call(
scope.querySelectorAll(
"section[data-markdown]:not([data-markdown-parsed])"
)
)
.forEach(function (section, i) {
if (section.getAttribute("data-markdown").length) {
externalPromises.push(
loadExternalMarkdown(section).then(
// Finished loading external file
function (xhr, url) {
section.outerHTML = slidify(xhr.responseText, {
separator: section.getAttribute("data-separator"),
verticalSeparator: section.getAttribute(
"data-separator-vertical"
),
notesSeparator: section.getAttribute(
"data-separator-notes"
),
attributes: getForwardedAttributes(section),
});
},
// Failed to load markdown
function (xhr, url) {
section.outerHTML =
'<section data-state="alert">' +
"ERROR: The attempt to fetch " +
url +
" failed with HTTP status " +
xhr.status +
"." +
"Check your browser's JavaScript console for more details." +
"<p>Remember that you need to serve the presentation HTML from a HTTP server.</p>" +
"</section>";
}
)
);
} else {
section.outerHTML = slidify(getMarkdownFromSlide(section), {
separator: section.getAttribute("data-separator"),
verticalSeparator: section.getAttribute(
"data-separator-vertical"
),
notesSeparator: section.getAttribute("data-separator-notes"),
attributes: getForwardedAttributes(section),
});
}
});
Promise.all(externalPromises).then(resolve);
});
}
function loadExternalMarkdown(section) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest(),
url = section.getAttribute("data-markdown");
var datacharset = section.getAttribute("data-charset");
// see https://developer.mozilla.org/en-US/docs/Web/API/element.getAttribute#Notes
if (datacharset != null && datacharset != "") {
xhr.overrideMimeType("text/html; charset=" + datacharset);
}
xhr.onreadystatechange = function (section, xhr) {
if (xhr.readyState === 4) {
// file protocol yields status code 0 (useful for local debug, mobile applications etc.)
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) {
resolve(xhr, url);
} else {
reject(xhr, url);
}
}
}.bind(this, section, xhr);
xhr.open("GET", url, true);
try {
xhr.send();
} catch (e) {
console.warn(
"Failed to get the Markdown file " +
url +
". Make sure that the presentation and the file are served by a HTTP server and the file can be found there. " +
e
);
resolve(xhr, url);
}
});
}
/**
* Check if a node value has the attributes pattern.
* If yes, extract it and add that value as one or several attributes
* to the target element.
*
* You need Cache Killer on Chrome to see the effect on any FOM transformation
* directly on refresh (F5)
* http://stackoverflow.com/questions/5690269/disabling-chrome-cache-for-website-development/7000899#answer-11786277
*/
function addAttributeInElement(node, elementTarget, separator) {
var mardownClassesInElementsRegex = new RegExp(separator, "mg");
var mardownClassRegex = new RegExp(
'([^"= ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])',
"mg"
);
var nodeValue = node.nodeValue;
var matches, matchesClass;
if ((matches = mardownClassesInElementsRegex.exec(nodeValue))) {
var classes = matches[1];
nodeValue =
nodeValue.substring(0, matches.index) +
nodeValue.substring(mardownClassesInElementsRegex.lastIndex);
node.nodeValue = nodeValue;
while ((matchesClass = mardownClassRegex.exec(classes))) {
if (matchesClass[2]) {
elementTarget.setAttribute(matchesClass[1], matchesClass[2]);
} else {
elementTarget.setAttribute(matchesClass[3], "");
}
}
return true;
}
return false;
}
/**
* Add attributes to the parent element of a text node,
* or the element of an attribute node.
*/
function addAttributes(
section,
element,
previousElement,
separatorElementAttributes,
separatorSectionAttributes
) {
if (
element != null &&
element.childNodes != undefined &&
element.childNodes.length > 0
) {
var previousParentElement = element;
for (var i = 0; i < element.childNodes.length; i++) {
var childElement = element.childNodes[i];
if (i > 0) {
var j = i - 1;
while (j >= 0) {
var aPreviousChildElement = element.childNodes[j];
if (
typeof aPreviousChildElement.setAttribute == "function" &&
aPreviousChildElement.tagName != "BR"
) {
previousParentElement = aPreviousChildElement;
break;
}
j = j - 1;
}
}
var parentSection = section;
if (childElement.nodeName == "section") {
parentSection = childElement;
previousParentElement = childElement;
}
if (
typeof childElement.setAttribute == "function" ||
childElement.nodeType == Node.COMMENT_NODE
) {
addAttributes(
parentSection,
childElement,
previousParentElement,
separatorElementAttributes,
separatorSectionAttributes
);
}
}
}
if (element.nodeType == Node.COMMENT_NODE) {
if (
addAttributeInElement(
element,
previousElement,
separatorElementAttributes
) == false
) {
addAttributeInElement(element, section, separatorSectionAttributes);
}
}
}
/**
* Converts any current data-markdown slides in the
* DOM to HTML.
*/
function convertSlides() {
var sections = deck
.getRevealElement()
.querySelectorAll("[data-markdown]:not([data-markdown-parsed])");
[].slice.call(sections).forEach(function (section) {
section.setAttribute("data-markdown-parsed", true);
var notes = section.querySelector("aside.notes");
var markdown = getMarkdownFromSlide(section);
section.innerHTML = marked(markdown);
addAttributes(
section,
section,
null,
section.getAttribute("data-element-attributes") ||
section.parentNode.getAttribute("data-element-attributes") ||
DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR,
section.getAttribute("data-attributes") ||
section.parentNode.getAttribute("data-attributes") ||
DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR
);
// If there were notes, we need to re-add them after
// having overwritten the section's HTML
if (notes) {
section.appendChild(notes);
}
});
return Promise.resolve();
}
function escapeForHTML(input) {
return input.replace(/([&<>'"])/g, (char) => HTML_ESCAPE_MAP[char]);
}
return {
id: "markdown",
/**
* Starts processing and converting Markdown within the
* current reveal.js deck.
*/
init: function (reveal) {
deck = reveal;
let { renderer, animateLists, ...markedOptions } =
deck.getConfig().markdown || {};
if (!renderer) {
renderer = new marked.Renderer();
renderer.code = (code, language) => {
// Off by default
let lineNumbers = "";
// Users can opt in to show line numbers and highlight
// specific lines.
// ```javascript [] show line numbers
// ```javascript [1,4-8] highlights lines 1 and 4-8
if (CODE_LINE_NUMBER_REGEX.test(language)) {
lineNumbers = language.match(CODE_LINE_NUMBER_REGEX)[1].trim();
lineNumbers = `data-line-numbers="${lineNumbers}"`;
language = language.replace(CODE_LINE_NUMBER_REGEX, "").trim();
}
// Escape before this gets injected into the DOM to
// avoid having the HTML parser alter our code before
// highlight.js is able to read it
code = escapeForHTML(code);
return `<pre><code ${lineNumbers} class="${language}">${code}</code></pre>`;
};
}
if (animateLists === true) {
renderer.listitem = (text) => `<li class="fragment">${text}</li>`;
}
marked.setOptions({
renderer,
...markedOptions,
});
return processSlides(deck.getRevealElement()).then(convertSlides);
},
// TODO: Do these belong in the API?
processSlides: processSlides,
convertSlides: convertSlides,
slidify: slidify,
marked: marked,
};
};
export default Plugin;