pkgs/nixos-render-docs: add sidebar-open and popover for mobile support

This commit is contained in:
Johannes Kirschbauer 2026-07-02 17:17:25 +02:00
commit e4f7bfed18
No known key found for this signature in database
5 changed files with 108 additions and 13 deletions

View file

@ -73,6 +73,7 @@ stdenvNoCC.mkDerivation (
../anchor.min.js
../manpage-urls.json
../redirects.json
../nav.json
]
);
};
@ -117,6 +118,7 @@ stdenvNoCC.mkDerivation (
--script ./anchor.min.js \
--script ./anchor-use.js \
--sidebar-depth 3 \
--nav ./nav.json \
manual.md \
out/index.html

3
doc/nav.json Normal file
View file

@ -0,0 +1,3 @@
{
"open": []
}

View file

@ -131,6 +131,32 @@ body {
padding-left: 1rem;
padding-right: 1rem;
}
/*
See: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
- :popover-open pseudo-class matches a popover element when it is in the showing state
- ::backdrop full-screen element placed directly behind popover
*/
nav.toc-sidebar:popover-open {
position: fixed;
inset: 0 auto 0 0;
width: min(20rem, 85vw);
height: 100dvh;
max-height: none;
margin: 0;
padding: 1rem;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--background);
border: 0;
border-right: 0.0625rem solid #d8d8d8;
box-shadow: 0 0 1.5rem rgb(0 0 0 / 0.35);
}
nav.toc-sidebar::backdrop {
background: rgb(0 0 0 / 0.5);
}
}
a {
@ -486,9 +512,25 @@ div.appendix .variablelist .term {
nav.toc-sidebar {
height: 100%;
overflow-y: none;
padding: 0 1rem 2rem;
border-bottom: 0.0625rem solid #d8d8d8;
}
/* menu button, shown on mobile, hidden on desktop */
.toc-toggle {
display: inline-flex;
align-items: center;
margin: 0.75rem 0 0;
padding: 0.4rem 0.8rem;
border: 0.0625rem solid #d8d8d8;
border-radius: 0.25rem;
background: var(--background);
color: var(--main-text-color);
font: inherit;
cursor: pointer;
position: fixed;
top: 0.5rem;
left: 0.8rem;
z-index: 2;
}
nav.toc-sidebar .toc {
@ -533,14 +575,25 @@ nav.toc-sidebar summary {
}
nav.toc-sidebar {
/* un-pop the drawer */
display: block;
position: static;
inset: auto;
/* */
margin: 0;
grid-column: 1;
grid-row: 2;
max-height: none;
overflow-y: auto;
border-bottom: none;
border: none;
border-right: 0.0625rem solid #d8d8d8;
}
/* Hide the toggle button on desktop */
.toc-toggle {
display: none;
}
main.content {
grid-column: 1 / -1;
grid-row: 2;

View file

@ -264,6 +264,7 @@ class HTMLParameters(NamedTuple):
# structural depth of the navigation sidebar tree
sidebar_depth: int
media_dir: Path
sidebar_open: frozenset[str] = frozenset()
class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
_base_path: Path
@ -367,6 +368,20 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
file.write(self._redirects.get_redirect_script(toc.target.path))
scripts.append(f'./{redirects_name}')
# Register a close handler
# Without this the popover can still be closed by clicking outside of it
# It handles auto-closing when the user clicks a href.
close_menu_js = """
document.addEventListener("DOMContentLoaded", () => {
const nav = document.getElementById("manual-toc");
nav?.addEventListener("click", (e) => {
if (e.target.closest("a[href]") && nav.matches(":popover-open")) {
nav.hidePopover();
}
});
});
"""
return "\n".join([
'<?xml version="1.0" encoding="utf-8" standalone="no"?>',
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"',
@ -380,13 +395,19 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
for style in self._html_params.stylesheets)),
"".join((f'<script src="{html.escape(script, True)}" type="text/javascript"></script>'
for script in scripts)),
f"<script>{close_menu_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}',
' </head>',
' <body>',
# See: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
# Supported by most browsers since 2023, full support since Jan 2025
(' <button type="button" class="toc-toggle" popovertarget="manual-toc"'
' popovertargetaction="toggle" aria-label="Toggle table of contents">'
'☰</button>') if sidebar else "",
nav_html,
f' <nav class="toc-sidebar">{sidebar}</nav>' if sidebar else "",
f' <nav id="manual-toc" class="toc-sidebar" popover="auto">{sidebar}</nav>' if sidebar else "",
' <main class="content">',
])
@ -450,9 +471,10 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
link = f'<a href="{e.target.href()}">{e.target.toc_html}</a>'
cls = html.escape(e.kind, True)
if children:
open_attr = " open" if e.target.id in self._html_params.sidebar_open else ""
items.append(
f'<li class="{cls}">'
f'<details open><summary>{link}</summary>{children}</details>'
f'<details{open_attr}><summary>{link}</summary>{children}</details>'
'</li>'
)
else:
@ -729,6 +751,9 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None:
p.add_argument('--media-dir', default="media", type=Path)
p.add_argument('--redirects', type=Path)
p.add_argument('--sidebar-depth', default=2, type=int)
# nav metadata (JSON): {"open": ["anchor-id", ...]} selects which sidebar
# entries render expanded; omitted or absent means everything is collapsed.
p.add_argument('--nav', type=Path)
# Deprecated flags,
p.add_argument('--toc-depth', nargs='?', action=_DeprecatedDepthFlag, default=None)
p.add_argument('--chunk-toc-depth', nargs='?', action=_DeprecatedDepthFlag, default=None)
@ -738,6 +763,10 @@ def _build_cli_html(p: argparse.ArgumentParser) -> None:
p.add_argument('outfile', type=Path)
def _run_cli_html(args: argparse.Namespace) -> None:
sidebar_open: frozenset[str] = frozenset()
if args.nav:
with open(args.nav) as nav_file:
sidebar_open = frozenset(json.load(nav_file).get("open", []))
with open(args.manpage_urls) as manpage_urls, open(Path(__file__).parent / "redirects.js") as redirects_script:
redirects = None
if args.redirects:
@ -747,7 +776,7 @@ def _run_cli_html(args: argparse.Namespace) -> None:
md = HTMLConverter(
args.revision,
HTMLParameters(args.generator, args.stylesheet, args.script,
args.sidebar_depth, args.media_dir),
args.sidebar_depth, args.media_dir, sidebar_open),
json.load(manpage_urls), redirects)
md.convert(args.infile, args.outfile)

View file

@ -3,7 +3,7 @@ from pathlib import Path
from nixos_render_docs.manual import HTMLConverter, HTMLParameters
def _build(tmp_path: Path, sidebar_depth: int = 2) -> str:
def _build(tmp_path: Path, sidebar_depth: int = 2, sidebar_open: frozenset[str] = frozenset()) -> str:
(tmp_path / "part.md").write_text(
"# Build helpers {#part-builders}\n\n" # -> h2
"```{=include=} chapters\nchapter.md\n```\n"
@ -29,7 +29,7 @@ def _build(tmp_path: Path, sidebar_depth: int = 2) -> str:
out.mkdir(exist_ok=True)
conv = HTMLConverter(
"1.0.0",
HTMLParameters("test-gen", [], [], sidebar_depth, Path("media")),
HTMLParameters("test-gen", [], [], sidebar_depth, Path("media"), sidebar_open),
{},
)
conv.convert(tmp_path / "index.md", out / "index.html")
@ -53,11 +53,12 @@ def test_single_h1_and_flat_heading_levels(tmp_path: Path) -> None:
def test_sidebar_is_collapsible_tree(tmp_path: Path) -> None:
html = _build(tmp_path)
assert '<nav class="toc-sidebar">' in html
assert '<nav id="manual-toc" class="toc-sidebar" popover="auto">' in html
assert 'popovertarget="manual-toc"' in html # the toggle button
assert '<ol class="toc">' in html
# entries with children collapse into <details>, open by default.
assert "<details open><summary>" in html
assert "<details>" not in html # There is no tag without open
# Entries with children collapse into <details>; closed by default.
assert "<details><summary>" in html
assert "<details open>" not in html # nothing opens without nav metadata
# No more inline TOCs, this makes the output visually hard to parse
assert "Table of Contents" not in html
# Sidebar links to structural entries
@ -66,6 +67,13 @@ def test_sidebar_is_collapsible_tree(tmp_path: Path) -> None:
assert 'href="#sec-first"' in html
def test_nav_metadata_opens_selected_entries(tmp_path: Path) -> None:
# ids listed in the nav "open" set render as <details open>
html = _build(tmp_path, sidebar_depth=3, sidebar_open=frozenset({"chap-fpa"}))
assert '<details open><summary><a href="#chap-fpa"' in html
assert '<details><summary><a href="#part-builders"' in html
def test_sidebar_depth_caps_the_tree(tmp_path: Path) -> None:
# sub-a is h3 in a .chapter.md
# with depth=3, it gets listed
@ -105,7 +113,7 @@ def test_chunked_pages_carry_the_sidebar(tmp_path: Path) -> None:
)
conv.convert(tmp_path / "index.md", out / "index.html")
chunk = (out / "chapter.html").read_text()
assert '<nav class="toc-sidebar">' in chunk
assert '<nav id="manual-toc" class="toc-sidebar" popover="auto">' in chunk
assert '<ol class="toc">' in chunk
assert 'href="chapter.html#sec-first"' in chunk
# All headings visible in the chunk sidebar