From fc182eeca8e64efffee808dd0736b53a4f7e0a14 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Thu, 16 Jul 2026 16:06:20 +0200 Subject: [PATCH] pkgs/nixos-render-docs: add auto-highlight toc Added an intersection observer, that adds 'active' and 'active-trailing' classes to the toc --- doc/style.css | 42 ++++++++++++- .../src/nixos_render_docs/manual.py | 59 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/doc/style.css b/doc/style.css index 9410d39e54be..c844d85e9fb7 100644 --- a/doc/style.css +++ b/doc/style.css @@ -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 */ diff --git a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py index 9177b0ad5202..4ef5d68f2d88 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/manual.py @@ -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'' for script in scripts)), f"", + f"", f' ', f' ' if home.target.href() else "", f' {up_link}{prev_link}{next_link}',