Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions aviary/docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ html:
favicon : "aviary_logo.png"
extra_css:
- custom.css
extra_js:
- collapsible_headers.js


launch_buttons:
Expand Down
228 changes: 228 additions & 0 deletions aviary/docs/_static/collapsible_headers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* collapsible_headers.js
*
* Automatically makes every h2-h6 section in a Jupyter Book page
* collapsible without requiring any per-header markup from authors.
*
* How it works:
* 1. On DOMContentLoaded, init() walks every <section> element inside
* the main article container.
* 2. For each section that has a direct h2-h6 child, the DOM is
* restructured: all children are moved into a <details> block whose
* <summary> contains the heading. The section starts open.
* 3. If the page URL contains a fragment (#anchor), openParentsForHash()
* forces any <details> ancestor of that target element to be open so
* the linked content is always visible on arrival.
*
* Styling is handled entirely in custom.css (aviary-collapsible-* classes).
* No external dependencies are required.
*/
(function () {
"use strict";

/*
* getHeadingLevel
*
* Returns the numeric heading level (1-6) for a given element, or null
* if the element is not a heading tag. Used to guard against accidentally
* processing h1 page-title headings.
*
* @param {Element} heading - A DOM element to inspect.
* @returns {number|null}
*/
function getHeadingLevel(heading) {
if (!heading || !heading.tagName) {
return null;
}
var match = heading.tagName.match(/^H([1-6])$/);
return match ? parseInt(match[1], 10) : null;
}

/*
* hasHeadingContent
*
* Returns true if the section element contains at least one child element
* other than the heading itself. Sections that only contain a heading
* (no body content) are left untouched because wrapping them in a
* collapsible <details> would produce an empty, misleading toggle.
*
* @param {Element} section - The <section> element to check.
* @param {Element} heading - The heading element within that section.
* @returns {boolean}
*/
function hasHeadingContent(section, heading) {
if (!section) {
return false;
}

var child = section.firstElementChild;
while (child) {
if (child !== heading) {
return true;
}
child = child.nextElementSibling;
}
return false;
}

/*
* buildCollapsibleSection
*
* Restructures a single <section> into a collapsible <details> block:
*
* Before: After:
* <section> <section>
* <h2>Title</h2> <details open>
* <p>Body...</p> <summary><h2>Title</h2></summary>
* </section> <div class="...-body">
* <p>Body...</p>
* </div>
* </details>
* </section>
*
* All existing children (including the heading) are moved, preserving
* their event listeners and sub-tree structure. The section starts open
* so the page layout is unchanged on first load.
*
* @param {Element} section - The <section> element to transform.
* @param {Element} heading - The direct h2-h6 child to use as the summary.
*/
function buildCollapsibleSection(section, heading) {
if (!section || !heading) {
return;
}

if (!hasHeadingContent(section, heading)) {
return;
}

var details = document.createElement("details");
details.className = "aviary-collapsible-section";
details.open = true; /* start expanded so the page looks unchanged by default */

var summary = document.createElement("summary");
summary.className = "aviary-collapsible-summary";
summary.appendChild(heading);

var body = document.createElement("div");
body.className = "aviary-collapsible-body";

/* Drain all remaining children of the section into the body div.
* We loop on firstChild (not firstElementChild) to also capture
* text nodes and comments that may sit between elements. */
while (section.firstChild) {
body.appendChild(section.firstChild);
}

details.appendChild(summary);
details.appendChild(body);
section.appendChild(details);
}

/*
* openParentsForHash
*
* When a page is loaded with a URL fragment (e.g. page.html#my-section),
* the target element may be inside a collapsed <details> block, making its
* content invisible. This function ensures the anchor destination is always
* fully visible in two steps:
*
* Step 1 — Open the section the anchor belongs to.
* In Sphinx/JupyterBook the anchor id is placed on the <section> element
* itself, not on the heading. After our JS transformation the <details>
* block is a direct child of that <section>, so we look for it with
* :scope > details and open it so the section body is revealed.
*
* Step 2 — Open all ancestor <details> blocks.
* If the target section is nested inside another collapsed section we
* walk up the DOM and force every <details> ancestor open too, ensuring
* the full path from the page root to the destination is visible.
*
* Also registered as a "hashchange" listener so in-page navigation
* (e.g. clicking a TOC link) triggers the same behaviour.
*/
function openParentsForHash() {
if (!window.location.hash) {
return;
}

var target = document.getElementById(window.location.hash.slice(1));
if (!target) {
return;
}

/* Step 1: if the anchor points at a <section> whose content was wrapped
* in a <details> child by buildCollapsibleSection, open that details so
* the body of the destination section is expanded and visible. */
var ownDetails = target.querySelector(":scope > details.aviary-collapsible-section");
if (ownDetails) {
ownDetails.open = true;
}

/* Step 2: walk up the DOM and open every enclosing <details> block so
* that any parent sections hiding this target are also expanded. */
var node = target;
while (node) {
if (node.tagName === "DETAILS") {
node.open = true;
}
node = node.parentElement;
}
}

/*
* init
*
* Entry point. Queries the article element that Jupyter Book / sphinx-book-
* theme places all page content inside, then iterates every <section>
* descendant. Sections are processed deepest-first because
* querySelectorAll returns elements in document order (parent before
* child), but buildCollapsibleSection moves children into a new subtree,
* so inner sections are handled before their parent drains them.
*
* A data attribute (data-aviary-collapsible-processed) is set on each
* section after processing to ensure idempotency in case init is called
* more than once.
*/
function init() {
var article = document.querySelector("main.bd-main article.bd-article");
if (!article) {
return;
}

var sections = article.querySelectorAll("section");
sections.forEach(function (section) {
/* Skip sections that were already transformed. */
if (section.dataset.aviaryCollapsibleProcessed === "true") {
return;
}

/* Only collapse sections whose immediate heading is h2 or deeper;
* h1 is the page title and should never be collapsible. */
var heading = section.querySelector(":scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6");
if (!heading) {
return;
}

var level = getHeadingLevel(heading);
if (level === null || level < 2) {
return;
}

section.dataset.aviaryCollapsibleProcessed = "true";
buildCollapsibleSection(section, heading);
});

openParentsForHash();
window.addEventListener("hashchange", openParentsForHash);
}

/* Run init as soon as the DOM is ready. If the script is deferred or
* placed at the end of <body> the document may already be interactive,
* so we handle both cases. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
130 changes: 130 additions & 0 deletions aviary/docs/_static/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,133 @@
max-width: 100%; /* default is 88rem */
}

/* ============================================================
* Collapsible headers
* Each h2-h6 section is automatically wrapped in a <details>
* element by collapsible_headers.js. The rules below style
* those wrappers. They use PyData Sphinx Theme CSS custom
* properties (--pst-*) for colours, with plain hex fallbacks
* for any browser that does not support the variables.
* ============================================================ */

/* Outer <details> container: subtle border and rounded corners
* to visually group the heading with its content. */
details.aviary-collapsible-section {
margin-bottom: 0.75rem;
border: 1px solid var(--pst-color-border, #d0d7de);
border-radius: 0.3rem;
background-color: var(--pst-color-surface, #ffffff);
}

/* <summary> bar that the user clicks to toggle the section open or closed.
*
* The summary contains two children arranged horizontally by flexbox:
* 1. The disclosure triangle (::before pseudo-element, defined below)
* 2. The heading element (h2–h6, moved here by collapsible_headers.js)
*
* Property-by-property explanation:
*
* cursor: pointer
* Changes the mouse cursor to a hand on hover, making it obvious the
* bar is interactive — the same affordance used on <button> and <a>.
*
* list-style: none
* Browsers (notably Firefox) render a <summary> with a list-item
* bullet by default. This removes it so only our custom triangle
* is shown.
*
* padding: 0.45rem 0.75rem
* Adds breathing room around the triangle and heading text so the
* click target is comfortable and the heading does not touch the
* border of the enclosing <details> box.
*
* display: flex
* Switches the summary's layout to flexbox so its children (triangle
* and heading) can be placed side-by-side and aligned on the same
* baseline rather than flowing as inline text.
*
* align-items: center
* Vertically centres the triangle and heading relative to each other.
* Without this the triangle would sit at the top of the summary bar
* when the heading text wraps to multiple lines.
*
* gap: 0.45rem
* Adds a consistent small space between the triangle and the start
* of the heading text without needing margin on either element. */
details.aviary-collapsible-section > summary.aviary-collapsible-summary {
cursor: pointer;
list-style: none;
padding: 0.45rem 0.75rem;
display: flex;
align-items: center;
gap: 0.45rem;
}

/* Remove the built-in WebKit disclosure triangle so our custom
* ::before triangle is the only indicator shown. */
details.aviary-collapsible-section > summary.aviary-collapsible-summary::-webkit-details-marker {
display: none;
}

/* Disclosure triangle drawn with a clip-path polygon rather than
* a Unicode glyph. A clip-path triangle fills its bounding box
* precisely, so transform-origin: center center rotates around
* the true visual centre of the triangle — something a text glyph
* cannot guarantee because glyphs rarely fill their em-box evenly.
*
* The polygon() points describe a right-pointing triangle:
* top-left → right-middle → bottom-left
*
* When the section is open the triangle is rotated 90° to point
* downward. The CSS transition animates the rotation smoothly. */

/* Insert a generated element at the very beginning of our custom summary bar,
* but only when that summary bar sits directly inside our custom collapsible details block.
* The ::before pseudo-element is what lets us draw the triangle purely in CSS without adding
* any extra HTML markup.
*/
details.aviary-collapsible-section > summary.aviary-collapsible-summary::before {
content: ""; /* no text; shape comes from clip-path */
flex-shrink: 0; /* never let the flex container squash it */
display: inline-block;
width: 0.75rem;
height: 0.75rem;
background-color: var(--pst-color-text-muted, #57606a);
clip-path: polygon(0% 0%, 100% 50%, 0% 100%); /* right-pointing triangle */
transform-origin: center center; /* rotates around true geometric centre */
transition: transform 0.15s ease-in-out;
transform: rotate(90deg); /* default (open) state: pointing down */
}

/* Closed state: triangle returns to pointing right (0°),
* signalling that clicking will expand the section. */
/* The only property in this rule is transform: rotate(0deg), which returns
* the triangle to pointing right. Combined with the open-state rule above it
* (transform: rotate(90deg)), this is how the triangle animates between right (closed)
* and down (open) — the two rules define the two states, and the transition on the
* ::before element smoothly interpolates between them when open is added or removed.
*/
details.aviary-collapsible-section:not([open]) > summary.aviary-collapsible-summary::before {
transform: rotate(0deg);
}

/* Heading elements inside <summary> fill the remaining width
* (flex: 1) and have their default top/bottom margin removed
* so the summary bar stays compact. */
details.aviary-collapsible-section > summary.aviary-collapsible-summary > h2,
details.aviary-collapsible-section > summary.aviary-collapsible-summary > h3,
details.aviary-collapsible-section > summary.aviary-collapsible-summary > h4,
details.aviary-collapsible-section > summary.aviary-collapsible-summary > h5,
details.aviary-collapsible-section > summary.aviary-collapsible-summary > h6 {
display: block;
margin: 0;
flex: 1;
}

/* Body wrapper div that holds all section content below the
* heading. Padding keeps content from touching the container
* edges while the top is left at 0 to sit flush with the summary. */
details.aviary-collapsible-section > .aviary-collapsible-body {
padding: 0 0.9rem 0.9rem;
}

Loading