gridsome-plugin-remark-revealjs
Version:
Gridsome markdown remark transformer plugin to embed convert markdown files into Reveal.js HTML.
570 lines (468 loc) • 18.9 kB
JavaScript
const fs = require("fs");
const path = require("path");
const isURL = require("is-url");
const findAfter = require("unist-util-find-after");
const visit = require("unist-util-visit-parents");
const normalize = require("normalize-path");
const unified = require("unified");
const toVFile = require("to-vfile");
const remarkParse = require("remark-parse");
// const toc = require("mdast-util-toc");
let options = {};
const DEFAULT_SEPARATOR_ESCAPE_CHAR = "=",
DEFAULT_HORIZONTAL_SLIDE_SEPARATOR = "==",
DEFAULT_VERTICAL_SLIDE_SEPARATOR = "=",
DEFAULT_NOTES_SEPARATOR = "notes:",
DEFAULT_SLIDE_ATTRIBUTE_DELIMITER = "slide:",
DEFAULT_SUB_SLIDE_ATTRIBUTE_DELIMITER = "v-slide:",
DEFAULT_FILE_DELIMITER = "markdown:",
DEFAULT_FILE_ESCAPE_CHARS = "--",
DEFAULT_IFRAME_DELIMITER = "iframe:";
let startIndex = 0,
endIndex;
function revealJSPlugin (api, options) {
return transform;
}
function transform (tree, file) {
startIndex = 0;
// ugly simple check to see if this plugin is being run from node or client
if(file.data.node) {
options = {
directory: formatSlideDirectory(file.data.node.fileInfo),
horizontalSlide: {
separator: `${DEFAULT_SEPARATOR_ESCAPE_CHAR}${file.data.node.horizontalSeparator || DEFAULT_HORIZONTAL_SLIDE_SEPARATOR}`,
attributeDelimiter: DEFAULT_SLIDE_ATTRIBUTE_DELIMITER
},
verticalSlide: {
separator: `${DEFAULT_SEPARATOR_ESCAPE_CHAR}${file.data.node.verticalSeparator || DEFAULT_VERTICAL_SLIDE_SEPARATOR}`,
attributeDelimiter: DEFAULT_SUB_SLIDE_ATTRIBUTE_DELIMITER
},
notesSeparator: file.data.node.notesSeparator || DEFAULT_NOTES_SEPARATOR,
fileDelimiter: DEFAULT_FILE_DELIMITER,
iFrameDelimiter: DEFAULT_IFRAME_DELIMITER,
tableOfContents: file.data.node.tableOfContents || false,
coverSlide: file.data.node.coverSlide || false,
variables: {...file.data.node.variables}
};
} else {
options = {
directory: "",
horizontalSlide: {
separator: `${DEFAULT_SEPARATOR_ESCAPE_CHAR}${DEFAULT_HORIZONTAL_SLIDE_SEPARATOR}`,
attributeDelimiter: DEFAULT_SLIDE_ATTRIBUTE_DELIMITER
},
verticalSlide: {
separator: `${DEFAULT_SEPARATOR_ESCAPE_CHAR}${DEFAULT_VERTICAL_SLIDE_SEPARATOR}`,
attributeDelimiter: DEFAULT_SUB_SLIDE_ATTRIBUTE_DELIMITER
},
notesSeparator: DEFAULT_NOTES_SEPARATOR,
fileDelimiter: DEFAULT_FILE_DELIMITER,
iFrameDelimiter: DEFAULT_IFRAME_DELIMITER,
tableOfContents: false,
coverSlide: false,
variables: {}
};
}
// todo: find better implementation of ToC
// const tableOfContents = toc(tree);
// find variables in file.data.node.variables and find & replace those
const variableKeys = Object.keys(options.variables);
if(variableKeys.length > 0) {
visit (tree,
node => node.type === "text" &&
variableKeys.some(variable => node.value.includes(variable)),
(node, ancestors) => {
let { value } = node;
variableKeys.forEach(variable => {
value = value.replaceAll(`$${variable}`, options.variables[variable]);
});
node.value = value;
}
);
}
// read and inject external markdown files
visit (tree,
node => inlineSeparatorTest(node, "inlineCode", options.fileDelimiter),
addExternalMarkdown);
// wrap horizontal slides in section tags
visit (tree,
node => slideSeparatorTest(node, options.horizontalSlide.separator),
wrapSlides);
// wrap vertical slides in section tags
visit (tree,
node => slideSeparatorTest(node, options.verticalSlide.separator),
wrapSubSlides);
// wrap notes in aside tags
visit (tree,
node => inlineSeparatorTest(node, "text", options.notesSeparator),
wrapNotes);
// remove separators
visit (tree,
node => slideSeparatorTest(node, options.horizontalSlide.separator) ||
slideSeparatorTest(node, options.verticalSlide.separator),
removeSeparators);
// // set attributes for slide sections
visit (tree,
node => (node.type === "paragraph" && node.children[0].type === "inlineCode" &&
node.children[0].value.startsWith(options.horizontalSlide.attributeDelimiter)) ||
(node.type === "html" && node.value.startsWith("<!--")),
applySlideAttributes);
// set attributes for sub-slide sections
visit (tree,
node => inlineSeparatorTest(node, "inlineCode", options.verticalSlide.attributeDelimiter),
applySubSlideAttributes);
// add fragment div when thematic break (***)
visit (tree,
node => node.type === "thematicBreak",
addPause);
// add html video element when .mp4/.webm/.ogg is used
visit (tree,
node => node.type === "image",
addVideo);
// add html video element when .mp3/.wav/.ogg is used
visit (tree,
node => node.type === "image",
addAudio);
// place media outside of paragraph nodes
visit (tree,
node => node.type === "paragraph",
unwrapMedia);
// wrap words in square brackets in span tags
visit (tree,
node => node.type &&
node.type === "linkReference",
wrapSpans)
// add iframe embed
visit (tree,
node => inlineSeparatorTest(node, "inlineCode", options.iFrameDelimiter),
addIFrame);
// todo: format ToC slide in a proper layout for slides
// add ToC
// tree.children.push({
// type: "section",
// children: [tableOfContents.map],
// data: {
// hName: "section"
// }
// });
}
function formatSlideDirectory (fileInfo) {
const { path, name, extension } = fileInfo;
return path.split(`${name}${extension}`)[0]
}
function fileToAST (filename) {
let filePath = normalize(path.join(options.directory, filename));
if (!fs.existsSync(filePath)) {
throw Error(`Invalid markdown file specified; no such file "${filePath}"`);
}
let code = toVFile.readSync(filePath, "utf8");
const tree = unified()
.use(remarkParse)
.parse(code);
return [...tree.children];
}
function extractFilename (externalMarkdownString) {
const filenameMatchPattern = /:([^{]*[^{])/g;
const regexMatches = [...externalMarkdownString.matchAll(filenameMatchPattern)];
return regexMatches[0][1];
}
function addExternalMarkdown (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const { value } = node.children[0];
const markdownFilename = `${DEFAULT_FILE_ESCAPE_CHARS}${extractFilename(value)}`;
const astNodes = fileToAST(markdownFilename);
parent.children.splice(nodeIndex, 1, ...astNodes);
return nodeIndex + (astNodes.length - 1);
}
function slideSeparatorTest (node, separator) {
return node.type === "paragraph" &&
node.children[0].type === "text" &&
node.children[0].value === separator;
}
function inlineSeparatorTest (node, separatorType, separator) {
return node.type === "paragraph" &&
node.children[0].type === separatorType &&
node.children[0].value.startsWith(separator);
}
function wrapSlides (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
if (parent.type === "root") {
const isEnd = node => slideSeparatorTest(node, options.horizontalSlide.separator) && parent.children.indexOf(node) >= startIndex;
const endNode = findAfter(parent, parent.children[startIndex], isEnd);
endIndex = parent.children.indexOf(endNode);
const between = parent.children.slice(startIndex, endIndex > 0 ? endIndex : undefined);
const section = {
type: "section",
children: between,
data: {
hName: "section"
}
};
parent.children.splice(startIndex, section.children.length, section);
startIndex++;
return startIndex;
}
}
function wrapSubSlides (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
if (parent.type === "section" && ancestors[ancestors.length - 2].type === "root") {
const nodeIndex = parent.children.indexOf(node);
const isEnd = node => (slideSeparatorTest(node, options.verticalSlide.separator) && parent.children.indexOf(node) >= afterStart) || node.type === "section";
const afterStart = nodeIndex;
const endNode = findAfter(parent, parent.children[afterStart], isEnd);
const afterEnd = parent.children.indexOf(endNode);
const after = parent.children.slice(afterStart, afterEnd > 0 ? afterEnd : undefined);
const afterSection = {
type: "section",
children: after,
data: {
hName: "section"
}
};
if(parent.children[0].type !== "section") {
const beforeStart = 0;
const beforeEnd = nodeIndex;
const before = parent.children.slice(beforeStart, beforeEnd > 0 ? beforeEnd : undefined);
const beforeSection = {
type: "section",
children: before,
data: {
hName: "section"
}
}
parent.children.splice(beforeStart, beforeSection.children.length + afterSection.children.length, beforeSection, afterSection);
return beforeStart + 1;
} else {
parent.children.splice(afterStart, afterSection.children.length, afterSection);
}
}
}
function wrapNotes (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const noteNodes = parent.children.slice(nodeIndex, parent.children.length);
if (noteNodes[0].type === "paragraph" &&
noteNodes[0].children[0].value.startsWith(options.notesSeparator)) {
noteNodes[0].children[0].value = noteNodes[0].children[0].value.replace(options.notesSeparator, "");
noteNodes[0].children[0].value = noteNodes[0].children[0].value.trim();
}
const notes = {
type: "section",
children: noteNodes,
data: {
hName: "aside",
hProperties: {
className: "notes"
}
}
}
parent.children.splice(nodeIndex, notes.children.length, notes);
return nodeIndex + 1;
}
function removeSeparators (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
parent.children.splice(nodeIndex, 1);
return nodeIndex - 1;
}
function applySlideAttributes (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
if (ancestors.length <= 1 && parent.type === "root") {
// it's an HTML snippet, nothing to apply attributes to
return;
}
const superParent = ancestors[ancestors.length - 2];
const parentIndex = superParent.children.indexOf(parent);
let attrTarget;
if (parentIndex === 0 && superParent.type === "section") {
attrTarget = superParent;
} else {
attrTarget = parent;
}
setAttributesMarkdown(node, attrTarget);
parent.children.splice(nodeIndex, 1);
return nodeIndex - 1;
}
function applySubSlideAttributes (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
setAttributesMarkdown(node, parent)
parent.children.splice(nodeIndex, 1);
return nodeIndex - 1;
}
function setAttributesMarkdown (node, targetLeaf) {
if(targetLeaf.type !== "section") {
console.log("This is not a section, probably not the element where we want to apply the attributes, type is: ");
console.dir(targetLeaf);
return;
}
const nodeString = node.type === "html" ? node.value : node.children[0].value;
targetLeaf.data.hProperties = {};
// pattern matching any attributes based on =" (equal sign and double-quote)
const attributeSearchPattern = /([^ ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])/gm;
// pattern matching any string starting with a . (dot)
const dotClassSearchPattern = /(?<![""]) \.(.+?[^""])(?= +?(?![""]))/gm;
const attributeMatches = nodeString.matchAll(attributeSearchPattern);
const dotClassMatches = nodeString.matchAll(dotClassSearchPattern);
for (const dotClassMatch of dotClassMatches) {
const attrName = dotClassMatch[0].trim();
const attrValue = dotClassMatch[1];
if(attrName.startsWith(".")) {
targetLeaf.data.hProperties.className = attrValue;
}
}
for (const attributeMatch of attributeMatches) {
const attrName = attributeMatch[1].trim();
const attrValue = attributeMatch[2];
if(attrName === "class") {
targetLeaf.data.hProperties.className = attrValue;
}
if(attrName.startsWith("data-")) {
const cleanAttributeName = attrName.split("data-")[1];
const attrNameCamelCase = kebabToCamelCase(cleanAttributeName);
const dataAttributeName = `data${attrNameCamelCase}`;
targetLeaf.data.hProperties[dataAttributeName] = attrValue;
}
if(attrName === "type") {
targetLeaf.data.hProperties.className = attrValue;
if(attrValue === "title-slide") {
targetLeaf.data.hProperties.dataBackgroundColor = "var(--r-main-color)";
}
if(attrValue === "chapter-slide") {
targetLeaf.data.hProperties.dataBackgroundColor = "var(--r-secondary-color)";
}
}
}
}
function kebabToCamelCase (kebabCaseString) {
const explodedKebab = kebabCaseString.split("-");
const explodedCamel = explodedKebab.map(name => {
return name.charAt(0).toUpperCase() + name.substring(1);
});
return explodedCamel.join("");
}
function addPause (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const isEnd = node => node.type === "thematicBreak" ||
(node.type === "section" && node.data.hName === "aside") &&
parent.children.indexOf(node) >= nodeIndex;
const endNode = findAfter(parent, parent.children[nodeIndex], isEnd);
const endNodeIndex = parent.children.indexOf(endNode);
const between = parent.children.slice(nodeIndex + 1, endNodeIndex > 0 ? endNodeIndex : undefined);
const fragmentSection = {
type: "div",
children: between,
data: {
hName: "div",
hProperties: {
className: "fragment"
}
}
}
parent.children.splice(nodeIndex, fragmentSection.children.length + 1, fragmentSection);
}
function addVideo (node, ancestors) {
const video = /\.mp4|\.webm|\.ogg/g;
const {value, alt, url} = node;
if(url.match(video) || alt === "video") {
node.type = "video";
node.url = url;
node.data = {
hName: "video",
hProperties: {
alt: alt
}
}
node.children = [{
type: "source",
data: {
hName: "source",
hProperties: {
src: url,
type: `video/${url.match(video)[0].replace(".", "")}`
}
}
}]
}
}
function addAudio (node, ancestors) {
const audio = /\.mp3|\.wav|\.ogg/g;
const {value, alt, url} = node;
if(url.match(audio) || alt === "audio") {
node.type = "audio";
node.url = url;
node.data = {
hName: "audio",
hProperties: {
alt: alt
}
}
node.children = [{
type: "source",
data: {
hName: "source",
hProperties: {
src: url,
type: `audio/${url.match(audio)[0].replace(".", "")}`,
dataAutoplay: false
}
}
}]
}
}
function unwrapMedia (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const hasOnlyMediaNodes = node.children.every((child) => {
return (child.type === "video" || child.type === "audio" ) && child.url
})
if (!hasOnlyMediaNodes) return
parent.children.splice(nodeIndex, 1, ...node.children);
return nodeIndex
}
function wrapSpans (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const attributes = node.data ? node.data.hProperties : {};
if (node.children) {
const text = node.children &&
node.children[0] &&
node.children[0].value;
const data = {
text: text,
id: "",
class: [],
style: "",
...attributes
}
const returnNode = createSpan(data);
parent.children.splice(nodeIndex, 1, returnNode);
}
}
function createSpan (data) {
const classes = data.class ? data.class.join(" ") : "";
const text = data.text;
const id = data.id;
const style = data.style;
return {
type: "html",
value: `<span${id ? ` id="${id}"` : ""}${classes ? ` class="${classes}"` : ""}${style ? ` style="${style}"` : ""}>${text}</span>`
};
}
function addIFrame (node, ancestors) {
const parent = ancestors[ancestors.length - 1];
const nodeIndex = parent.children.indexOf(node);
const url = node.children[0].value.split("iframe:")[1];
if(isURL(url)) {
node.type = "html";
node.value = `<div style="width: 100%; margin: 0;">
<div style="position: relative; padding-bottom: 56.25%; padding-top: 25px; height: 0;">
<iframe style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;" src="${url}"></iframe>
</div>
</div>`;
}
}
module.exports = revealJSPlugin;