import clone from "clone"; import uslug from "uslug"; import Token from "markdown-it/lib/token"; const TOC = "@[toc]"; const TOC_RE = /^@\[toc\]/im; let markdownItSecondInstance = () => {}; let headingIds = {}; let tocHtml = ""; const repeat = (string, num) => new Array(num + 1).join(string); const makeSafe = (string, headingIds, slugifyFn) => { const key = slugifyFn(string); // slugify if (!headingIds[key]) { headingIds[key] = 0; } headingIds[key]++; return key + (headingIds[key] > 1 ? `-${headingIds[key]}` : ""); }; const space = () => { return { ...new Token("text", "", 0), content: " " }; }; const renderAnchorLinkSymbol = options => { if (options.anchorLinkSymbolClassName) { return [ { ...new Token("span_open", "span", 1), attrs: [["class", options.anchorLinkSymbolClassName]] }, { ...new Token("text", "", 0), content: options.anchorLinkSymbol }, new Token("span_close", "span", -1) ]; } else { return [ { ...new Token("text", "", 0), content: options.anchorLinkSymbol } ]; } }; const renderAnchorLink = (anchor, options, tokens, idx) => { const attrs = []; if (options.anchorClassName != null) { attrs.push(["class", options.anchorClassName]); } attrs.push(["href", `#${anchor}`]); const openLinkToken = { ...new Token("link_open", "a", 1), attrs }; const closeLinkToken = new Token("link_close", "a", -1); if (options.wrapHeadingTextInAnchor) { tokens[idx + 1].children.unshift(openLinkToken); tokens[idx + 1].children.push(closeLinkToken); } else { const linkTokens = [ openLinkToken, ...renderAnchorLinkSymbol(options), closeLinkToken ]; // `push` or `unshift` according to anchorLinkBefore option // space is at the opposite side. const actionOnArray = { false: "push", true: "unshift" }; // insert space between anchor link and heading ? if (options.anchorLinkSpace) { linkTokens[actionOnArray[!options.anchorLinkBefore]](space()); } tokens[idx + 1].children[actionOnArray[options.anchorLinkBefore]]( ...linkTokens ); } }; const treeToMarkdownBulletList = (tree, indent = 0) => tree .map(item => { const indentation = " "; let node = `${repeat(indentation, indent)}*`; if (item.heading.content) { const contentWithoutAnchor = item.heading.content.replace( /\[([^\]]*)\]\([^)]*\)/g, "$1" ); node += " " + `[${contentWithoutAnchor}](#${item.heading.anchor})\n`; } else { node += "\n"; } if (item.nodes.length) { node += treeToMarkdownBulletList(item.nodes, indent + 1); } return node; }) .join(""); const generateTocMarkdownFromArray = (headings, options) => { const tree = { nodes: [] }; // create an ast headings.forEach(heading => { if ( heading.level < options.tocFirstLevel || heading.level > options.tocLastLevel ) { return; } let i = 1; let lastItem = tree; for (; i < heading.level - options.tocFirstLevel + 1; i++) { if (lastItem.nodes.length === 0) { lastItem.nodes.push({ heading: {}, nodes: [] }); } lastItem = lastItem.nodes[lastItem.nodes.length - 1]; } lastItem.nodes.push({ heading: heading, nodes: [] }); }); return treeToMarkdownBulletList(tree.nodes); }; export default function(md, options) { options = { toc: true, tocClassName: "markdownIt-TOC", tocFirstLevel: 1, tocLastLevel: 6, tocCallback: null, anchorLink: true, anchorLinkSymbol: "#", anchorLinkBefore: true, anchorClassName: "markdownIt-Anchor", resetIds: true, anchorLinkSpace: true, anchorLinkSymbolClassName: null, wrapHeadingTextInAnchor: false, ...options }; markdownItSecondInstance = clone(md); // initialize key ids for each instance headingIds = {}; md.core.ruler.push("init_toc", function(state) { const tokens = state.tokens; // reset key ids for each document if (options.resetIds) { headingIds = {}; } const tocArray = []; let tocMarkdown = ""; let tocTokens = []; const slugifyFn = (typeof options.slugify === "function" && options.slugify) || uslug; for (let i = 0; i < tokens.length; i++) { if (tokens[i].type !== "heading_close") { continue; } const heading = tokens[i - 1]; const heading_close = tokens[i]; if (heading.type === "inline") { let content; if ( heading.children && heading.children.length > 0 && heading.children[0].type === "link_open" ) { // headings that contain links have to be processed // differently since nested links aren't allowed in markdown content = heading.children[1].content; heading._tocAnchor = makeSafe(content, headingIds, slugifyFn); } else { content = heading.content; heading._tocAnchor = makeSafe( heading.children.reduce((acc, t) => acc + t.content, ""), headingIds, slugifyFn ); } if (options.anchorLinkPrefix) { heading._tocAnchor = options.anchorLinkPrefix + heading._tocAnchor; } tocArray.push({ content, anchor: heading._tocAnchor, level: +heading_close.tag.substr(1, 1) }); } } tocMarkdown = generateTocMarkdownFromArray(tocArray, options); tocTokens = markdownItSecondInstance.parse(tocMarkdown, {}); // Adding tocClassName to 'ul' element if ( typeof tocTokens[0] === "object" && tocTokens[0].type === "bullet_list_open" ) { const attrs = (tocTokens[0].attrs = tocTokens[0].attrs || []); if (options.tocClassName != null) { attrs.push(["class", options.tocClassName]); } } tocHtml = markdownItSecondInstance.renderer.render( tocTokens, markdownItSecondInstance.options ); if (typeof state.env.tocCallback === "function") { state.env.tocCallback.call(undefined, tocMarkdown, tocArray, tocHtml); } else if (typeof options.tocCallback === "function") { options.tocCallback.call(undefined, tocMarkdown, tocArray, tocHtml); } else if (typeof md.options.tocCallback === "function") { md.options.tocCallback.call(undefined, tocMarkdown, tocArray, tocHtml); } }); md.inline.ruler.after("emphasis", "toc", (state, silent) => { let token; let match; if ( // Reject if the token does not start with @[ state.src.charCodeAt(state.pos) !== 0x40 || state.src.charCodeAt(state.pos + 1) !== 0x5b || // Don’t run any pairs in validation mode silent ) { return false; } // Detect TOC markdown match = TOC_RE.exec(state.src); match = !match ? [] : match.filter(m => m); if (match.length < 1) { return false; } // Build content token = state.push("toc_open", "toc", 1); token.markup = TOC; token = state.push("toc_body", "", 0); token = state.push("toc_close", "toc", -1); // Update pos so the parser can continue state.pos = state.pos + 6; return true; }); const originalHeadingOpen = md.renderer.rules.heading_open || function(...args) { const [tokens, idx, options, , self] = args; return self.renderToken(tokens, idx, options); }; md.renderer.rules.heading_open = function(...args) { const [tokens, idx, , ,] = args; const attrs = (tokens[idx].attrs = tokens[idx].attrs || []); const anchor = tokens[idx + 1]._tocAnchor; attrs.push(["id", anchor]); if (options.anchorLink) { renderAnchorLink(anchor, options, ...args); } return originalHeadingOpen.apply(this, args); }; md.renderer.rules.toc_open = () => ""; md.renderer.rules.toc_close = () => ""; md.renderer.rules.toc_body = () => ""; if (options.toc) { md.renderer.rules.toc_body = () => tocHtml; } }