339b7de700
CI / lint (push) Successful in 15s
CI / typecheck (push) Successful in 30s
CI / security (push) Successful in 24s
CI / quality (push) Successful in 16s
CI / integration_tests (push) Successful in 5m6s
CI / build (push) Successful in 15s
CI / unit_tests (push) Successful in 16m32s
CI / docker (push) Successful in 9s
CI / coverage (push) Has been cancelled
577 lines
20 KiB
JavaScript
577 lines
20 KiB
JavaScript
/**
|
|
* Collapsible Table of Contents for mkdocs-material with toc.integrate.
|
|
*
|
|
* Behaviour:
|
|
* - ALL nested TOC items start collapsed on page load.
|
|
* - Clicking a parent item toggles it and marks it as "user-pinned" so it
|
|
* stays in whatever state the user chose.
|
|
* - As the reader scrolls, the ancestors of the currently-active TOC link
|
|
* are automatically expanded so the highlighted item is always visible.
|
|
* - When the reader scrolls past a section, any items that were only
|
|
* auto-expanded (not user-pinned) collapse back down.
|
|
* - Works with mkdocs-material instant-loading / SPA navigation.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
/**
|
|
* Set of TOC <li> elements the user has manually expanded.
|
|
* These survive auto-collapse and are only cleared on page navigation.
|
|
*/
|
|
var userPinned = new Set();
|
|
|
|
/**
|
|
* Set of TOC <li> elements that were auto-expanded by the scroll tracker.
|
|
* Cleared and recalculated each time the active link changes.
|
|
*/
|
|
var autoExpanded = new Set();
|
|
|
|
/** Reference to the sidebar element (cached after first lookup). */
|
|
var sidebar = null;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return all ancestor <li.md-nav__item--nested> elements between `el`
|
|
* and the TOC root (the <ul data-md-component="toc"> list).
|
|
*/
|
|
function ancestorItems(el) {
|
|
var ancestors = [];
|
|
var node = el ? el.parentElement : null;
|
|
while (node && node !== sidebar) {
|
|
if (
|
|
node.tagName === "LI" &&
|
|
node.classList.contains("md-nav__item--nested")
|
|
) {
|
|
ancestors.push(node);
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
/**
|
|
* Collapse a nested item (add the CSS class that hides children).
|
|
*/
|
|
function collapse(li) {
|
|
li.classList.add("toc-collapsed");
|
|
}
|
|
|
|
/**
|
|
* Expand a nested item (remove the CSS class so children are visible).
|
|
*/
|
|
function expand(li) {
|
|
li.classList.remove("toc-collapsed");
|
|
}
|
|
|
|
// ── Shared: make nested items collapsible ────────────────────────────
|
|
//
|
|
// Scans `containerEl` for <li> items with nested <nav> children and
|
|
// turns them into collapsible items: adds the --nested class, collapses
|
|
// them, and injects a toggle chevron. This is used both for the active
|
|
// page's built-in TOC (md-nav--secondary) and for TOC items lazily
|
|
// injected into converted single-page nav sections.
|
|
|
|
function makeItemsCollapsible(containerEl, trackPinning) {
|
|
containerEl.querySelectorAll("li.md-nav__item").forEach(function (li) {
|
|
var nestedNav = li.querySelector(":scope > nav.md-nav, :scope > .md-nav");
|
|
if (!nestedNav) return;
|
|
|
|
li.classList.add("md-nav__item--nested");
|
|
|
|
// Collapse everything on load
|
|
collapse(li);
|
|
|
|
// Inject a dedicated toggle button (chevron) into the link row.
|
|
// Clicking the chevron only toggles expand/collapse.
|
|
// Clicking the link text still navigates to the heading.
|
|
var link = li.querySelector(":scope > a.md-nav__link");
|
|
if (link && !link.querySelector(".toc-toggle")) {
|
|
var toggle = document.createElement("span");
|
|
toggle.className = "toc-toggle";
|
|
toggle.setAttribute("role", "button");
|
|
toggle.setAttribute("aria-label", "Toggle section");
|
|
link.appendChild(toggle);
|
|
|
|
toggle.addEventListener("click", function (e) {
|
|
// Stop the click from propagating to the <a> and navigating
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
var isCollapsed = li.classList.contains("toc-collapsed");
|
|
if (isCollapsed) {
|
|
expand(li);
|
|
if (trackPinning) userPinned.add(li);
|
|
} else {
|
|
collapse(li);
|
|
if (trackPinning) userPinned.delete(li);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Initialisation ──────────────────────────────────────────────────
|
|
|
|
function initCollapsibleToc() {
|
|
sidebar = document.querySelector(".md-sidebar--primary");
|
|
if (!sidebar) return;
|
|
|
|
// Reset pinned / auto-expanded tracking on (re-)init
|
|
userPinned.clear();
|
|
autoExpanded.clear();
|
|
|
|
// Find the actual TOC (md-nav--secondary). We scope to the secondary
|
|
// nav to avoid touching the page-level nav items that wrap sections —
|
|
// those use Material's native checkbox-based toggle and should not be
|
|
// overridden.
|
|
//
|
|
// NOTE: The primary nav already has .md-nav--integrated added by
|
|
// mkdocs-material when the toc.integrate feature is enabled. We do
|
|
// NOT re-add that class or extend it to descendant navs — doing so
|
|
// causes the CSS rule ".md-nav--integrated .md-nav__item--nested >
|
|
// .md-nav { display: block }" to override Material's native
|
|
// checkbox-based collapse on primary nav sections (Reference,
|
|
// Development, Architecture Decisions, etc.), creating whitespace
|
|
// below collapsed section headings.
|
|
var tocNav = sidebar.querySelector("nav.md-nav--secondary");
|
|
if (!tocNav) return;
|
|
|
|
// Make all nested TOC items collapsible, with pinning support for
|
|
// the active-link watcher.
|
|
makeItemsCollapsible(tocNav, true);
|
|
|
|
// Kick off the active-link watcher
|
|
watchActiveLink();
|
|
}
|
|
|
|
// ── Active-link watcher ─────────────────────────────────────────────
|
|
//
|
|
// mkdocs-material dynamically toggles `.md-nav__link--active` on the
|
|
// TOC link that corresponds to the heading currently in view. We use
|
|
// a MutationObserver on the TOC container to detect when that class
|
|
// changes and react by expanding / collapsing as needed.
|
|
|
|
var observer = null;
|
|
|
|
function watchActiveLink() {
|
|
// Disconnect any previous observer (e.g. after SPA navigation)
|
|
if (observer) {
|
|
observer.disconnect();
|
|
observer = null;
|
|
}
|
|
|
|
var tocRoot = sidebar
|
|
? sidebar.querySelector("[data-md-component='toc']")
|
|
: null;
|
|
if (!tocRoot) return;
|
|
|
|
// Respond to the current active link immediately
|
|
handleActiveChange(tocRoot);
|
|
|
|
// Observe subtree attribute changes — the theme adds / removes
|
|
// the --active class on <a> elements as the user scrolls.
|
|
observer = new MutationObserver(function () {
|
|
handleActiveChange(tocRoot);
|
|
});
|
|
|
|
observer.observe(tocRoot, {
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["class"],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Called whenever the active TOC link may have changed.
|
|
*
|
|
* 1. Find the currently-active <a>.
|
|
* 2. Determine which ancestor <li>s need to be expanded.
|
|
* 3. Auto-expand those ancestors (unless already open).
|
|
* 4. Auto-collapse any previously auto-expanded items that are no longer
|
|
* ancestors of the active link (but leave user-pinned items alone).
|
|
*/
|
|
function handleActiveChange(tocRoot) {
|
|
var activeLink = tocRoot.querySelector("a.md-nav__link--active");
|
|
|
|
// Determine which nested <li>s must be open for the active link
|
|
var requiredOpen = activeLink ? ancestorItems(activeLink) : [];
|
|
|
|
// Also include the active link's own <li> if it is a nested item
|
|
if (activeLink) {
|
|
var parentLi = activeLink.closest("li.md-nav__item--nested");
|
|
if (parentLi && requiredOpen.indexOf(parentLi) === -1) {
|
|
requiredOpen.push(parentLi);
|
|
}
|
|
}
|
|
|
|
var requiredSet = new Set(requiredOpen);
|
|
|
|
// Collapse items that were auto-expanded last round but are no longer
|
|
// needed — skip anything the user has pinned open.
|
|
autoExpanded.forEach(function (li) {
|
|
if (!requiredSet.has(li) && !userPinned.has(li)) {
|
|
collapse(li);
|
|
}
|
|
});
|
|
|
|
// Build the new auto-expanded set
|
|
autoExpanded = new Set();
|
|
|
|
requiredOpen.forEach(function (li) {
|
|
if (li.classList.contains("toc-collapsed")) {
|
|
expand(li);
|
|
}
|
|
// Track as auto-expanded only if not user-pinned
|
|
if (!userPinned.has(li)) {
|
|
autoExpanded.add(li);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Convert leaf-page nav items into section-like structures ─────────
|
|
//
|
|
// Material renders leaf-page nav items (any <li> with just an <a> link
|
|
// and no nested <nav>) as flat links with no expand/collapse chevron.
|
|
// Only native sections (Reference, Development, Architecture Decisions)
|
|
// get a checkbox + label + nested <nav>.
|
|
//
|
|
// This function converts leaf-page items at two levels:
|
|
// 1. Top-level items (Specification, Work Remaining, FAQ)
|
|
// 2. Children inside native sections (Action CLI, Session Model, etc.)
|
|
//
|
|
// It explicitly avoids touching items inside md-nav--secondary (the TOC)
|
|
// or md-nav--page-toc (our own lazy-loaded TOC containers).
|
|
//
|
|
// The ACTIVE page (wherever it sits in the tree) is treated specially:
|
|
// it becomes an always-expanded, non-collapsible section with a
|
|
// <label> heading instead of an <a>, matching how Material handles
|
|
// native sections that contain the current page.
|
|
|
|
/** Counter for unique checkbox IDs across conversions. */
|
|
var pageNavCounter = 100;
|
|
|
|
/** Cache of fetched TOC HTML keyed by page URL. */
|
|
var tocCache = {};
|
|
|
|
/**
|
|
* Collect the <ul> lists whose direct <li> children should be
|
|
* converted. This includes:
|
|
* - The top-level nav list (direct child of md-nav--primary)
|
|
* - The child lists inside native sections (level-1 <nav> elements
|
|
* that are NOT md-nav--secondary and NOT md-nav--page-toc)
|
|
*/
|
|
function collectConvertibleLists(primaryNav) {
|
|
var lists = [];
|
|
|
|
// 1. Top-level list
|
|
var topList = primaryNav.querySelector(":scope > .md-nav__list");
|
|
if (topList) lists.push(topList);
|
|
|
|
// 2. Lists inside native sections (one level deep)
|
|
if (topList) {
|
|
topList.querySelectorAll(":scope > li.md-nav__item--nested").forEach(function (sectionLi) {
|
|
var sectionNav = sectionLi.querySelector(":scope > nav.md-nav:not(.md-nav--secondary):not(.md-nav--page-toc)");
|
|
if (sectionNav) {
|
|
var sectionList = sectionNav.querySelector(":scope > .md-nav__list");
|
|
if (sectionList) lists.push(sectionList);
|
|
}
|
|
});
|
|
}
|
|
|
|
return lists;
|
|
}
|
|
|
|
/**
|
|
* Convert leaf-page <li> items into section-like expandable structures.
|
|
* Only operates on items in the top-level nav and inside native sections.
|
|
*/
|
|
function convertPageItems() {
|
|
var primaryNav = document.querySelector("nav.md-nav--primary");
|
|
if (!primaryNav) return;
|
|
|
|
var lists = collectConvertibleLists(primaryNav);
|
|
|
|
lists.forEach(function (ul) {
|
|
// Only process DIRECT children of each list
|
|
var children = ul.querySelectorAll(":scope > li.md-nav__item");
|
|
children.forEach(function (li) {
|
|
// Skip items already converted or that are native sections
|
|
if (li.classList.contains("md-nav__item--nested")) return;
|
|
|
|
// Active page items need special handling — always-expanded,
|
|
// non-collapsible, matching native section behaviour.
|
|
if (li.classList.contains("md-nav__item--active")) {
|
|
convertActivePageItem(li);
|
|
return;
|
|
}
|
|
|
|
convertInactivePageItem(li);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert an INACTIVE leaf-page <li> into a section-like structure
|
|
* with a checkbox + label + nested nav that lazy-loads the page's TOC.
|
|
*/
|
|
function convertInactivePageItem(li) {
|
|
var link = li.querySelector(":scope > a.md-nav__link");
|
|
if (!link) return;
|
|
|
|
var href = link.getAttribute("href");
|
|
var text = link.textContent.trim();
|
|
var navId = "__nav_" + (pageNavCounter++);
|
|
|
|
// Build the checkbox
|
|
var checkbox = document.createElement("input");
|
|
checkbox.className = "md-nav__toggle md-toggle";
|
|
checkbox.type = "checkbox";
|
|
checkbox.id = navId;
|
|
|
|
// Build the label (with chevron icon) — identical to native sections
|
|
var label = document.createElement("label");
|
|
label.className = "md-nav__link";
|
|
label.setAttribute("for", navId);
|
|
label.tabIndex = 0;
|
|
|
|
var ellipsis = document.createElement("span");
|
|
ellipsis.className = "md-ellipsis";
|
|
ellipsis.textContent = text;
|
|
label.appendChild(ellipsis);
|
|
|
|
var icon = document.createElement("span");
|
|
icon.className = "md-nav__icon md-icon";
|
|
label.appendChild(icon);
|
|
|
|
// Build the nested nav with the original link as first child
|
|
var nav = document.createElement("nav");
|
|
nav.className = "md-nav md-nav--page-toc";
|
|
nav.setAttribute("data-md-level", "1");
|
|
nav.setAttribute("aria-expanded", "false");
|
|
|
|
var ul = document.createElement("ul");
|
|
ul.className = "md-nav__list";
|
|
|
|
// First child: a link to the page itself (like section index pages)
|
|
var indexLi = document.createElement("li");
|
|
indexLi.className = "md-nav__item";
|
|
var indexLink = document.createElement("a");
|
|
indexLink.className = "md-nav__link";
|
|
indexLink.href = href;
|
|
var indexSpan = document.createElement("span");
|
|
indexSpan.className = "md-ellipsis";
|
|
indexSpan.textContent = text;
|
|
indexLink.appendChild(indexSpan);
|
|
indexLi.appendChild(indexLink);
|
|
ul.appendChild(indexLi);
|
|
|
|
nav.appendChild(ul);
|
|
|
|
// Replace the <li> contents
|
|
li.innerHTML = "";
|
|
li.classList.add("md-nav__item--nested");
|
|
li.appendChild(checkbox);
|
|
li.appendChild(label);
|
|
li.appendChild(nav);
|
|
|
|
// Lazy-load the TOC when first expanded
|
|
checkbox.addEventListener("change", function () {
|
|
if (!checkbox.checked) return;
|
|
if (tocCache[href]) {
|
|
injectToc(ul, tocCache[href]);
|
|
return;
|
|
}
|
|
loadToc(href, ul);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert an ACTIVE page item to look like a native expanded section.
|
|
*
|
|
* Material emits a __toc checkbox + label (with a TOC-list icon) plus
|
|
* an <a> link and the md-nav--secondary TOC nav. We hide the __toc
|
|
* machinery and replace the <a> with a non-navigating <label> that
|
|
* has the same section-heading + chevron appearance as native sections.
|
|
*
|
|
* The TOC stays always-visible and cannot be collapsed — matching how
|
|
* Material treats native sections that contain the current page.
|
|
*/
|
|
function convertActivePageItem(li) {
|
|
// 1. Force-check and hide the __toc checkbox so Material keeps the
|
|
// TOC visible, and prevent any future toggling.
|
|
var tocCheckbox = li.querySelector(":scope > #__toc");
|
|
if (tocCheckbox) {
|
|
tocCheckbox.checked = true;
|
|
tocCheckbox.disabled = true;
|
|
tocCheckbox.style.display = "none";
|
|
}
|
|
|
|
// 2. Hide the __toc label (it has the wrong icon and toggles the
|
|
// checkbox — we don't want either).
|
|
var tocLabel = li.querySelector(':scope > label[for="__toc"]');
|
|
if (tocLabel) {
|
|
tocLabel.style.display = "none";
|
|
}
|
|
|
|
// 3. Mark the <li> as nested + our custom marker so CSS can style it.
|
|
li.classList.add("md-nav__item--nested");
|
|
li.classList.add("md-nav__item--section-page");
|
|
|
|
// 4. Replace the <a> with a non-navigating <label> that matches the
|
|
// exact structure of native section headings.
|
|
var link = li.querySelector(":scope > a.md-nav__link");
|
|
if (link) {
|
|
var text = link.textContent.trim();
|
|
|
|
var heading = document.createElement("label");
|
|
heading.className = "md-nav__link";
|
|
heading.tabIndex = 0;
|
|
|
|
var ellipsis = document.createElement("span");
|
|
ellipsis.className = "md-ellipsis";
|
|
ellipsis.textContent = text;
|
|
heading.appendChild(ellipsis);
|
|
|
|
var icon = document.createElement("span");
|
|
icon.className = "md-nav__icon md-icon";
|
|
heading.appendChild(icon);
|
|
|
|
link.parentNode.replaceChild(heading, link);
|
|
}
|
|
|
|
// 5. Force the TOC nav always visible (belt-and-suspenders alongside
|
|
// the CSS rule).
|
|
var tocNav = li.querySelector(":scope > nav.md-nav--secondary");
|
|
if (tocNav) {
|
|
tocNav.style.display = "block";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch a page and extract its TOC <ul> content, then inject it.
|
|
*/
|
|
function loadToc(href, targetUl) {
|
|
// Add a placeholder
|
|
var placeholder = document.createElement("li");
|
|
placeholder.className = "md-nav__item md-nav__toc-placeholder";
|
|
placeholder.textContent = "Loading…";
|
|
targetUl.appendChild(placeholder);
|
|
|
|
fetch(href)
|
|
.then(function (res) { return res.text(); })
|
|
.then(function (html) {
|
|
// Remove placeholder
|
|
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
|
|
|
|
// Parse the fetched page and extract TOC items
|
|
var parser = new DOMParser();
|
|
var doc = parser.parseFromString(html, "text/html");
|
|
var tocList = doc.querySelector("[data-md-component='toc']");
|
|
if (!tocList) return;
|
|
|
|
// Cache the TOC items (as an array of <li> outerHTML)
|
|
var items = [];
|
|
tocList.querySelectorAll(":scope > li").forEach(function (li) {
|
|
items.push(li.outerHTML);
|
|
});
|
|
tocCache[href] = items;
|
|
injectToc(targetUl, items);
|
|
})
|
|
.catch(function () {
|
|
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inject cached TOC items into the target <ul>.
|
|
*/
|
|
function injectToc(targetUl, items) {
|
|
// Don't inject if already done
|
|
if (targetUl.dataset.tocLoaded) return;
|
|
targetUl.dataset.tocLoaded = "true";
|
|
|
|
items.forEach(function (itemHtml) {
|
|
var temp = document.createElement("template");
|
|
temp.innerHTML = itemHtml;
|
|
var li = temp.content.firstElementChild;
|
|
if (li) {
|
|
// Fix relative anchor links — they need the page href prefix
|
|
li.querySelectorAll("a[href^='#']").forEach(function (a) {
|
|
var pageLink = targetUl.querySelector("li:first-child a");
|
|
if (pageLink) {
|
|
a.href = pageLink.href + a.getAttribute("href");
|
|
}
|
|
});
|
|
targetUl.appendChild(li);
|
|
}
|
|
});
|
|
|
|
// Initialise collapse/expand behaviour on any nested items that
|
|
// were just injected (sub-sections inside the fetched TOC).
|
|
// No pinning tracking — these are non-active pages with no
|
|
// MutationObserver watching them.
|
|
makeItemsCollapsible(targetUl, false);
|
|
}
|
|
|
|
// ── Lock active ancestor sections ────────────────────────────────────
|
|
//
|
|
// When you are viewing a page inside a section (e.g. CI/CD Pipeline
|
|
// inside Development), Material marks every ancestor section <li> with
|
|
// md-nav__item--active and checks its toggle. By default the user can
|
|
// still uncheck (collapse) those sections. We prevent this so that
|
|
// every section on the path to the active page stays permanently open,
|
|
// matching the intuitive expectation that you cannot collapse a section
|
|
// you are currently inside.
|
|
|
|
function lockActiveAncestorSections() {
|
|
var primaryNav = document.querySelector("nav.md-nav--primary");
|
|
if (!primaryNav) return;
|
|
|
|
// Find native sections that are ancestors of the current page.
|
|
// These have both --active (propagated up from the active page)
|
|
// and --nested (they are real sections, not converted page items).
|
|
primaryNav.querySelectorAll(
|
|
"li.md-nav__item--active.md-nav__item--nested:not(.md-nav__item--section-page)"
|
|
).forEach(function (li) {
|
|
var toggle = li.querySelector(":scope > input.md-nav__toggle");
|
|
if (!toggle) return;
|
|
|
|
// Force checked and prevent user from unchecking
|
|
toggle.checked = true;
|
|
|
|
// Intercept click/change to keep it locked
|
|
toggle.addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
});
|
|
toggle.addEventListener("change", function () {
|
|
toggle.checked = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Bootstrap ───────────────────────────────────────────────────────
|
|
|
|
function bootstrap() {
|
|
initCollapsibleToc();
|
|
convertPageItems();
|
|
lockActiveAncestorSections();
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", bootstrap);
|
|
} else {
|
|
bootstrap();
|
|
}
|
|
|
|
// Re-init after instant navigation (mkdocs-material SPA mode)
|
|
if (typeof document$ !== "undefined") {
|
|
document$.subscribe(function () {
|
|
// Reset TOC cache and counter on navigation
|
|
tocCache = {};
|
|
pageNavCounter = 100;
|
|
bootstrap();
|
|
});
|
|
}
|
|
})();
|