Indented ToC

This notebook can generate a Table of Contents (ToC), with indentations, automatically for your notebook.

This notebook is a fork of Bryan Hughes’s Intended ToC with option to exclude headings from selected DOM elements.

Note: Clicking on links in Safari doesn't work because scrollIntoView in an iFrame doesn't really work correctly`

Usage

Import this notebook with:

  \`\`\`js
  import {toc} from "@saneef/indented-toc"
  \`\`\`

Basic Example

You can create the table of contents for a notebook with the following call:

  \`\`\`JavaScript
  toc()
  \`\`\`

This call produces the following ToC for this notebook:

Customizing Included Header Levels

If you don't like which header levels are included in the ToC by default, you can customize which header levels to include:

  \`\`\`JavaScript
  toc("h2,h3,h4")
  \`\`\`

or:

  \`\`\`JavaScript
  toc({
    headers: "h2,h3,h4"
  })
  \`\`\`

This call produces the following ToC for this notebook:

Hiding an Appendix

If your notebooks are like mine, you might have a section titled "appendix," "implementation," or similar section to house implementation details. You can hide this section with the following call:

  \`\`\`JavaScript
  toc({
    hideStartingFrom: "Implementation"
  })
  \`\`\`

This call produces the following ToC for this notebook:

Customizing or Hiding the Title

If you would like to change the title, you can change it with:

  \`\`\`JavaScript
  toc({
    title: "My Table of Contents"
  })
  \`\`\`

This call produces the following ToC for this notebook:

You can also hide the title by passing in `null` for the title value:

  \`\`\`JavaScript
  toc({
    title: null
  })
  \`\`\`

This call produces the following ToC for this notebook:

const hide_title_toc = toc({
  title: null
})

Excluding headings with a DOM element

If you would like to ignore headings with a DOM element. ```html

Not important heading

```

  \`\`\`JavaScript
  toc({
    exclude: ".ignore-in-toc"
  })
  \`\`\`

This call produces the following ToC for this notebook:

const exclude_toc = toc({
  exclude: ".ignore-in-toc"
})

Bringing it All Together

All of the options can be combined together to create a more highly customized ToC

\`\`\`JavaScript
toc({
  headers: "h2,h3,h4",
  title: "My ToC",
  hideStartingFrom: "Implementation"
})
\`\`\`

This call produces the following ToC for this notebook:

const combined_toc = toc({
  headers: "h2,h3,h4",
  title: "My ToC",
  hideStartingFrom: "Implementation"
})

API

The `toc` method has the following signature:

\`\`\`TypeScript
function toc(
  options?: string | { headers?: string, title?: string | null, hideStartingFrom?: string, exclude?: string}
): MutationObserver
\`\`\`

Note: This signature is written using TypeScript syntax.

Options has the following defaults:

Implementation

function toc(options = {}) {
  if (typeof options === "string") options = { headers: options };

  const {
    headers = "h1,h2,h3",
    hideStartingFrom = null,
    title = "Table of Contents",
    exclude
  } = options;

  return Generators.observe((notify) => {
    let previousHeadings = [];
    let renderedEmptyToC = false;

    const ensureId = (el) => {
      if (el.id) return el.id;
      const base = (el.textContent || "").trim().toLowerCase()
        .replace(/\s+/g, "-")
        .replace(/[^a-z0-9\-]/g, "")
        .replace(/\-+/g, "-")
        .replace(/^\-|\-$/g, "") || "section";
      let id = base, i = 1;
      while (document.getElementById(id)) id = `${base}-${i++}`;
      el.id = id;
      return id;
    };

    function observed() {
      let currentHeadings = Array.from(document.querySelectorAll(headers));

      if (exclude) {
        currentHeadings = currentHeadings.filter((h) => !h.closest(exclude));
      }

      // Nothing to render
      if (!currentHeadings.length) {
        if (!renderedEmptyToC) {
          notify(html`<div>Unable to generate ToC: no headings found</div>`);
          renderedEmptyToC = true;
        }
        return;
      }

      // Bail if unchanged
      if (
        currentHeadings.length === previousHeadings.length &&
        !currentHeadings.some((h, i) => previousHeadings[i] !== h)
      ) return;

      renderedEmptyToC = false;
      previousHeadings = currentHeadings.slice();

      // Determine the leftmost level (e.g., 2 for h2)
      const startIndentation = headers
        .split(",")
        .map((h) => parseInt(h.replace(/h/gi, ""), 10))
        .filter((n) => !Number.isNaN(n))
        .sort((a, b) => a - b)[0] ?? 1;

      // Build the nested list
      const container = document.createElement("div");
      const frag = document.createDocumentFragment();

      if (title) frag.append(html`<b>${DOM.text(title)}</b>`);

      let currentIndentation;
      let ulStack = [];

      const openUl = () => {
        const ul = document.createElement("ul");
        (ulStack[ulStack.length - 1] ?? frag).appendChild(ul);
        ulStack.push(ul);
      };
      const closeUl = () => { ulStack.pop(); };

      for (const h of currentHeadings) {
        if (hideStartingFrom && h.textContent === hideStartingFrom) break;

        const nodeIndentation = parseInt(h.tagName[1], 10);

        if (typeof currentIndentation === "undefined") {
          currentIndentation = startIndentation;
          // open lists until we reach the first heading level
          while (nodeIndentation > currentIndentation) {
            openUl();
            currentIndentation++;
          }
          if (ulStack.length === 0) openUl(); // at least one UL
        } else {
          while (currentIndentation < nodeIndentation) {
            openUl();
            currentIndentation++;
          }
          while (currentIndentation > nodeIndentation) {
            closeUl();
            currentIndentation--;
          }
          if (ulStack.length === 0) openUl(); // safety
        }

        const id = ensureId(h);
        const li = html`<li><a href="#${id}">${DOM.text(h.textContent)}</a></li>`;
        li.onclick = (e) => {
          // preserve anchor while making scrolling smooth
          // (browser default jump works too if you prefer)
          e.preventDefault();
          document.getElementById(id)?.scrollIntoView();
          history.replaceState(null, "", `#${id}`);
        };
        ulStack[ulStack.length - 1].append(li);
      }

      // Close down to the start level
      while ((currentIndentation ?? startIndentation) > startIndentation) {
        closeUl();
        currentIndentation--;
      }

      container.append(frag);
      notify(container);
    }

    const observer = new MutationObserver(observed);
    observer.observe(document.body, { childList: true, subtree: true });
    observed();
    return () => observer.disconnect();
  });
}

Appendix