Files
freemo e40dd75478
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 20s
CI / quality (pull_request) Successful in 20s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 43s
CI / typecheck (pull_request) Successful in 53s
CI / integration_tests (pull_request) Successful in 4m16s
CI / unit_tests (pull_request) Successful in 5m51s
CI / docker (pull_request) Successful in 8s
CI / benchmark-regression (pull_request) Successful in 17m24s
CI / coverage (pull_request) Successful in 21m32s
CI / lint (push) Successful in 14s
CI / build (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 27s
CI / typecheck (push) Successful in 42s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m40s
CI / benchmark-publish (push) Successful in 8m35s
CI / unit_tests (push) Successful in 9m44s
CI / docker (push) Successful in 51s
CI / coverage (push) Successful in 22m30s
Docs: Fixed broken navigation menu on mobile
2026-02-22 01:15:07 -05:00

599 lines
21 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;
});
});
}
// ── Desktop detection ────────────────────────────────────────────────
//
// This entire script is designed for the desktop persistent sidebar
// (≥76.25em). On mobile, Material uses a slide-out drawer with its
// own drill-down navigation, scroll management, and back-button
// behaviour. Running our DOM restructuring (convertPageItems,
// initCollapsibleToc, lockActiveAncestorSections) on mobile breaks
// the drawer's scroll container and makes it impossible to scroll
// past the visible items.
//
// We use matchMedia to detect the breakpoint and only bootstrap on
// desktop. On resize/orientation changes that cross the breakpoint,
// the page reloads via Material's SPA system anyway, so we don't need
// a live resize listener.
var desktopQuery = window.matchMedia("(min-width: 76.25em)");
function isDesktop() {
return desktopQuery.matches;
}
// ── Bootstrap ───────────────────────────────────────────────────────
function bootstrap() {
if (!isDesktop()) return; // skip all DOM manipulation on mobile
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();
});
}
})();