UNPKG

@metalsmith/table-of-contents

Version:

A metalsmith plugin to generate table of contents.

140 lines (119 loc) 3.59 kB
const cheerio = require('cheerio') const entities = require('entities') const slugify = require('slugify') const TocItem = require('./TocItem') /** * @param {TocItem[]} tocItems * @returns {TocItem} */ function buildTocItems(tocItems) { if (tocItems.length === 0) return [] const levels = tocItems.map((tocItem) => tocItem.level) let lastLevel = Math.min.apply(null, levels) - 1 const root = new TocItem('', lastLevel - 1) let toc = root tocItems.forEach(function (tocItem) { while (tocItem.level != 1 + lastLevel) { if (tocItem.level < 1 + lastLevel) { toc = toc.parent lastLevel-- } else if (tocItem.level > 1 + lastLevel) { const bridgeToc = new TocItem('', lastLevel) bridgeToc.parent = toc toc.items.push(bridgeToc) toc = bridgeToc lastLevel++ } } lastLevel = tocItem.level toc.items.push(tocItem) tocItem.parent = toc toc = tocItem }) return root } /** * @callback anchorStrategy * @param {import('cheerio').Cheerio} $el */ const anchorStrategy = { /** @type {anchorStrategy} */ keep() { return false }, /** @type {anchorStrategy} */ add($el) { if ($el.attr('id')) return $el.attr('id') return slugify(entities.decodeHTML($el.text()), { lower: true, strict: true, trim: true }) }, /** @type {anchorStrategy} */ overwrite($el) { return slugify(entities.decodeHTML($el.text()), { lower: true, strict: true, trim: true }) } } /** * @typedef {Object} Options * @prop {String} [levels] * @prop {String} [root] * @prop {'add'|'keep'|'overwrite'|anchorStrategy} [anchor] */ /** @type Options */ const defaultOptions = { levels: [2, 3, 4, 5, 6], root: null, anchor: 'add' } /** * @param {Options} options * @returns {Object} */ function normalizeOptions(options) { options = Object.assign({}, defaultOptions, options) if (typeof options.anchor !== 'function') { if (!Object.prototype.hasOwnProperty.call(anchorStrategy, options.anchor)) { throw new Error(`'${options.anchor}' is not a valid value for 'anchor'`) } options.anchor = anchorStrategy[options.anchor] } options.levels = options.levels .map((level) => { return `${options.root ? `${options.root} ` : ''} h${level}` }) .join(',') return options } /** * Initialize the `@metalsmith/table-of-contents` plugin * * @param {Options} options * @returns {import('metalsmith').Plugin} */ module.exports = function initializeTableOfContents(options) { const addAnchors = (options || {}).anchor !== 'keep' options = normalizeOptions(options) return function tableOfContents(files, metalsmith, done) { setImmediate(done) Object.values(files).forEach((file) => { if (!file.toc) return const $ = cheerio.load(file.contents, { decodeEntities: false }, false) const $elements = $(options.levels) const tocItems = $elements .map((i, el) => { const $el = $(el) const hlevel = parseInt($el.prop('tagName').match(/^h([1-6])$/i)[1]) const tocItem = new TocItem(entities.decodeHTML($el.text()), hlevel) tocItem.anchor = options.anchor($el) || '' // side effect in map! if (addAnchors && tocItem.anchor) { if (!$(`#${tocItem.anchor}, [name="${tocItem.anchor}"]`).length) { $el.attr('id', tocItem.anchor, $) } } return tocItem }) .toArray() file.toc = buildTocItems(tocItems) file.contents = Buffer.from($.html()) }) } }