pkgs/nixos-render-docs: replace inline tocs with collapsible sidebar (#537810)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"open": []
|
||||
}
|
||||
+70
-26
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user