UNPKG

@k9n/scully-plugin-toc

Version:

This plugin for scully provides a postRenderer to generate a table of contents for the rendered route content

120 lines (111 loc) 3.65 kB
import { getPluginConfig, HandledRoute, log, logWarn, yellow, } from '@scullyio/scully'; import { logWarnOnce } from '@scullyio/scully/src/lib/utils'; import { JSDOM } from 'jsdom'; import { TocPluginName } from './constants'; import { Level, TocConfig } from './interfaces'; export const headingLevel = (tag: string): number | null => { const match = tag.match(/(?!h)[123456]/g); return match && match.length ? Number(match[0]) : null; }; export const tocPlugin = async (html: string, routeData: HandledRoute) => { const tocConfig = getPluginConfig<TocConfig>(TocPluginName); const route = routeData.route; try { const dom = new JSDOM(html); const { window } = dom; /** * define insert point */ let tocInsertPointSelector = '#toc'; if (!tocConfig.insertSelector) { logWarn(`No "insertSelector" for "toc" provided, using default: "#id".`); } else { tocInsertPointSelector = tocConfig.insertSelector; } /** * search for insert point */ const insertPoint = window.document.querySelector(tocInsertPointSelector); // in case <div id="toc"></div> is not on the site if (!insertPoint) { logWarn( `Insert point with selector ${tocInsertPointSelector} not found. Skipping toc generation for route ${route}.` ); return html; } /** * get headings for toc generation */ let levels: Level[] = ['h2', 'h3']; if (!tocConfig.level) { logWarn( `Option "level" for "toc" not set, using default: "['h2', 'h3']".` ); } else { levels = tocConfig.level; } const possibleValues = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; let selector = ''; levels.forEach((level) => { const lowerCased = level.toLowerCase(); if (possibleValues.indexOf(lowerCased) === -1) { logWarnOnce( `Level "${level}" is not valid. It should be one of ${JSON.stringify( possibleValues )}.` ); } else { selector += tocConfig.blogAreaSelector ? `${tocConfig.blogAreaSelector} ${lowerCased},` : `${lowerCased},`; } }); // remove leading and trailing comma selector = selector.replace(/(^,)|(,$)/g, ''); const headers = window.document.querySelectorAll(selector); if (!headers.length) { logWarnOnce(`No selector match found for ${selector}.`); } /** * build nested ul, li list */ let previousTag: number | null; let toc = ''; headers.forEach((c: any) => { const level = headingLevel(c.tagName); const trailingSlash = tocConfig.trailingSlash ? '/' : ''; const onClickScrollIntoViewString = tocConfig.scrollIntoViewOnClick ? ` onclick="document.getElementById('${c.id}').scrollIntoView();"` : ''; const baseLiEl = `<li${onClickScrollIntoViewString}><a href="${route}${trailingSlash}#${c.id}">${c.textContent}</a></li>`; if (previousTag && level && level > previousTag) { toc += '<ul style="margin-bottom: 0px">'; } if (previousTag && level && level < previousTag) { toc += '</ul>'; } toc += baseLiEl; previousTag = level; }); /** * append toc as child */ const list = window.document.createElement('ul'); list.innerHTML = toc; insertPoint.appendChild(list); /** * return new serialized HTML */ return dom.serialize(); } catch (e) { logWarn(`error in tocPlugin, didn't parse for route '${yellow(route)}'`); } // in case of failure return unchanged HTML to keep flow going return Promise.resolve(html); };