pkgs/nixos-render-docs: add auto-highlight toc

Added an intersection observer, that adds 'active' and 'active-trailing'
classes to the toc
This commit is contained in:
Johannes Kirschbauer
2026-07-16 16:06:20 +02:00
parent eba42c0514
commit fc182eeca8
2 changed files with 98 additions and 3 deletions

View File

@@ -156,8 +156,7 @@ body {
}
a {
text-decoration: none;
border-bottom: 1px solid;
text-decoration: underline;
color: var(--link-color);
}
@@ -521,6 +520,44 @@ nav.toc-sidebar li {
nav.toc-sidebar summary {
cursor: pointer;
display: flex;
align-items: center;
list-style: none;
}
nav.toc summary::before {
content: "▸";
flex: none;
font-size: 0.75em;
}
nav.toc details[open] > summary::before {
content: "▾";
}
nav.toc a {
display: block;
width: 100%;
padding: 3px 8px;
border-left: 3px solid transparent;
border-radius: 3px;
color: inherit;
text-decoration: none;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
nav.toc a:hover {
background-color: #f2f2f2;
}
nav.toc a.active {
background-color: #d8d8d8;
border-left-color: #444;
font-weight: 600;
}
nav.toc a.active-trail {
border-left-color: #bbb;
background-color: #e8e8e8;
}
@media screen and (min-width: 768px) {
@@ -553,7 +590,6 @@ nav.toc-sidebar summary {
max-height: none;
overflow-y: auto;
border: none;
border-right: 0.0625rem solid #d8d8d8;
}
/* Hide the toggle button on desktop */

View File

@@ -384,6 +384,64 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
});
});
"""
intersection_observer_js = """
function createObserver() {
const content = document.querySelector(".content");
const headings = content.querySelectorAll("h1, h2, h3, h4, h5, h6");
const links = new Map();
document.querySelectorAll("ol li a").forEach((a) => {
links.set(a.hash.slice(1), a);
});
const visible = new Set();
function setActive(link) {
document
.querySelectorAll("ol.toc a.active, ol.toc a.active-trail")
.forEach((a) => a.classList.remove("active", "active-trail"));
link.classList.add("active");
let li = link.closest("li")?.parentElement.closest("li");
for (; li; li = li.parentElement.closest("li")) {
li.querySelector(":scope > details > summary > a")?.classList.add(
"active-trail",
);
}
}
const handleIntersect = (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target);
else visible.delete(entry.target);
}
const current = [...visible]
.filter((h) => links.has(h.id))
.sort(
(a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top,
)[0];
if (!current) return;
const link = links.get(current.id);
if (link) setActive(link);
};
const observer = new IntersectionObserver(handleIntersect, {
root: content,
rootMargin: "0px 0px -70% 0px",
threshold: 0,
});
[...headings]
.filter((h) => h.id && links.has(h.id))
.forEach((h) => observer.observe(h));
}
document.addEventListener("DOMContentLoaded", createObserver);
"""
header_content = ""
if self._html_params.header:
with open(self._html_params.header) as header_file:
@@ -403,6 +461,7 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
"".join((f'<script src="{html.escape(script, True)}" type="text/javascript"></script>'
for script in scripts)),
f"<script>{close_menu_js}</script>",
f"<script>{intersection_observer_js}</script>",
f' <meta name="generator" content="{html.escape(self._html_params.generator, True)}" />',
f' <link rel="home" href="{home.target.href()}" title="{home.target.title}" />' if home.target.href() else "",
f' {up_link}{prev_link}{next_link}',