about summary refs log tree commit diff
diff options
context:
space:
mode:
authorpennae <github@quasiparticle.net>2023-01-31 04:19:28 +0100
committerpennae <82953136+pennae@users.noreply.github.com>2023-02-08 15:23:34 +0100
commit10a4f0daca909e580df687426ced8e0d39056297 (patch)
treec99ec34d269203c1dc76e775ec836cb20efa6914
parent56f1d99b161a8665b7df57984af5f76dc842be9b (diff)
nixos-render-docs: add options manpage converter
mdoc is just too slow to render on groff, and semantic markup doesn't
help us any for generated pages.

this produces a lot of changes to configuration.nix.5, but only few
rendering changes. most of those seem to be place losing a space where
docbook emitted roff code that did not faithfully represent the input
text, though a few places also gained space where docbook dropped them.
notably we also don't need the compatibility code docbook-xsl emitted
because that problem was fixed over a decade ago.

this will handle block quotes, which the docbook stylesheets turned into
a mess of roff requests that ended up showing up in the output instead
of being processed.
-rw-r--r--nixos/doc/manual/default.nix35
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py316
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py4
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py141
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py29
-rw-r--r--pkgs/tools/nix/nixos-render-docs/src/tests/test_options.py2
6 files changed, 505 insertions, 22 deletions
diff --git a/nixos/doc/manual/default.nix b/nixos/doc/manual/default.nix
index 342834e257a2a..9dab1738abedd 100644
--- a/nixos/doc/manual/default.nix
+++ b/nixos/doc/manual/default.nix
@@ -21,6 +21,8 @@ let
     withManOptDedupPatch = true;
   };
 
+  manpageUrls = pkgs.path + "/doc/manpage-urls.json";
+
   # We need to strip references to /nix/store/* from options,
   # including any `extraSources` if some modules came from elsewhere,
   # or else the build will fail.
@@ -72,7 +74,7 @@ let
     nativeBuildInputs = [ pkgs.nixos-render-docs ];
   } ''
     nixos-render-docs manual docbook \
-      --manpage-urls ${pkgs.path + "/doc/manpage-urls.json"} \
+      --manpage-urls ${manpageUrls} \
       "$out" \
       --section \
         --section-id modules \
