markdown-it-table-of-contents
Version:
A Markdown-it plugin for adding a table of contents to markdown documents
277 lines (243 loc) • 8.66 kB
JavaScript
//@ts-check
;
/*
* 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 defaults = {
includeLevel: [1, 2],
containerClass: 'table-of-contents',
slugify: slugify,
markerPattern: /^\[\[toc\]\]/im,
listType: 'ul',
format: function (content, md) {
return md.renderInline(content);
},
forceFullToc: false,
containerHeaderHtml: undefined,
containerFooterHtml: undefined,
transformLink: undefined,
};
/**
* @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<TocItem>} children Sub-items for this list item
* @property {TocItem} parent Parent this item belongs to
*/
/**
* Finds all headline items for the defined levels in a Markdown document.
* @param {Array<number>} levels includeLevels like `[1, 2, 3]`
* @param {*} tokens Tokens gathered by the plugin
* @param {*} options Plugin options
* @returns {Array<HeadlineItem>}
*/
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 = token.children
.filter((childToken) => childToken.type === 'text' || childToken.type === 'code_inline')
.reduce((acc, t) => acc + t.content, '');
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} 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<HeadlineItem>} 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<HeadlineItem>} 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 = '<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 ? `<a href="#${anchor}">${text}</a>` : (text || '');
return li + (childItem.children.length > 0 ? tocItemToHtml(childItem, options, md) : '') + '</li>';
}).join('') + '</' + options.listType + '>';
}
module.exports = function (md, o) {
const options = Object.assign({}, defaults, o);
const tocRegexp = options.markerPattern;
let gstate;
function toc(state, silent) {
let token;
let match;
// Reject if the token does not start with [
if (state.src.charCodeAt(state.pos) !== 0x5B /* [ */) {
return false;
}
// Don't run any pairs in validation mode
if (silent) {
return false;
}
// Detect TOC markdown
match = tocRegexp.exec(state.src.substr(state.pos));
match = !match ? [] : match.filter(function (m) { return 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
var newline = state.src.indexOf('\n', state.pos);
if (newline !== -1) {
state.pos = newline;
} else {
state.pos = state.pos + state.posMax + 1;
}
return true;
}
md.renderer.rules.toc_open = function (tokens, index) {
var tocOpenHtml = '<div class="' + options.containerClass + '">';
if (options.containerHeaderHtml) {
tocOpenHtml += options.containerHeaderHtml;
}
return tocOpenHtml;
};
md.renderer.rules.toc_close = function (tokens, index) {
var tocFooterHtml = '';
if (options.containerFooterHtml) {
tocFooterHtml = options.containerFooterHtml;
}
return tocFooterHtml + '</div>';
};
md.renderer.rules.toc_body = function (tokens, index) {
if (options.forceFullToc) {
throw ("forceFullToc was removed in version 0.5.0. For more information, see https://github.com/Oktavilla/markdown-it-table-of-contents/pull/41");
} else {
const headlineItems = findHeadlineElements(options.includeLevel, gstate.tokens, options);
const toc = flatHeadlineItemsToNestedTree(headlineItems);
const html = tocItemToHtml(toc, options, md);
return html;
}
};
// Catch all the tokens for iteration later
md.core.ruler.push('grab_state', function (state) {
gstate = state;
});
// Insert TOC
md.inline.ruler.after('emphasis', 'toc', toc);
};