markdown-it-table-of-contents
Version:
A Markdown-it plugin for adding a table of contents to markdown documents
341 lines (300 loc) • 10.4 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.
*/
// --- Default helpers and options ---
/**
* Slugify a string to be used as anchor
* @param {string} text Text to slugify
* @param {string} rawToken Raw token to extract text from
* @returns {string} Slugified anchor string
*/
function slugify(text, rawToken) {
return encodeURIComponent(String(text).trim().toLowerCase().replace(/\s+/g, '-'));
};
/**
* Default formatter for headline text
* @param {string} content Text content of the headline
* @param {*} md Markdown-it instance
* @returns {string} Formatted content
*/
function format(content, md) {
return md.renderInline(content);
}
/**
* Generates the opening HTML for a container with a specified class and optional header HTML.
* @param {string} containerClass The CSS class to apply to the container div
* @param {string} containerHeaderHtml Optional HTML to include as the container's header
* @returns {string} HTML string
*/
function transformContainerOpen(containerClass, containerHeaderHtml) {
let tocOpenHtml = '<div class="' + containerClass + '">';
if (containerHeaderHtml) {
tocOpenHtml += containerHeaderHtml;
}
return tocOpenHtml;
};
/**
* Generates the closing HTML / footer for a container
* @param {string} containerFooterHtml The HTML string to be used for closing the container
* @returns {string} HTML string
*/
function transformContainerClose(/** @type {string} */ containerFooterHtml) {
let tocFooterHtml = '';
if (containerFooterHtml) {
tocFooterHtml = containerFooterHtml;
}
return tocFooterHtml + '</div>';
};
/**
* Helper to extract text from tokens, same function as in markdown-it-anchor
* @param {Array<any>} tokens Tokens
* @param {string} rawToken Raw token to extract text from
* @returns {string}
*/
function getTokensText(tokens, rawToken) {
return tokens
.filter(t => ['text', 'code_inline'].includes(t.type))
.map(t => t.content)
.join('')
.trim();
}
const defaultOptions = {
includeLevel: [1, 2],
containerClass: 'table-of-contents',
slugify,
markerPattern: /^\[\[toc\]\]/im,
omitTag: '<!-- omit from toc -->',
listType: 'ul',
format,
containerHeaderHtml: undefined,
containerFooterHtml: undefined,
transformLink: undefined,
transformContainerOpen,
transformContainerClose,
getTokensText
};
// --- Types ---
/**
* @typedef {Object} HeadlineItem
* @property {number} level Headline level
* @property {string | null} anchor Anchor target
* @property {string} text Text of headline
* @property {any | null} token Raw token of headline
*/
/**
* @typedef {Object} TocItem
* @property {number} level Item level
* @property {string} text Text of link
* @property {string | null} anchor Target of link
* @property {Array<TocItem>} children Sub-items for this list item
* @property {TocItem | null} parent Parent this item belongs to
*/
// --- TOC builder ---
/**
* 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) {
/** @type {HeadlineItem[]} */
const headings = [];
/** @type {HeadlineItem | null} */
let currentHeading = null;
tokens.forEach((/** @type {*} */ token, /** @type {number} */ index) => {
if (token.type === 'heading_open') {
const prev = index > 0 ? tokens[index - 1] : null;
if (prev && prev.type === 'html_block' && prev.content.trim().toLowerCase().replace('\n', '') === options.omitTag) {
return;
}
const id = findExistingIdAttr(token);
const level = parseInt(token.tag.toLowerCase().replace('h', ''), 10);
if (levels.indexOf(level) >= 0) {
currentHeading = {
level: level,
text: '',
anchor: id || null,
token: null
};
}
}
else if (currentHeading && token.type === 'inline') {
const textContent = options.getTokensText(token.children, token);
currentHeading.text = textContent;
currentHeading.token = token;
if (!currentHeading.anchor) {
currentHeading.anchor = options.slugify(textContent, token);
}
}
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((/** @type {string | any[]} */ attr) => {
if (Array.isArray(attr) && attr.length >= 2) {
return attr[0] === 'id';
}
return false;
});
if (idAttr && Array.isArray(idAttr) && idAttr.length >= 2) {
const [_, 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 | null} 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
/** @type {TocItem} */
const toc = { level: getMinLevel(headlineItems) - 1, anchor: null, text: '', 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, 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++) {
if (currentRootNode.parent) {
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
* @param {any} options
* @param {any} md
* @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 + '>';
}
const markdownItTableOfContents = function (/** @type {any} */ md, /** @type {any} */ opts) {
const options = Object.assign({}, defaultOptions, opts);
const tocRegexp = options.markerPattern;
/**
* Markdown-it block rule to find [[toc]] markers
* @param {*} state
* @param {*} startLine
* @param {*} endLine
* @param {*} silent
* @returns {boolean}
*/
function toc(state, startLine, endLine, silent) {
let token;
let match;
const start = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// Detect markup
match = tocRegexp.exec(state.src.substring(start, max));
match = !match ? [] : match.filter(function (/** @type {any} */ 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 = match[0];
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 () {
return options.transformContainerOpen(options.containerClass, options.containerHeaderHtml);
};
md.renderer.rules.toc_close = function () {
return options.transformContainerClose(options.containerFooterHtml) + '\n';
};
md.renderer.rules.toc_body = function (/** @type {any} */ tokens) {
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']
});
};
export default markdownItTableOfContents;