@@ -255,9 +257,12 @@ in rec {
   manpages = runCommand "nixos-manpages"
     { inherit sources;
       nativeBuildInputs = [
+        buildPackages.installShellFiles
+      ] ++ lib.optionals allowDocBook [
         buildPackages.libxml2.bin
         buildPackages.libxslt.bin
-        buildPackages.installShellFiles
+      ] ++ lib.optionals (! allowDocBook) [
+        buildPackages.nixos-render-docs
       ];
       allowedReferences = ["out"];
     }
@@ -265,14 +270,24 @@ in rec {
       # Generate manpages.
       mkdir -p $out/share/man/man8
       installManPage ${./manpages}/*
-      xsltproc --nonet \
-        --maxdepth 6000 \
-        --param man.output.in.separate.dir 1 \
-        --param man.output.base.dir "'$out/share/man/'" \
-        --param man.endnotes.are.numbered 0 \
-        --param man.break.after.slash 1 \
-        ${docbook_xsl_ns}/xml/xsl/docbook/manpages/docbook.xsl \
-        ${manual-combined}/man-pages-combined.xml
+      ${if allowDocBook
+        then ''
+          xsltproc --nonet \
+            --maxdepth 6000 \
+            --param man.output.in.separate.dir 1 \
+            --param man.output.base.dir "'$out/share/man/'" \
+            --param man.endnotes.are.numbered 0 \
+            --param man.break.after.slash 1 \
+            ${docbook_xsl_ns}/xml/xsl/docbook/manpages/docbook.xsl \
+            ${manual-combined}/man-pages-combined.xml
+        ''
+        else ''
+          mkdir -p $out/share/man/man5
+          nixos-render-docs options manpage \
+            --revision ${lib.escapeShellArg revision} \
+            ${optionsJSON}/share/doc/nixos/options.json \
+            $out/share/man/man5/configuration.nix.5
+        ''}
     '';
 
 }
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py
new file mode 100644
index 0000000000000..8188cfb9871b2
--- /dev/null
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py
@@ -0,0 +1,316 @@
+from collections.abc import Mapping, MutableMapping, Sequence
+from dataclasses import dataclass
+from typing import Any, cast, Iterable, Optional
+
+import re
+
+import markdown_it
+from markdown_it.token import Token
+from markdown_it.utils import OptionsDict
+
+from .md import Renderer
+
+# roff(7) says:
+#
+# > roff documents may contain only graphable 7-bit ASCII characters, the space character,
+# > and, in certain circumstances, the tab character. The backslash character ‘\’ indicates
+# > the start of an escape sequence […]
+#
+# mandoc_char(7) says about the `'~^ characters:
+#
+# > In prose, this automatic substitution is often desirable; but when these characters have
+# > to be displayed as plain ASCII characters, for example in source code samples, they require
+# > escaping to render as follows:
+#
+# since we don't want these to be touched anywhere (because markdown will do all substituations
+# we want to have) we'll escape those as well. we also escape " (macro metacharacter), - (might
+# turn into a typographic hyphen), and . (roff request marker at SOL, changes spacing semantics
+# at EOL). groff additionally does not allow unicode escapes for codepoints below U+0080, so
+# those need "proper" roff escapes/replacements instead.
+_roff_unicode = re.compile(r'''[^\n !#$%&()*+,\-./0-9:;<=>?@A-Z[\\\]_a-z{|}]''', re.ASCII)
+_roff_escapes = {
+    ord('"'): "\\(dq",
+    ord("'"): "\\(aq",
+    ord('-'): "\\-",
+    ord('.'): "\\&.",
+    ord('\\'): "\\e",
+    ord('^'): "\\(ha",
+    ord('`'): "\\(ga",
+    ord('~'): "\\(ti",
+    ord('…'): "...", # TODO docbook compat, remove later
+}
+def man_escape(s: str) -> str:
+    s = s.translate(_roff_escapes)
+    return _roff_unicode.sub(lambda m: f"\\[u{ord(m[0]):04X}]", s)
+
+# remove leading and trailing spaces from links and condense multiple consecutive spaces
+# into a single space for presentation parity with html. this is currently easiest with
+# regex postprocessing and some marker characters. since we don't want to drop spaces
+# from code blocks we will have to specially protect *inline* code (luckily not block code)
+# so normalization can turn the spaces inside it into regular spaces again.
+_normalize_space_re = re.compile(r'''\u0000 < *| *>\u0000 |(?<= ) +''')
+def _normalize_space(s: str) -> str:
+    return _normalize_space_re.sub("", s).replace("\0p", " ")
+
+def _protect_spaces(s: str) -> str:
+    return s.replace(" ", "\0p")
+
+@dataclass(kw_only=True)
+class List:
+    width: int
+    next_idx: Optional[int] = None
+    compact: bool
+    first_item_seen: bool = False
+
+# this renderer assumed that it produces a set of lines as output, and that those lines will
+# be pasted as-is into a larger output. no prefixing or suffixing is allowed for correctness.
+#
+# NOTE that we output exclusively physical markup. this is because we have to use the older
+# mandoc(7) format instead of the newer mdoc(7) format due to limitations in groff: while
+# using mdoc in groff works fine it is not a native format and thus very slow to render on
+# manpages as large as configuration.nix.5. mandoc(1) renders both really quickly, but with
+# groff being our predominant manpage viewer we have to optimize for groff instead.
+#
+# while we do use only physical markup (adjusting indentation with .RS and .RE, adding
+# vertical spacing with .sp, \f[BIRP] escapes for bold/italic/roman/$previous font, \h for
+# horizontal motion in a line) we do attempt to copy the style of mdoc(7) semantic requests
+# as appropriate for each markup element.
+class ManpageRenderer(Renderer):
+    __output__ = "man"
+
+    _href_targets: dict[str, str]
+
+    _do_parbreak_stack: list[bool]
+    _list_stack: list[List]
+    _font_stack: list[str]
+
+    def __init__(self, manpage_urls: Mapping[str, str], href_targets: dict[str, str],
+                 parser: Optional[markdown_it.MarkdownIt] = None):
+        super().__init__(manpage_urls, parser)
+        self._href_targets = href_targets
+        self._do_parbreak_stack = []
+        self._list_stack = []
+        self._font_stack = []
+
+    def _join_block(self, ls: Iterable[str]) -> str:
+        return "\n".join([ l for l in ls if len(l) ])
+    def _join_inline(self, ls: Iterable[str]) -> str:
+        return _normalize_space(super()._join_inline(ls))
+
+    def _enter_block(self) -> None:
+        self._do_parbreak_stack.append(False)
+    def _leave_block(self) -> None:
+        self._do_parbreak_stack.pop()
+        self._do_parbreak_stack[-1] = True
+    def _maybe_parbreak(self, suffix: str = "") -> str:
+        result = f".sp{suffix}" if self._do_parbreak_stack[-1] else ""
+        self._do_parbreak_stack[-1] = True
+        return result
+
+    def _admonition_open(self, kind: str) -> str:
+        self._enter_block()
+        return (
+            '.sp\n'
+            '.RS 4\n'
+            f'\\fB{kind}\\fP\n'
+            '.br'
+        )
+    def _admonition_close(self) -> str:
+        self._leave_block()
+        return ".RE"
+
+    def render(self, tokens: Sequence[Token], options: OptionsDict,
+               env: MutableMapping[str, Any]) -> str:
+        self._do_parbreak_stack = [ False ]
+        self._font_stack = [ "\\fR" ]
+        return super().render(tokens, options, env)
+
+    def text(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+             env: MutableMapping[str, Any]) -> str:
+        return man_escape(token.content)
+    def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                       env: MutableMapping[str, Any]) -> str:
+        return self._maybe_parbreak()
+    def paragraph_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                        env: MutableMapping[str, Any]) -> str:
+        return ""
+    def hardbreak(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        return ".br"
+    def softbreak(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        return " "
+    def code_inline(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                    env: MutableMapping[str, Any]) -> str:
+        return _protect_spaces(man_escape(token.content))
+    def code_block(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                   env: MutableMapping[str, Any]) -> str:
+        return self.fence(token, tokens, i, options, env)
+    def link_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        href = cast(str, token.attrs['href'])
+        (text, font) = ("", "\\fB")
+        if tokens[i + 1].type == 'link_close' and href in self._href_targets:
+            # TODO error or warning if the target can't be resolved
+            text = self._href_targets[href]
+        elif href in self._href_targets:
+            font = "\\fR" # TODO docbook renders these links differently for some reason
+        self._font_stack.append(font)
+        return f"{font}{text}\0 <"
+    def link_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                   env: MutableMapping[str, Any]) -> str:
+        self._font_stack.pop()
+        return f">\0 {self._font_stack[-1]}"
+    def list_item_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                       env: MutableMapping[str, Any]) -> str:
+        self._enter_block()
+        lst = self._list_stack[-1]
+        maybe_space = '' if not lst.first_item_seen else '.sp\n'
+        lst.first_item_seen = True
+        head = "•"
+        if lst.next_idx is not None:
+            head = f" {lst.next_idx}."
+            lst.next_idx += 1
+        return (
+            f'{maybe_space}'
+            f'.RS {lst.width}\n'
+            f"\\h'-{lst.width}'{man_escape(head)}\\h'{lst.width - len(head)}'\\c"
+        )
+    def list_item_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                        env: MutableMapping[str, Any]) -> str:
+        self._leave_block()
+        return ".RE"
+    def bullet_list_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                         env: MutableMapping[str, Any]) -> str:
+        self._list_stack.append(List(width=4, compact=False))
+        return self._maybe_parbreak()
+    def bullet_list_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                          env: MutableMapping[str, Any]) -> str:
+        self._list_stack.pop()
+        return ""
+    def em_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                env: MutableMapping[str, Any]) -> str:
+        self._font_stack.append("\\fI")
+        return "\\fI"
+    def em_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                 env: MutableMapping[str, Any]) -> str:
+        self._font_stack.pop()
+        return self._font_stack[-1]
+    def strong_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                    env: MutableMapping[str, Any]) -> str:
+        self._font_stack.append("\\fB")
+        return "\\fB"
+    def strong_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                     env: MutableMapping[str, Any]) -> str:
+        self._font_stack.pop()
+        return self._font_stack[-1]
+    def fence(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+              env: MutableMapping[str, Any]) -> str:
+        s = man_escape(token.content).rstrip('\n')
+        return (
+            '.sp\n'
+            '.RS 4\n'
+            '.nf\n'
+            f'{s}\n'
+            '.fi\n'
+            '.RE'
+        )
+    def blockquote_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                        env: MutableMapping[str, Any]) -> str:
+        maybe_par = self._maybe_parbreak("\n")
+        self._enter_block()
+        return (
+            f"{maybe_par}"
+            ".RS 4\n"
+            f"\\h'-3'\\fI\\(lq\\(rq\\fP\\h'1'\\c"
+        )
+    def blockquote_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                         env: MutableMapping[str, Any]) -> str:
+        self._leave_block()
+        return ".RE"
+    def note_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        return self._admonition_open("Note")
+    def note_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                   env: MutableMapping[str, Any]) -> str:
+        return self._admonition_close()
+    def caution_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                     env: MutableMapping[str, Any]) -> str:
+        return self._admonition_open( "Caution")
+    def caution_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                      env: MutableMapping[str, Any]) -> str:
+        return self._admonition_close()
+    def important_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                       env: MutableMapping[str, Any]) -> str:
+        return self._admonition_open( "Important")
+    def important_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                        env: MutableMapping[str, Any]) -> str:
+        return self._admonition_close()
+    def tip_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                 env: MutableMapping[str, Any]) -> str:
+        return self._admonition_open( "Tip")
+    def tip_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        return self._admonition_close()
+    def warning_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                     env: MutableMapping[str, Any]) -> str:
+        return self._admonition_open( "Warning")
+    def warning_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                      env: MutableMapping[str, Any]) -> str:
+        return self._admonition_close()
+    def dl_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                env: MutableMapping[str, Any]) -> str:
+        return ""
+    def dl_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                 env: MutableMapping[str, Any]) -> str:
+        return ""
+    def dt_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                env: MutableMapping[str, Any]) -> str:
+        return ".PP"
+    def dt_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                 env: MutableMapping[str, Any]) -> str:
+        return ""
+    def dd_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                env: MutableMapping[str, Any]) -> str:
+        self._enter_block()
+        return ".RS 4"
+    def dd_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                 env: MutableMapping[str, Any]) -> str:
+        self._leave_block()
+        return ".RE"
+    def myst_role(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                  env: MutableMapping[str, Any]) -> str:
+        if token.meta['name'] in [ 'command', 'env', 'option' ]:
+            return f'\\fB{man_escape(token.content)}\\fP'
+        elif token.meta['name'] == 'file':
+            return f'{man_escape(token.content)}'
+        elif token.meta['name'] == 'var':
+            return f'\\fI{man_escape(token.content)}\\fP'
+        elif token.meta['name'] == 'manpage':
+            [page, section] = [ s.strip() for s in token.content.rsplit('(', 1) ]
+            section = section[:-1]
+            return f'\\fB{man_escape(page)}\\fP\\fR({man_escape(section)})\\fP'
+        else:
+            raise NotImplementedError("md node not supported yet", token)
+    def inline_anchor(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                      env: MutableMapping[str, Any]) -> str:
+        return "" # mdoc knows no anchors
+    def heading_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                     env: MutableMapping[str, Any]) -> str:
+        raise RuntimeError("md token not supported in manpages", token)
+    def heading_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                      env: MutableMapping[str, Any]) -> str:
+        raise RuntimeError("md token not supported in manpages", token)
+    def ordered_list_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                          env: MutableMapping[str, Any]) -> str:
+        # max item head width for a number, a dot, and one leading space and one trailing space
+        width = 3 + len(str(cast(int, token.meta['end'])))
+        self._list_stack.append(
+            List(width    = width,
+                 next_idx = cast(int, token.attrs.get('start', 1)),
+                 compact  = bool(token.meta['compact'])))
+        return self._maybe_parbreak()
+    def ordered_list_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
+                           env: MutableMapping[str, Any]) -> str:
+        self._list_stack.pop()
+        return ""
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
index 16e473e97adeb..5bc16e65933c8 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
@@ -391,7 +391,7 @@ class Converter(ABC):
         tokens = self._md.parse(src, env if env is not None else {})
         return self._post_parse(tokens)
 
-    def _render(self, src: str) -> str:
-        env: dict[str, Any] = {}
+    def _render(self, src: str, env: Optional[MutableMapping[str, Any]] = None) -> str:
+        env = {} if env is None else env
         tokens = self._parse(src, env)
         return self._md.renderer.render(tokens, self._md.options, env) # type: ignore[no-any-return]
diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
index 9603b5726897b..364fb6dc2c3a8 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
@@ -2,13 +2,16 @@ import argparse
 import json
 
 from abc import abstractmethod
-from collections.abc import MutableMapping, Sequence
+from collections.abc import Mapping, MutableMapping, Sequence
 from markdown_it.utils import OptionsDict
 from markdown_it.token import Token
 from typing import Any, Optional
 from xml.sax.saxutils import escape, quoteattr
 
+import markdown_it
+
 from .docbook import DocBookRenderer, make_xml_id
+from .manpage import ManpageRenderer, man_escape
 from .md import Converter, md_escape
 from .types import OptionLoc, Option, RenderedOption
 
@@ -28,16 +31,10 @@ class BaseConverter(Converter):
 
     def __init__(self, manpage_urls: dict[str, str],
                  revision: str,
-                 document_type: str,
-                 varlist_id: str,
-                 id_prefix: str,
                  markdown_by_default: bool):
         super().__init__(manpage_urls)
         self._options = {}
         self._revision = revision
-        self._document_type = document_type
-        self._varlist_id = varlist_id
-        self._id_prefix = id_prefix
         self._markdown_by_default = markdown_by_default
 
     def _sorted_options(self) -> list[tuple[str, RenderedOption]]:
@@ -183,6 +180,17 @@ class DocBookConverter(BaseConverter):
     __renderer__ = OptionsDocBookRenderer
     __option_block_separator__ = ""
 
+    def __init__(self, manpage_urls: dict[str, str],
+                 revision: str,
+                 markdown_by_default: bool,
+                 document_type: str,
+                 varlist_id: str,
+                 id_prefix: str):
+        super().__init__(manpage_urls, revision, markdown_by_default)
+        self._document_type = document_type
+        self._varlist_id = varlist_id
+        self._id_prefix = id_prefix
+
     def _render_code(self, option: dict[str, Any], key: str) -> list[str]:
         if lit := option_is(option, key, 'literalDocBook'):
             return [ f"<para><emphasis>{key.capitalize()}:</emphasis> {lit['text']}</para>" ]
@@ -258,6 +266,101 @@ class DocBookConverter(BaseConverter):
 
         return "\n".join(result)
 
+class OptionsManpageRenderer(ManpageRenderer):
+    pass
+
+class ManpageConverter(BaseConverter):
+    def __renderer__(self, manpage_urls: Mapping[str, str],
+                     parser: Optional[markdown_it.MarkdownIt] = None) -> OptionsManpageRenderer:
+        return OptionsManpageRenderer(manpage_urls, self._options_by_id, parser)
+
+    __option_block_separator__ = ".sp"
+
+    _options_by_id: dict[str, str]
+
+    def __init__(self, revision: str, markdown_by_default: bool):
+        self._options_by_id = {}
+        super().__init__({}, revision, markdown_by_default)
+
+    def add_options(self, options: dict[str, Any]) -> None:
+        for (k, v) in options.items():
+            self._options_by_id[f'#{make_xml_id(f"opt-{k}")}'] = k
+        return super().add_options(options)
+
+    def _render_code(self, option: dict[str, Any], key: str) -> list[str]:
+        if lit := option_is(option, key, 'literalDocBook'):
+            raise RuntimeError("can't render manpages in the presence of docbook")
+        else:
+            return super()._render_code(option, key)
+
+    def _render_description(self, desc: str | dict[str, Any]) -> list[str]:
+        if isinstance(desc, str) and not self._markdown_by_default:
+            raise RuntimeError("can't render manpages in the presence of docbook")
+        else:
+            return super()._render_description(desc)
+
+    def _related_packages_header(self) -> list[str]:
+        return [
+            '\\fIRelated packages:\\fP',
+            '.sp',
+        ]
+
+    def _decl_def_header(self, header: str) -> list[str]:
+        return [
+            f'\\fI{man_escape(header)}:\\fP',
+        ]
+
+    def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]:
+        return [
+            '.RS 4',
+            f'\\fB{man_escape(name)}\\fP',
+            '.RE'
+        ]
+
+    def _decl_def_footer(self) -> list[str]:
+        return []
+
+    def finalize(self) -> str:
+        result = []
+
+        result += [
+            r'''.TH "CONFIGURATION\&.NIX" "5" "01/01/1980" "NixOS" "NixOS Reference Pages"''',
+            r'''.\" disable hyphenation''',
+            r'''.nh''',
+            r'''.\" disable justification (adjust text to left margin only)''',
+            r'''.ad l''',
+            r'''.\" enable line breaks after slashes''',
+            r'''.cflags 4 /''',
+            r'''.SH "NAME"''',
+            self._render('{file}`configuration.nix` - NixOS system configuration specification'),
+            r'''.SH "DESCRIPTION"''',
+            r'''.PP''',
+            self._render('The file {file}`/etc/nixos/configuration.nix` contains the '
+                        'declarative specification of your NixOS system configuration. '
+                        'The command {command}`nixos-rebuild` takes this file and '
+                        'realises the system configuration specified therein.'),
+            r'''.SH "OPTIONS"''',
+            r'''.PP''',
+            self._render('You can use the following options in {file}`configuration.nix`.'),
+        ]
+
+        for (name, opt) in self._sorted_options():
+            result += [
+                ".PP",
+                f"\\fB{man_escape(name)}\\fR",
+                ".RS 4",
+            ]
+            result += opt.lines
+            result.append(".RE")
+
+        result += [
+            r'''.SH "AUTHORS"''',
+            r'''.PP''',
+            r'''Eelco Dolstra and the Nixpkgs/NixOS contributors''',
+        ]
+
+        return "\n".join(result)
+
 def _build_cli_db(p: argparse.ArgumentParser) -> None:
     p.add_argument('--manpage-urls', required=True)
     p.add_argument('--revision', required=True)
@@ -268,27 +371,47 @@ def _build_cli_db(p: argparse.ArgumentParser) -> None:
     p.add_argument("infile")
     p.add_argument("outfile")
 
+def _build_cli_manpage(p: argparse.ArgumentParser) -> None:
+    p.add_argument('--revision', required=True)
+    p.add_argument("infile")
+    p.add_argument("outfile")
+
 def _run_cli_db(args: argparse.Namespace) -> None:
     with open(args.manpage_urls, 'r') as manpage_urls:
         md = DocBookConverter(
             json.load(manpage_urls),
             revision = args.revision,
+            markdown_by_default = args.markdown_by_default,
             document_type = args.document_type,
             varlist_id = args.varlist_id,
-            id_prefix = args.id_prefix,
-            markdown_by_default = args.markdown_by_default)
+            id_prefix = args.id_prefix)
 
         with open(args.infile, 'r') as f:
             md.add_options(json.load(f))
         with open(args.outfile, 'w') as f:
             f.write(md.finalize())
 
+def _run_cli_manpage(args: argparse.Namespace) -> None:
+    md = ManpageConverter(
+        revision = args.revision,
+        # manpage rendering only works if there's no docbook, so we can
+        # also set markdown_by_default with no ill effects.
+        markdown_by_default = True)
+
+    with open(args.infile, 'r') as f:
+        md.add_options(json.load(f))
+    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'))
+    _build_cli_manpage(formats.add_parser('manpage'))
 
 def run_cli(args: argparse.Namespace) -> None:
     if args.format == 'docbook':
         _run_cli_db(args)
+    elif args.format == 'manpage':
+        _run_cli_manpage(args)
     else:
         raise RuntimeError('format not hooked up', args)
diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py
new file mode 100644
index 0000000000000..0ccd33f1be418
--- /dev/null
+++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py
@@ -0,0 +1,29 @@
+import nixos_render_docs
+
+from typing import Mapping, Optional
+
+import markdown_it
+
+class Converter(nixos_render_docs.md.Converter):
+    def __renderer__(self, manpage_urls: Mapping[str, str],
+                     parser: Optional[markdown_it.MarkdownIt] = None
+                     ) -> nixos_render_docs.manpage.ManpageRenderer:
+        return nixos_render_docs.manpage.ManpageRenderer(manpage_urls, self.options_by_id, parser)
+
+    def __init__(self, manpage_urls: Mapping[str, str], options_by_id: dict[str, str] = {}):
+        self.options_by_id = options_by_id
+        super().__init__(manpage_urls)
+
+def test_inline_code() -> None:
+    c = Converter({})
+    assert c._render("1  `x  a  x`  2") == "1 x  a  x 2"
+
+def test_fonts() -> None:
+    c = Converter({})
+    assert c._render("*a **b** c*") == "\\fIa \\fBb\\fI c\\fR"
+    assert c._render("*a [1 `2`](3) c*") == "\\fIa \\fB1 2\\fI c\\fR"
+
+def test_expand_link_targets() -> None:
+    c = Converter({}, { '#foo1': "bar", "#foo2": "bar" })
+    assert (c._render("[a](#foo1) [](#foo2) [b](#bar1) [](#bar2)") ==
+            "\\fRa\\fR \\fBbar\\fR \\fBb\\fR \\fB\\fR")
diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_options.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_options.py
index 5a02fabde0fbc..9608ed6392188 100644
--- a/pkgs/tools/nix/nixos-render-docs/src/tests/test_options.py
+++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_options.py
@@ -4,7 +4,7 @@ from markdown_it.token import Token
 import pytest
 
 def test_option_headings() -> None:
-    c = nixos_render_docs.options.DocBookConverter({}, 'local', 'none', 'vars', 'opt-', False)
+    c = nixos_render_docs.options.DocBookConverter({}, 'local', False, 'none', 'vars', 'opt-')
     with pytest.raises(RuntimeError) as exc:
         c._render("# foo")
     assert exc.value.args[0] == 'md token not supported in options doc'