diff --git a/doc/doc-support/package.nix b/doc/doc-support/package.nix index 3cc6daa32863..466beb204425 100644 --- a/doc/doc-support/package.nix +++ b/doc/doc-support/package.nix @@ -116,8 +116,7 @@ stdenvNoCC.mkDerivation ( --script ./highlightjs/loader.js \ --script ./anchor.min.js \ --script ./anchor-use.js \ - --toc-depth 1 \ - --section-toc-depth 1 \ + --sidebar-depth 3 \ 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/style.css b/doc/style.css index c3c58e88637d..90517caf4948 100644 --- a/doc/style.css +++ b/doc/style.css @@ -387,15 +387,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 +399,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; @@ -515,12 +495,23 @@ 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) { 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..902b365d8b5f 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,12 +261,8 @@ 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 class ManualHTMLRenderer(RendererMixin, HTMLRenderer): @@ -289,14 +293,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 +310,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)), '
', '
', '
', @@ -434,103 +437,60 @@ 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'
' - f' ' - f' {child.target.toc_html}' - f' ' - 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: + 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'
    {i}. {e.target.toc_html}
    ' - for i, e in enumerate(lst, start=1) - ] + entries = "".join( + f'
  • {e.target.toc_html}
  • ' + for e in lst + ) return ( f'
    ' f'

    List of {kind}

    ' - f'
    {"".join(entries)}
    ' + f'
      {entries}
    ' '
    ' ) - # 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,17 +715,25 @@ 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) + # 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) @@ -778,8 +746,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), 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..339bf58b2462 --- /dev/null +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manual.py @@ -0,0 +1,116 @@ +from pathlib import Path + +from nixos_render_docs.manual import HTMLConverter, HTMLParameters + + +def _build(tmp_path: Path, sidebar_depth: int = 2) -> 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")), + {}, + ) + 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("' in html + assert '

    None: + html = _build(tmp_path) + assert '