diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py
index e65eadcbbf90..5af13267b14d 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py
@@ -4,6 +4,7 @@ import sys
from typing import Any, Dict
from .md import Converter
+from . import manual
from . import options
def main() -> None:
@@ -12,9 +13,12 @@ def main() -> None:
commands = parser.add_subparsers(dest='command', required=True)
options.build_cli(commands.add_parser('options'))
+ manual.build_cli(commands.add_parser('manual'))
args = parser.parse_args()
if args.command == 'options':
options.run_cli(args)
+ elif args.command == 'manual':
+ manual.run_cli(args)
else:
raise RuntimeError('command not hooked up', args)
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
new file mode 100644
index 000000000000..361bc0877893
--- /dev/null
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
@@ -0,0 +1,143 @@
+import argparse
+import json
+
+from abc import abstractmethod
+from collections.abc import MutableMapping, Sequence
+from typing import Any, cast, NamedTuple, Optional, Union
+from xml.sax.saxutils import escape, quoteattr
+from markdown_it.token import Token
+from markdown_it.utils import OptionsDict
+
+from .docbook import DocBookRenderer
+from .md import Converter
+
+class RenderedSection:
+ id: Optional[str]
+ chapters: list[str]
+
+ def __init__(self, id: Optional[str]) -> None:
+ self.id = id
+ self.chapters = []
+
+class BaseConverter(Converter):
+ _sections: list[RenderedSection]
+
+ def __init__(self, manpage_urls: dict[str, str]):
+ super().__init__(manpage_urls)
+ self._sections = []
+
+ def add_section(self, id: Optional[str], chapters: list[str]) -> None:
+ self._sections.append(RenderedSection(id))
+ for content in chapters:
+ self._md.renderer._title_seen = False # type: ignore[attr-defined]
+ self._sections[-1].chapters.append(self._render(content))
+
+ @abstractmethod
+ def finalize(self) -> str: raise NotImplementedError()
+
+class ManualDocBookRenderer(DocBookRenderer):
+ # needed to check correctness of chapters.
+ # we may want to use front matter instead of this kind of heuristic.
+ _title_seen = False
+
+ def _heading_tag(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+ env: MutableMapping[str, Any]) -> tuple[str, dict[str, str]]:
+ (tag, attrs) = super()._heading_tag(token, tokens, i, options, env)
+ if self._title_seen:
+ if token.tag == 'h1':
+ raise RuntimeError("only one title heading allowed", token)
+ return (tag, attrs)
+ self._title_seen = True
+ return ("chapter", attrs | {
+ 'xmlns': "http://docbook.org/ns/docbook",
+ 'xmlns:xlink': "http://www.w3.org/1999/xlink",
+ })
+
+ # TODO minimize docbook diffs with existing conversions. remove soon.
+ def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+ env: MutableMapping[str, Any]) -> str:
+ return super().paragraph_open(token, tokens, i, options, env) + "\n "
+ def paragraph_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+ env: MutableMapping[str, Any]) -> str:
+ return "\n" + super().paragraph_close(token, tokens, i, options, env)
+ def code_block(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+ env: MutableMapping[str, Any]) -> str:
+ return f"\n{escape(token.content)}"
+ def fence(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+ env: MutableMapping[str, Any]) -> str:
+ info = f" language={quoteattr(token.info)}" if token.info != "" else ""
+ return f"\n{escape(token.content)}"
+
+class DocBookConverter(BaseConverter):
+ __renderer__ = ManualDocBookRenderer
+
+ def finalize(self) -> str:
+ result = []
+
+ for section in self._sections:
+ id = "id=" + quoteattr(section.id) if section.id is not None else ""
+ result.append(f'')
+ result += section.chapters
+ result.append(f'')
+
+ return "\n".join(result)
+
+
+
+class Section:
+ id: Optional[str] = None
+ chapters: list[str]
+
+ def __init__(self) -> None:
+ self.chapters = []
+
+class SectionAction(argparse.Action):
+ def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace,
+ values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None:
+ sections = getattr(ns, self.dest)
+ if sections is None: sections = []
+ sections.append(Section())
+ setattr(ns, self.dest, sections)
+
+class SectionIDAction(argparse.Action):
+ def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace,
+ values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None:
+ sections = getattr(ns, self.dest)
+ if sections is None: raise argparse.ArgumentError(self, "no active section")
+ sections[-1].id = cast(str, values)
+
+class ChaptersAction(argparse.Action):
+ def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace,
+ values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None:
+ sections = getattr(ns, self.dest)
+ if sections is None: raise argparse.ArgumentError(self, "no active section")
+ sections[-1].chapters.extend(cast(Sequence[str], values))
+
+def _build_cli_db(p: argparse.ArgumentParser) -> None:
+ p.add_argument('--manpage-urls', required=True)
+ p.add_argument("outfile")
+ p.add_argument("--section", dest="contents", action=SectionAction, nargs=0)
+ p.add_argument("--section-id", dest="contents", action=SectionIDAction)
+ p.add_argument("--chapters", dest="contents", action=ChaptersAction, nargs='+')
+
+def _run_cli_db(args: argparse.Namespace) -> None:
+ with open(args.manpage_urls, 'r') as manpage_urls:
+ md = DocBookConverter(json.load(manpage_urls))
+ for section in args.contents:
+ chapters = []
+ for p in section.chapters:
+ with open(p, 'r') as f:
+ chapters.append(f.read())
+ md.add_section(section.id, chapters)
+ with open(args.outfile, 'w') as f:
+ f.write(md.finalize())
+
+def build_cli(p: argparse.ArgumentParser) -> None:
+ formats = p.add_subparsers(dest='format', required=True)
+ _build_cli_db(formats.add_parser('docbook'))
+
+def run_cli(args: argparse.Namespace) -> None:
+ if args.format == 'docbook':
+ _run_cli_db(args)
+ else:
+ raise RuntimeError('format not hooked up', args)