pkgs/nixos-render-docs: replace inline tocs with collapsible sidebar (#537810)

This commit is contained in:
Johannes Kirschbauer
2026-07-03 09:00:52 +00:00
committed by GitHub
10 changed files with 309 additions and 149 deletions
+3 -2
View File
@@ -73,6 +73,7 @@ stdenvNoCC.mkDerivation (
../anchor.min.js
../manpage-urls.json
../redirects.json
../nav.json
]
);
};
@@ -116,8 +117,8 @@ stdenvNoCC.mkDerivation (
--script ./highlightjs/loader.js \
--script ./anchor.min.js \
--script ./anchor-use.js \
--toc-depth 1 \
--section-toc-depth 1 \
--sidebar-depth 3 \
--nav ./nav.json \
manual.md \
out/index.html
+2 -2
View File
@@ -125,7 +125,7 @@ There are 2 ways to package backend dependencies: either per-dependency mix2nix
When writing an elixir project targeting `mixRelease`, you can also consider using [deps_nix](https://github.com/code-supply/deps_nix) with `mixNixDeps`. `deps_nix` supports git dependencies, but is intended to be added to the project's `mix.exs` directly.
###### mix2nix {#mix2nix}
##### mix2nix {#mix2nix}
`mix2nix` is a cli tool available in Nixpkgs. It will generate a Nix expression from a `mix.lock` file. It is quite standard in the 2nix tool series.
@@ -175,7 +175,7 @@ If there are git dependencies.
You will need to run the build process once to fix the hash to correspond to your new git src.
###### FOD {#fixed-output-derivation}
##### FOD {#fixed-output-derivation}
A fixed output derivation will download mix dependencies from the internet. To ensure reproducibility, a hash will be supplied. Note that mix is relatively reproducible. An FOD generating a different hash on each run hasn't been observed (as opposed to npm where the chances are relatively high). See [akkoma](https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ak/akkoma/package.nix) for a usage example of FOD.
+3
View File
@@ -0,0 +1,3 @@
{
"open": []
}
+70 -26
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 {
@@ -387,15 +413,6 @@ div.appendix dt {
margin-top: 1em;
}
div.book .toc dt,
div.appendix .toc dt {
margin-top: 0;
}
.list-of-examples dt {
margin-top: 0;
}
div.book code,
div.appendix code {
padding: 0;
@@ -408,17 +425,6 @@ div.appendix code {
hyphens: none;
}
div.book div.toc,
div.appendix div.toc {
margin-bottom: 3em;
border-bottom: 0.0625rem solid #d8d8d8;
}
div.book div.toc dd,
div.appendix div.toc dd {
margin-left: 2em;
}
div.book span.command,
div.appendix span.command {
font-family: monospace;
@@ -506,21 +512,48 @@ 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 {
margin-bottom: 0;
}
nav.toc-sidebar .toc dl {
nav.toc-sidebar ol.toc,
nav.toc-sidebar ol.toc ol {
list-style: none;
margin: 0;
padding-left: 0;
}
nav.toc-sidebar ol.toc ol {
padding-left: 1em;
}
nav.toc-sidebar li {
margin: 0;
}
nav.toc-sidebar .toc dd {
margin-left: 1em;
nav.toc-sidebar summary {
cursor: pointer;
}
@media screen and (min-width: 768px) {
@@ -542,14 +575,25 @@ nav.toc-sidebar .toc dd {
}
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;
+1 -2
View File
@@ -201,8 +201,7 @@ rec {
--script ./highlightjs/loader.js \
--script ./anchor.min.js \
--script ./anchor-use.js \
--toc-depth 1 \
--chunk-toc-depth 1 \
--sidebar-depth 2 \
./manual.md \
$dst/${common.indexPath}
@@ -1,12 +1,13 @@
from collections.abc import Mapping, Sequence
from typing import cast, Optional, NamedTuple
from html import escape
from typing import NamedTuple, Optional, cast
from markdown_it.token import Token
from .manual_structure import XrefTarget
from .md import Renderer
class UnresolvedXrefError(Exception):
pass
@@ -17,9 +18,6 @@ class Heading(NamedTuple):
# special handling for part content: whether partinfo div was already closed from
# elsewhere or still needs closing.
partintro_closed: bool
# tocs are generated when the heading opens, but have to be emitted into the file
# after the heading titlepage (and maybe partinfo) has been closed.
toc_fragment: str
_bullet_list_styles = [ 'disc', 'circle', 'square' ]
_ordered_list_styles = [ '1', 'a', 'i', 'A', 'I' ]
@@ -29,7 +27,6 @@ class HTMLRenderer(Renderer):
_headings: list[Heading]
_attrspans: list[str]
_hlevel_offset: int = 0
_bullet_list_nesting: int = 0
_ordered_list_nesting: int = 0
@@ -185,8 +182,7 @@ class HTMLRenderer(Renderer):
anchor = f'id="{escape(anchor, True)}"'
result = self._close_headings(hlevel)
tag = self._heading_tag(token, tokens, i)
toc_fragment = self._build_toc(tokens, i)
self._headings.append(Heading(tag, hlevel, htag, tag != 'part', toc_fragment))
self._headings.append(Heading(tag, hlevel, htag, tag != 'part'))
return (
f'{result}'
f'<div class="{tag}">'
@@ -205,8 +201,6 @@ class HTMLRenderer(Renderer):
)
if heading.container_tag == 'part':
result += '<div class="partintro">'
else:
result += heading.toc_fragment
return result
def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int) -> str:
extra = 'compact' if token.meta.get('compact', False) else ''
@@ -329,14 +323,14 @@ class HTMLRenderer(Renderer):
)
def _make_hN(self, level: int) -> tuple[str, str]:
return f"h{min(6, max(1, level + self._hlevel_offset))}", ""
return f"h{min(6, max(1, level))}", ""
def _maybe_close_partintro(self) -> str:
if self._headings:
heading = self._headings[-1]
if heading.container_tag == 'part' and not heading.partintro_closed:
self._headings[-1] = heading._replace(partintro_closed=True)
return heading.toc_fragment + "</div>"
return "</div>"
return ""
def _close_headings(self, level: Optional[int]) -> str:
@@ -349,5 +343,3 @@ class HTMLRenderer(Renderer):
def _heading_tag(self, token: Token, tokens: Sequence[Token], i: int) -> str:
return "section"
def _build_toc(self, tokens: Sequence[Token], i: int) -> str:
return ""
@@ -4,21 +4,29 @@ import html
import json
import re
import xml.sax.saxutils as xml
from abc import abstractmethod
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any, Callable, cast, ClassVar, Generic, get_args, NamedTuple
from typing import Any, Callable, ClassVar, Generic, NamedTuple, cast, get_args
from markdown_it.token import Token
from . import md, options
from .html import HTMLRenderer, UnresolvedXrefError
from .manual_structure import check_structure, FragmentType, is_include, make_xml_id, TocEntry, TocEntryType, XrefTarget
from .manual_structure import (
FragmentType,
TocEntry,
TocEntryType,
XrefTarget,
check_structure,
is_include,
make_xml_id,
)
from .md import Converter, Renderer
from .redirects import Redirects
from .src_error import SrcError
class BaseConverter(Converter[md.TR], Generic[md.TR]):
# per-converter configuration for ns:arg=value arguments to include blocks, following
# the include type. html converters need something like this to support chunking, or
@@ -253,13 +261,10 @@ class HTMLParameters(NamedTuple):
generator: str
stylesheets: Sequence[str]
scripts: Sequence[str]
# number of levels in the rendered table of contents. tables are prepended to
# the content they apply to (entire document / document chunk / top-level section
# of a chapter), setting a depth of 0 omits the respective table.
toc_depth: int
chunk_toc_depth: int
section_toc_depth: int
# 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
@@ -289,14 +294,13 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
target_path.write_bytes(content)
return f"./{self._html_params.media_dir}/{target_name}"
def _push(self, tag: str, hlevel_offset: int) -> Any:
result = (self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir)
self._hlevel_offset += hlevel_offset
def _push(self, tag: str) -> Any:
result = (self._toplevel_tag, self._headings, self._attrspans, self._in_dir)
self._toplevel_tag, self._headings, self._attrspans = tag, [], []
return result
def _pop(self, state: Any) -> None:
(self._toplevel_tag, self._headings, self._attrspans, self._hlevel_offset, self._in_dir) = state
(self._toplevel_tag, self._headings, self._attrspans, self._in_dir) = state
def _render_book(self, tokens: Sequence[Token]) -> str:
assert tokens[4].children
@@ -307,7 +311,7 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
toc = TocEntry.of(tokens[0])
return "\n".join([
self._file_header(toc, sidebar=self._build_toc(tokens, 0)),
self._file_header(toc, sidebar=self._build_sidebar(toc)),
' <div class="book">',
' <div class="titlepage">',
' <div>',
@@ -364,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"',
@@ -377,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">',
])
@@ -434,103 +458,61 @@ class ManualHTMLRenderer(RendererMixin, HTMLRenderer):
if token.tag == 'h1':
return self._toplevel_tag
return super()._heading_tag(token, tokens, i)
def _build_toc(self, tokens: Sequence[Token], i: int) -> str:
toc = TocEntry.of(tokens[i])
if toc.kind == 'section' and self._html_params.section_toc_depth < 1:
return ""
def walk_and_emit(toc: TocEntry, depth: int) -> list[str]:
if depth <= 0:
return []
result = []
for child in toc.children:
result.append(
f'<dt>'
f' <span class="{html.escape(child.kind, True)}">'
f' <a href="{child.target.href()}">{child.target.toc_html}</a>'
f' </span>'
f'</dt>'
)
# we want to look straight through parts because docbook-xsl did too, but it
# also makes for more uesful top-level tocs.
next_level = walk_and_emit(child, depth - (0 if child.kind == 'part' else 1))
if next_level:
result.append(f'<dd><dl>{"".join(next_level)}</dl></dd>')
return result
def _build_sidebar(self, toc: TocEntry) -> str:
root = toc.root
def render_entries(entries: Sequence[TocEntry], budget: int) -> str:
items: list[str] = []
for e in entries:
# 'part' are structural containers we look straight through, so
# they do not consume a depth level
child_budget = budget if e.kind == 'part' else budget - 1
children = (render_entries(e.children, child_budget)
if e.children and child_budget > 0 else "")
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_attr}><summary>{link}</summary>{children}</details>'
'</li>'
)
else:
items.append(f'<li class="{cls}">{link}</li>')
return f'<ol class="toc">{"".join(items)}</ol>' if items else ""
def build_list(kind: str, id: str, lst: Sequence[TocEntry]) -> str:
if not lst:
return ""
entries = [
f'<dt>{i}. <a href="{e.target.href()}">{e.target.toc_html}</a></dt>'
for i, e in enumerate(lst, start=1)
]
entries = "".join(
f'<li><a href="{e.target.href()}">{e.target.toc_html}</a></li>'
for e in lst
)
return (
f'<div class="{id}">'
f'<p><strong>List of {kind}</strong></p>'
f'<dl>{"".join(entries)}</dl>'
f'<ol class="toc">{entries}</ol>'
'</div>'
)
# we don't want to generate the "Title of Contents" header for sections,
# docbook didn't and it's only distracting clutter unless it's the main table.
# we also want to generate tocs only for a top-level section (ie, one that is
# not itself contained in another section)
print_title = toc.kind != 'section'
if toc.kind == 'section':
if toc.parent and toc.parent.kind == 'section':
toc_depth = 0
else:
toc_depth = self._html_params.section_toc_depth
elif toc.starts_new_chunk and toc.kind != 'book':
toc_depth = self._html_params.chunk_toc_depth
else:
toc_depth = self._html_params.toc_depth
if not (items := walk_and_emit(toc, toc_depth)):
return ""
figures = build_list("Figures", "list-of-figures", toc.figures)
examples = build_list("Examples", "list-of-examples", toc.examples)
return "".join([
f'<div class="toc">',
' <p><strong>Table of Contents</strong></p>' if print_title else "",
f' <dl class="toc">'
f' {"".join(items)}'
f' </dl>'
f'</div>'
f'{figures}'
f'{examples}'
])
nav = render_entries(root.children, self._html_params.sidebar_depth)
figures = build_list("Figures", "list-of-figures", root.figures)
examples = build_list("Examples", "list-of-examples", root.examples)
return f'{nav}{figures}{examples}'
def _make_hN(self, level: int) -> tuple[str, str]:
# for some reason chapters didn't increase the hN nesting count in docbook xslts.
# originally this was duplicated here for consistency with docbook rendering, but
# it could be reevaluated and changed now that docbook is gone.
if self._toplevel_tag == 'chapter':
level -= 1
# this style setting is also for docbook compatibility only and could well go away.
style = ""
if level + self._hlevel_offset < 3 \
and (self._toplevel_tag == 'section' or (self._toplevel_tag == 'chapter' and level > 0)):
style = "clear: both"
tag, hstyle = super()._make_hN(max(1, level))
return tag, style
# book heading := h1
# Everything else is h2 ... h6
return super()._make_hN(level + 1)
def _included_thing(self, tag: str, token: Token, tokens: Sequence[Token], i: int) -> str:
outer, inner = [], []
# since books have no non-include content the toplevel book wrapper will not count
# towards nesting depth. other types will have at least a title+id heading which
# *does* count towards the nesting depth. chapters give a -1 to included sections
# mirroring the special handing in _make_hN. sigh.
hoffset = (
0 if not self._headings
else self._headings[-1].level - 1 if self._toplevel_tag == 'chapter'
else self._headings[-1].level
)
outer.append(self._maybe_close_partintro())
into = token.meta['include-args'].get('into-file')
fragments = token.meta['included']
state = self._push(tag, hoffset)
state = self._push(tag)
if into:
toc = TocEntry.of(fragments[0][0][0])
inner.append(self._file_header(toc))
# we do not set _hlevel_offset=0 because docbook didn't either.
# chunk pages carry the same whole-book sidebar as the main page.
inner.append(self._file_header(toc, sidebar=self._build_sidebar(toc)))
else:
inner = outer
in_dir = self._in_dir
@@ -755,21 +737,36 @@ class HTMLConverter(BaseConverter[ManualHTMLRenderer]):
server_redirects_file.write("\n".join(formatted_server_redirects))
class _DeprecatedDepthFlag(argparse.Action):
def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace,
values: Any, option_string: str | None = None) -> None:
parser.error(f"{option_string} has been removed, use --sidebar-depth instead")
def _build_cli_html(p: argparse.ArgumentParser) -> None:
p.add_argument('--manpage-urls', required=True)
p.add_argument('--revision', required=True)
p.add_argument('--generator', default='nixos-render-docs')
p.add_argument('--stylesheet', default=[], action='append')
p.add_argument('--script', default=[], action='append')
p.add_argument('--toc-depth', default=1, type=int)
p.add_argument('--chunk-toc-depth', default=1, type=int)
p.add_argument('--section-toc-depth', default=0, type=int)
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)
p.add_argument('--section-toc-depth', nargs='?', action=_DeprecatedDepthFlag, default=None)
# Positional
p.add_argument('infile', type=Path)
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:
@@ -778,8 +775,8 @@ def _run_cli_html(args: argparse.Namespace) -> None:
md = HTMLConverter(
args.revision,
HTMLParameters(args.generator, args.stylesheet, args.script, args.toc_depth,
args.chunk_toc_depth, args.section_toc_depth, args.media_dir),
HTMLParameters(args.generator, args.stylesheet, args.script,
args.sidebar_depth, args.media_dir, sidebar_open),
json.load(manpage_urls), redirects)
md.convert(args.infile, args.outfile)
@@ -10,7 +10,7 @@ def set_prefix(token: Token, ident: str) -> None:
def test_auto_id_prefix_simple() -> None:
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, 2, 2, Path("")), {})
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, Path("")), {})
src = f"""
# title
@@ -31,7 +31,7 @@ def test_auto_id_prefix_simple() -> None:
def test_auto_id_prefix_repeated() -> None:
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, 2, 2, Path("")), {})
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, Path("")), {})
src = f"""
# title
@@ -57,7 +57,7 @@ def test_auto_id_prefix_repeated() -> None:
]
def test_auto_id_prefix_maximum_nested() -> None:
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, 2, 2, Path("")), {})
md = HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, Path("")), {})
src = f"""
# h1
@@ -0,0 +1,124 @@
from pathlib import Path
from nixos_render_docs.manual import HTMLConverter, HTMLParameters
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"
)
(tmp_path / "chapter.md").write_text(
"# Fixed-point arguments {#chap-fpa}\n\n" # -> h2
"Intro.\n\n"
"## First section {#sec-first}\n\n" # -> h3
"Body.\n\n"
"### A subsection {#sub-a}\n\n"
"Deep.\n\n"
"#### Deeper {#d4}\n\n"
"More.\n\n"
"##### Deepest {#d5}\n\n"
"Most.\n"
)
(tmp_path / "index.md").write_text(
"# Test manual {#book-test}\n\n" # -> h1 (book title)
"## Version 1\n\n" # -> h2
"```{=include=} parts\npart.md\n```\n"
)
out = tmp_path / "out"
out.mkdir(exist_ok=True)
conv = HTMLConverter(
"1.0.0",
HTMLParameters("test-gen", [], [], sidebar_depth, Path("media"), sidebar_open),
{},
)
conv.convert(tmp_path / "index.md", out / "index.html")
return (out / "index.html").read_text()
def test_single_h1_and_flat_heading_levels(tmp_path: Path) -> None:
html = _build(tmp_path)
# There should be only one h1 on an html for acessibility and semantic reasons
assert html.count("<h1") == 1
assert '<h1 class="title">' in html
assert '<h2 id="part-builders" class="title"' in html
assert '<h2 id="chap-fpa" class="title"' in html
assert '<h3 id="sec-first" class="title"' in html
assert '<h4 id="sub-a" class="title"' in html
assert '<h5 id="d4" class="title"' in html
assert '<h6 id="d5" class="title"' in html
# nothing overflows the h6 ceiling.
assert "<h7" not in html
def test_sidebar_is_collapsible_tree(tmp_path: Path) -> None:
html = _build(tmp_path)
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>; 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
assert 'href="#part-builders"' in html
assert 'href="#chap-fpa"' in html
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
deep = _build(tmp_path, sidebar_depth=3)
assert 'href="#sub-a"' in deep
# with depth=2, its not listed
shallow = _build(tmp_path, sidebar_depth=2)
assert 'href="#sub-a"' not in shallow
def test_chunked_pages_carry_the_sidebar(tmp_path: Path) -> None:
# the user may need to navigate
# between different chunks
# every chunk page carries the same sidebar entries
# So navigation between chunks is possible
(tmp_path / "chapter.md").write_text(
"# Fixed-point arguments {#chap-fpa}\n\n"
"Intro.\n\n"
"## First section {#sec-first}\n\n"
"Body.\n\n"
"### A subsection {#sub-a}\n\n"
"Deep.\n"
)
(tmp_path / "index.md").write_text(
"# Test manual {#book-test}\n\n"
"## Version 1\n\n"
"```{=include=} chapters html:into-file=//chapter.html\nchapter.md\n```\n"
)
out = tmp_path / "out"
out.mkdir(exist_ok=True)
conv = HTMLConverter(
"1.0.0",
HTMLParameters("test-gen", [], [], 3, Path("media")),
{},
)
conv.convert(tmp_path / "index.md", out / "index.html")
chunk = (out / "chapter.html").read_text()
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
assert '<h2 id="chap-fpa" class="title"' in chunk
assert '<h3 id="sec-first" class="title"' in chunk
assert '<h4 id="sub-a" class="title"' in chunk
assert chunk.count("<h1") <= 1
assert "Table of Contents" not in chunk
@@ -20,7 +20,7 @@ class TestRedirects(unittest.TestCase):
infile.write(content)
redirects = Redirects({"redirects-test-suite": ["index.html#redirects-test-suite"]} | raw_redirects, '')
return HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, 2, 2, Path("")), {}, redirects)
return HTMLConverter("1.0.0", HTMLParameters("", [], [], 2, Path("")), {}, redirects)
def run_test(self, md: HTMLConverter):
md.convert(Path(__file__).parent / 'index.md', Path(__file__).parent / 'index.html')