mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-07-06 17:13:24 -05:00
pkgs/nixos-render-docs: add sidebar-open and popover for mobile support
This commit is contained in:
parent
ad97f5573d
commit
e4f7bfed18
5 changed files with 108 additions and 13 deletions
|
|
@ -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
3
doc/nav.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"open": []
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue