easy-toc
Version:
Lightweight Vanilla JS Table of Contents
350 lines (266 loc) • 8.6 kB
text/typescript
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;