//@ts-check
'use strict';
/*
* markdown-it-table-of-contents
*
* The algorithm works as follows:
* Step 1: Gather all headline tokens from a Markdown document and put them in an array.
* Step 2: Turn the flat array into a nested tree, respecting the correct headline level.
* Step 3: Turn the nested tree into HTML code.
*/
const slugify = function (s) {
return encodeURIComponent(String(s).trim().toLowerCase().replace(/\s+/g, '-'));
};
const transformContainerOpen = function (containerClass, containerHeaderHtml) {
let tocOpenHtml = '
';
if (containerHeaderHtml) {
tocOpenHtml += containerHeaderHtml;
}
return tocOpenHtml;
};
const transformContainerClose = function (containerFooterHtml) {
let tocFooterHtml = '';
if (containerFooterHtml) {
tocFooterHtml = containerFooterHtml;
}
return tocFooterHtml + '
';
};
const defaultOptions = {
includeLevel: [1, 2],
containerClass: 'table-of-contents',
slugify: slugify,
markerPattern: /^\[\[toc\]\]/im,
listType: 'ul',
format: function (content, md) {
return md.renderInline(content);
},
containerHeaderHtml: undefined,
containerFooterHtml: undefined,
transformLink: undefined,
transformContainerOpen: transformContainerOpen,
transformContainerClose: transformContainerClose,
getTokensText: getTokensText
};
/**
* @typedef {Object} HeadlineItem
* @property {number} level Headline level
* @property {string} anchor Anchor target
* @property {string} text Text of headline
*/
/**
* @typedef {Object} TocItem
* @property {number} level Item level
* @property {string} text Text of link
* @property {string} anchor Target of link
* @property {Array} children Sub-items for this list item
* @property {TocItem} parent Parent this item belongs to
*/
/**
* Helper to extract text from tokens, same function as in markdown-it-anchor
* @returns {string}
*/
function getTokensText(tokens) {
return tokens
.filter(t => ['text', 'code_inline'].includes(t.type))
.map(t => t.content)
.join('');
}
/**
* Finds all headline items for the defined levels in a Markdown document.
* @param {Array} levels includeLevels like `[1, 2, 3]`
* @param {*} tokens Tokens gathered by the plugin
* @param {*} options Plugin options
* @returns {Array}
*/
function findHeadlineElements(levels, tokens, options) {
const headings = [];
let currentHeading = null;
tokens.forEach(token => {
if (token.type === 'heading_open') {
const id = findExistingIdAttr(token);
const level = parseInt(token.tag.toLowerCase().replace('h', ''), 10);
if (levels.indexOf(level) >= 0) {
currentHeading = {
level: level,
text: null,
anchor: id || null
};
}
}
else if (currentHeading && token.type === 'inline') {
const textContent = options.getTokensText(token.children);
currentHeading.text = textContent;
if (!currentHeading.anchor) {
currentHeading.anchor = options.slugify(textContent, token.content);
}
}
else if (token.type === 'heading_close') {
if (currentHeading) {
headings.push(currentHeading);
}
currentHeading = null;
}
});
return headings;
}
/**
* Helper to find an existing id attr on a token. Should be a heading_open token, but could be anything really
* Provided by markdown-it-anchor or markdown-it-attrs
* @param {any} token Token
* @returns {string | null} Id attribute to use as anchor
*/
function findExistingIdAttr(token) {
if (token && token.attrs && token.attrs.length > 0) {
const idAttr = token.attrs.find((attr) => {
if (Array.isArray(attr) && attr.length >= 2) {
return attr[0] === 'id';
}
return false;
});
if (idAttr && Array.isArray(idAttr) && idAttr.length >= 2) {
const [key, val] = idAttr;
return val;
}
}
return null;
}
/**
* Helper to get minimum headline level so that the TOC is nested correctly
* @param {Array} headlineItems Search these
* @returns {number} Minimum level
*/
function getMinLevel(headlineItems) {
return Math.min(...headlineItems.map(item => item.level));
}
/**
* Helper that creates a TOCItem
* @param {number} level
* @param {string} text
* @param {string} anchor
* @param {TocItem} rootNode
* @returns {TocItem}
*/
function addListItem(level, text, anchor, rootNode) {
const listItem = { level, text, anchor, children: [], parent: rootNode };
rootNode.children.push(listItem);
return listItem;
}
/**
* Turns a list of flat headline items into a nested tree object representing the TOC
* @param {Array} headlineItems
* @returns {TocItem} Tree of TOC items
*/
function flatHeadlineItemsToNestedTree(headlineItems) {
// create a root node with no text that holds the entire TOC. this won't be rendered, but only its children
const toc = { level: getMinLevel(headlineItems) - 1, anchor: null, text: null, children: [], parent: null };
// pointer that tracks the last root item of the current list
let currentRootNode = toc;
// pointer that tracks the last item (to turn it into a new root node if necessary)
let prevListItem = currentRootNode;
headlineItems.forEach(headlineItem => {
// if level is bigger, take the previous node, add a child list, set current list to this new child list
if (headlineItem.level > prevListItem.level) {
// eslint-disable-next-line no-unused-vars
Array.from({ length: headlineItem.level - prevListItem.level }).forEach(_ => {
currentRootNode = prevListItem;
prevListItem = addListItem(headlineItem.level, null, null, currentRootNode);
});
prevListItem.text = headlineItem.text;
prevListItem.anchor = headlineItem.anchor;
}
// if level is same, add to the current list
else if (headlineItem.level === prevListItem.level) {
prevListItem = addListItem(headlineItem.level, headlineItem.text, headlineItem.anchor, currentRootNode);
}
// if level is smaller, set current list to currentlist.parent
else if (headlineItem.level < prevListItem.level) {
for (let i = 0; i < prevListItem.level - headlineItem.level; i++) {
currentRootNode = currentRootNode.parent;
}
prevListItem = addListItem(headlineItem.level, headlineItem.text, headlineItem.anchor, currentRootNode);
}
});
return toc;
}
/**
* Recursively turns a nested tree of tocItems to HTML.
* @param {TocItem} tocItem
* @returns {string}
*/
function tocItemToHtml(tocItem, options, md) {
return '<' + options.listType + '>' + tocItem.children.map(childItem => {
let li = '';
let anchor = childItem.anchor;
if (options && options.transformLink) {
anchor = options.transformLink(anchor);
}
let text = childItem.text ? options.format(childItem.text, md, anchor) : null;
li += anchor ? `${text}` : (text || '');
return li + (childItem.children.length > 0 ? tocItemToHtml(childItem, options, md) : '') + '';
}).join('') + '' + options.listType + '>';
}
module.exports = function (md, opts) {
const options = Object.assign({}, defaultOptions, opts);
const tocRegexp = options.markerPattern;
function toc(state, startLine, endLine, silent) {
let token;
let match;
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// Reject if the token does not start with [
if (state.src.charCodeAt(start) !== 0x5B /* [ */) {
return false;
}
// Detect [[toc]] markup
match = tocRegexp.exec(state.src.substring(start, max));
match = !match ? [] : match.filter(function (m) { return m; });
if (match.length < 1) {
return false;
}
if (silent) {
return true;
}
state.line = startLine + 1
// Build content
token = state.push('toc_open', 'toc', 1);
token.markup = '[[toc]]';
token.map = [startLine, state.line];
token = state.push('toc_body', '', 0);
token.markup = ''
token.map = [startLine, state.line];
token.children = [];
token = state.push('toc_close', 'toc', -1);
token.markup = '';
return true;
}
md.renderer.rules.toc_open = function (tokens, index) {
return options.transformContainerOpen(options.containerClass, options.containerHeaderHtml);
};
md.renderer.rules.toc_close = function (tokens, index) {
return options.transformContainerClose(options.containerFooterHtml) + '\n';
};
md.renderer.rules.toc_body = function (tokens, index) {
const headlineItems = findHeadlineElements(options.includeLevel, tokens, options);
const tocTree = flatHeadlineItemsToNestedTree(headlineItems);
const html = tocItemToHtml(tocTree, options, md);
return html;
};
md.block.ruler.before('heading', 'toc', toc, {
alt: ['paragraph', 'reference', 'blockquote']
});
};