UNPKG

easy-toc

Version:

Lightweight Vanilla JS Table of Contents

350 lines (266 loc) 8.6 kB
interface TocSettings { include: Array<string>; exclude: Array<string>; prefix: string; hierarchical: boolean; anchor_tagname: string; anchor_class: string; } const d = document; /** * Shorthand for document.querySelector * * @param {string} - a valid querySelector * * @returns {Element | null} */ const _$ = (s: string): Element | null => { return d.querySelector(s); } /** * Shorthand for document.querySelectorAll * * @param {string} - a valid querySelector * * @returns {NodeListOf<Element>} */ const _$$ = (s: string): NodeListOf<Element> => { return d.querySelectorAll(s); } /** * Table of contents Class * * @param {TocSettings} - Object with settings */ class easy_toc { append_to_el: Element; settings: TocSettings; private _includeStr: string; private _excludeStr: string; private _found_selectors: Record<string, unknown>; private _toc_list_element: Element; private _is_rendered: boolean; protected _defaults = { include: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], exclude: [ '[data-no-toc]' ], prefix: 'easy_toc_', hierarchical: true, anchor_tagname: 'div', anchor_class: 'anchor', }; constructor(append_to: string | Element | null, settings: TocSettings) { this.append_to_el = (append_to !== null && typeof append_to === 'string') ? _$(append_to) : append_to as Element; if (!append_to) { this.append_to_el = d.body; } this.settings = this._merge_settings( this._defaults, settings || {} ); this._includeStr = this.settings.include.join(','); this._excludeStr = this.settings.exclude.join(','); // Initialize found Selectors this._found_selectors = {}; this._toc_list_element = null; this._is_rendered = false; } /** * Basic Helper Function to merge user defined settings with the defaults Object * * @param {object} args Arguments to check * * @returns {object} Merged Settings Object * * @since 1.0.0 */ protected _merge_settings(...args: Array<unknown>): TocSettings { const merged = {}; Array.prototype.forEach.call(args, (obj) => { for (const key in obj) { if (!Object.prototype.hasOwnProperty.call(obj, key)) { return; } merged[key] = obj[key]; } }); return merged as TocSettings; } /** * Simple getter for nodes that match with the settings object * * @return {NodeListOf<Element>} Matching Nodes * * @since 1.0.0 */ protected _get_nodes(): NodeListOf<Element> { let getElements = _$$(this._includeStr); if (this.settings.exclude.length > 0) { getElements = Array.prototype.slice.call(getElements).filter( (node) => { if ( ( node.matches || node.matchesSelector || node.msMatchesSelector || node.mozMatchesSelector || node.webkitMatchesSelector || node.oMatchesSelector ).call(node, this._excludeStr) !== true ) { return node; } } ); } return getElements; } /** * Build an object with all available toc anchors * and their related informations * * @since 1.0.0 */ protected _build_node_obj(): Record<string, unknown> { const node_obj = {}; const slugMap = {}; Array.prototype.forEach.call(this._get_nodes(), (node: Element, index: number) => { let cur_heading_level: number; if ( this.settings.hierarchical ) { let tag_name_matches: Array<string>; if ((tag_name_matches = /h\d/i.exec(node.tagName)) !== null) { cur_heading_level = parseInt( tag_name_matches[0].substr(1,1) ); } else { cur_heading_level = null; } } else { cur_heading_level = null; } const slug = this._slugify(node.textContent, 0, slugMap); slugMap[index] = slug; node_obj[index] = { el: node, heading_level: cur_heading_level, className: this.settings.prefix + this.settings.anchor_class, id: this.settings.prefix + slug } }); return node_obj; } /** * Utility for generating unique slugs * * @param str * @param recursionCount * @param slugMap * * @since 1.0.0 */ protected _slugify(str: string, recursionCount: number, slugMap: Record<string, unknown>): string { let slug = str.trim().toLowerCase().split(' ').join('-').replace(/[!@#$%^&*():]/ig, '').replace(/\//ig, '-'); if (Object.values(slugMap).indexOf(slug) > -1) { recursionCount++; slug = this._slugify(str + ' ' + recursionCount, recursionCount, slugMap); } return slug; } /** * Basic helper function to wrap Elements * * @param el {Element} the Element to unwrap * * @since 1.0.0 */ protected _wrap(el: Element, wrapper: Element): void { el.parentNode.insertBefore(wrapper, el); wrapper.appendChild(el); } /** * Basic helper function to unwrap Elements * * @param el {Element} the Element to unwrap * * @since 1.0.0 */ protected _unwrap(el: Element): void { const wrapper = el.parentElement; wrapper.insertAdjacentElement('afterend', el); wrapper.parentNode.removeChild(wrapper); } /** * Render TOC * * @since 1.0.0 */ protected _render(): void { const selectors = Object.values(this._found_selectors); const toc_list_tagname = ( this.settings.hierarchical ) ? 'ol' : 'ul'; const toc_list = d.createElement(toc_list_tagname); toc_list.className = this.settings.prefix + 'list'; this._toc_list_element = toc_list; this.append_to_el.appendChild(toc_list); let prev_heading_level = null; let cur_hierarchical_toc_list = toc_list; let cur_hierarchical_level = 1; // Modify markup of available selectors Array.prototype.forEach.call(selectors, (s: { className: string; id: string; el: Element; heading_level: number; }) => { const toc_anchor_wrapper = d.createElement(this.settings.anchor_tagname); toc_anchor_wrapper.classList.add(s.className); toc_anchor_wrapper.id = s.id; this._wrap(s.el, toc_anchor_wrapper); const toc_list_item = d.createElement('li'); toc_list_item.className = this.settings.prefix + 'list-item'; if ( this.settings.hierarchical && prev_heading_level !== null && prev_heading_level < s.heading_level ) { const hierarchical_toc_list = d.createElement(toc_list_tagname); hierarchical_toc_list.setAttribute('data-level', cur_hierarchical_level.toString()); cur_hierarchical_toc_list.appendChild(hierarchical_toc_list); cur_hierarchical_toc_list = hierarchical_toc_list; cur_hierarchical_level++; cur_hierarchical_toc_list.appendChild(toc_list_item); } else if ( this.settings.hierarchical && prev_heading_level !== null && prev_heading_level == s.heading_level ) { cur_hierarchical_toc_list.appendChild(toc_list_item); } else { // Reset hierarchical List Starting point cur_hierarchical_toc_list = toc_list; toc_list.appendChild(toc_list_item); } const toc_list_item_link = d.createElement('a'); toc_list_item_link.className = this.settings.prefix + 'list-item_link'; toc_list_item_link.href = '#' + s.id; toc_list_item_link.setAttribute('data-heading_level', s.heading_level.toString()); toc_list_item_link.innerText = s.el.textContent; toc_list_item.appendChild(toc_list_item_link); prev_heading_level = s.heading_level; }); this._is_rendered = true; } /** * Method: init * * @since 1.0.0 */ init(): void { // Destroy any existing initialization this.destroy(); this._found_selectors = this._build_node_obj(); this._render(); } /** * Method: update - just an alias for init() * * @since 1.0.0 */ update(): void { this.init(); } /** * Method: destroy * * @returns {void} */ destroy(): void { if (this._is_rendered !== false) { const selectors = Object.values(this._found_selectors); Array.prototype.forEach.call(selectors, (s: { className: string; id: string; el: Element; }) => { this._unwrap(s.el); }); this._toc_list_element.parentNode.removeChild(this._toc_list_element); } this._found_selectors = {}; this._is_rendered = false; } } export default easy_toc;