diff --git a/doc/doc-support/package.nix b/doc/doc-support/package.nix
index 3cc6daa32863..f2c37df39132 100644
--- a/doc/doc-support/package.nix
+++ b/doc/doc-support/package.nix
@@ -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
diff --git a/doc/languages-frameworks/beam.section.md b/doc/languages-frameworks/beam.section.md
index 70ad1591be01..db4edaad4a4b 100644
--- a/doc/languages-frameworks/beam.section.md
+++ b/doc/languages-frameworks/beam.section.md
@@ -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.
diff --git a/doc/nav.json b/doc/nav.json
new file mode 100644
index 000000000000..15d4926421cf
--- /dev/null
+++ b/doc/nav.json
@@ -0,0 +1,3 @@
+{
+ "open": []
+}
diff --git a/doc/style.css b/doc/style.css
index c3c58e88637d..ac052c81511c 100644
--- a/doc/style.css
+++ b/doc/style.css
@@ -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;
diff --git a/nixos/doc/manual/default.nix b/nixos/doc/manual/default.nix
index c8afe6167a7d..cf5c28358097 100644
--- a/nixos/doc/manual/default.nix
+++ b/nixos/doc/manual/default.nix
@@ -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}
diff --git a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/html.py b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/html.py
index 3e4ff1aedb7a..5944431d4680 100644
--- a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/html.py
+++ b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/html.py
@@ -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'
'
@@ -205,8 +201,6 @@ class HTMLRenderer(Renderer):
)
if heading.container_tag == 'part':
result += '
'
- 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 + "
"
+ return "
"
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 ""
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 9208148c594b..11d247320d68 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
@@ -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)),
'
',
'
',
'
',
@@ -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([
'',
''
for script in scripts)),
+ f"",
f' ',
f' ' if home.target.href() else "",
f' {up_link}{prev_link}{next_link}',
' ',
' ',
+ # See: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
+ # Supported by most browsers since 2023, full support since Jan 2025
+ (' ') if sidebar else "",
nav_html,
- f' ' if sidebar else "",
+ f' ' if sidebar else "",
' ',
])
@@ -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'
'
- )
- # 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'
{"".join(next_level)}
')
- 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'{e.target.toc_html}'
+ 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'
'
+ f'{link}{children}'
+ '
'
+ )
+ else:
+ items.append(f'
{link}
')
+ return f'{"".join(items)}' if items else ""
def build_list(kind: str, id: str, lst: Sequence[TocEntry]) -> str:
if not lst:
return ""
- entries = [
- f'
'
)
- # 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'
',
- '
Table of Contents
' if print_title else "",
- f'
'
- f' {"".join(items)}'
- f'
'
- f'
'
- 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)
diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_auto_id_prefix.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_auto_id_prefix.py
index 6fb706bad5ac..fce017fc70ba 100644
--- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_auto_id_prefix.py
+++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_auto_id_prefix.py
@@ -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
diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py
new file mode 100644
index 000000000000..14f7024ce186
--- /dev/null
+++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py
@@ -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("