ToC = {
const registry = new Set();
invalidation.then(() => registry.clear());
return function ToC({
headers: selectors = ["h2", "h3", "h4", "h5", "h6"],
title,
filter,
ID: id = false,
root = (document.body.querySelector(".observablehq-root") ?? document.body),
class: className,
view = false,
scroll: {
behavior: scrollBehavior = "smooth",
block: scrollBlock = "start",
inline: scrollInline = "start"
} = {},
order = false,
orderDepth,
modify,
maxLength: undefined,
invalidation
} = {}) {
const scrollOpts = id ? undefined : Object.freeze({
behavior: scrollBehavior,
block: scrollBlock,
inline: scrollInline
});
scrollBehavior = scrollBlock = scrollInline = undefined;
if (typeof selectors == "string" || selectors instanceof String) {
selectors = [selectors];
} else if (!Array.isArray(selectors) && typeof selectors == "object" && Symbol.iterator in selectors) {
selectors = [...selectors];
}
// ## initialise filter
// allow filter to be specified as either a string or string array to exclude or a function to include
// The filter ends up being a function or falsy.
if (filter && !(typeof filter === "function" || filter instanceof Function)) {
if (Array.isArray(filter) || (filter != null && !(typeof filter == "string" || filter instanceof String) && Symbol.iterator in filter)) {
let filterElements = new Set((Array.isArray(filter) ? filter : [...filter]).map(s => `${s}`.trim()));
filter = ({textContent: text}) => !filterElements.has(text.trim());
} else {
let filterElement = filter;
filter = ({textContent: text}) => filterElement != text.trim();
}
}
// ## initialise order & orderDepth
if ((typeof order == "number" || order instanceof Number) && (order = order.valueOf()) >= 0 && Number.isInteger(order)) {
order = order.valueOf();
orderDepth = (typeof orderDepth == "number" || orderDepth instanceof Number) ? orderDepth.valueOf() : Infinity;
orderDepth == 0 && (order = false);
} else {
order = false;
orderDepth = undefined;
}
// # construct contents
const contents = document.createElement(title ? "section" : order !== false ? "ol" : "ul");
const rootList = title ? document.createElement(order !== false ? "ol" : "ul") : contents;
(order !== false && order != 1) && (rootList.start = order);
if (title) {
if (title instanceof Node) {
contents.append(title);
} else {
let titleElm = document.createElement("strong");
titleElm.append(title);
contents.append(titleElm);
}
contents.append(rootList);
}
className && contents.classList.append(...(Array.isArray(className) ? className : (className = [className])));
view && Object.defineProperty(contents, "value", {
get: function value() {
return headings.map(heading => [heading, selectors.findIndex(selector => heading.matches(selector)) - 1]);
}
});
// # create updater
let cleanUp;
const targets = id ? undefined : new WeakMap(); // map each anchor to its heading element
const headings = [];
const update = () => {
if (!invalidation && !contents.isConnected) {
return cleanUp?.();
}
let newHeadings = [...root.querySelectorAll(selectors.join(", "))]
.filter(h => [...registry].every(c => !c.contains(h)));
filter && (newHeadings = newHeadings.filter(filter));
if ( // check whether the list is empty or identical to before
!newHeadings.length ||
(headings.length == newHeadings.length
&& !headings.some((h, i) => newHeadings[i] !== h))
) { return; }
targets?.clear?.();
headings.splice(0, Infinity, ...newHeadings);
let baseLevel = 0;
let curLevel;
let curList = document.createElement((order !== false && (baseLevel <= orderDepth)) ? "ol" : "ul");
// (order !== false) && (curList.dataset.depthA = baseLevel);
(order !== false && order != 1) && (curList.start = order);
for (let heading of headings) {
// find the index of the first matching selector
let nodeLevel = selectors.findIndex(selector => heading.matches(selector)) - 1;
if (curLevel == undefined) { // The first header isn’t top-level.
curLevel = baseLevel;
while (nodeLevel > curLevel) { // indent
curLevel++;
let subList = document.createElement((order !== false && (curLevel <= orderDepth)) ? "ol" : "ul");
// (order !== false) && (curList.dataset.depthB = curLevel);
(order !== false && order != 1) && (subList.start = order);
curList.append(subList);
curList = subList;
}
} else {
while (curLevel < nodeLevel) { // indent
curLevel++;
let subList = document.createElement((order !== false && (curLevel <= orderDepth)) ? "ol" : "ul");
// (order !== false) && (curList.dataset.depthC = curLevel);
(order !== false && order != 1) && (subList.start = order);
curList.append(subList);
curList = subList;
}
while (curLevel > nodeLevel) { // unindent
curLevel--;
curList = curList.parentElement ?? curList;
}
}
let listItem = document.createElement("li");
let link = document.createElement("a");
link.append(...[...heading.childNodes].map(n => n.cloneNode(Infinity)));
listItem.append(link);
if (id) {
let elmID = heading.id || (heading.id = DOM.uid().id);
let loc = new URL(window.location);
loc.search = "";
loc.hash = elmID;
link.href = loc;
} else {
link.href = "#";
targets.set(link, heading);
}
modify?.call(undefined, {heading, link, listItem});
curList.append(listItem);
}
rootList.replaceChildren(...curList.childNodes);
view && contents.dispatchEvent(new Event("input"));
};
// # initialise MutationObserver
const watcher = new MutationObserver(update);
{
let listener = id ? undefined : ["click", event => {
let linked = targets.has(event.target);
let target = linked ? event.target : event.target.closest("a");
if (target && (linked ||= targets.has(target))) {
event.preventDefault();
targets.get(target).scrollIntoView(scrollOpts);
}
}, { passive: false }];
listener && contents.addEventListener(...listener);
cleanUp = () => {
cleanUp = undefined;
registry.delete(contents);
listener && contents.removeEventListener(...listener);
watcher.disconnect();
headings.length = selectors.length = 0;
};
}
invalidation?.then(cleanUp);
watcher.observe(root, { childList: true, subtree: true, characterData: true });
registry.add(contents);
return contents;
};
}