UNPKG

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
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;