nixos-render-docs: track links in manpages

for the longest time we completely dropped link targets in
configuration.nix.5. let's stop doing this now and instead provide a
footnote for each link in a given option, numbered locally per option.

we will currently duplicate the link for <labelless-links> because it
makes it easier to get the collection of all links in a given option.
this may not be useful enough, so over time we might decide to drop the
footnotes for such links.

pennae 78052a22 3c7fd940

Changed files
+58 -7
pkgs
tools
nix
nixos-render-docs
src
nixos_render_docs
tests
+14 -1
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py
···
# mainly used by the options manpage converter to not emit extra quotes in defaults
# and examples where it's already clear from context that the following text is code.
inline_code_is_quoted: bool = True
+
link_footnotes: Optional[list[str]] = None
_href_targets: dict[str, str]
+
_link_stack: list[str]
_do_parbreak_stack: list[bool]
_list_stack: list[List]
_font_stack: list[str]
···
parser: Optional[markdown_it.MarkdownIt] = None):
super().__init__(manpage_urls, parser)
self._href_targets = href_targets
+
self._link_stack = []
self._do_parbreak_stack = []
self._list_stack = []
self._font_stack = []
···
def link_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
env: MutableMapping[str, Any]) -> str:
href = cast(str, token.attrs['href'])
+
self._link_stack.append(href)
text = ""
if tokens[i + 1].type == 'link_close' and href in self._href_targets:
# TODO error or warning if the target can't be resolved
···
return f"\\fB{text}\0 <"
def link_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict,
env: MutableMapping[str, Any]) -> str:
+
href = self._link_stack.pop()
+
text = ""
+
if self.link_footnotes is not None:
+
try:
+
idx = self.link_footnotes.index(href) + 1
+
except ValueError:
+
self.link_footnotes.append(href)
+
idx = len(self.link_footnotes)
+
text = "\\fR" + man_escape(f"[{idx}]")
self._font_stack.pop()
-
return f">\0 {self._font_stack[-1]}"
+
return f">\0 {text}{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()
+26 -4
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
···
return [ l for part in blocks for l in part ]
+
def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption:
+
try:
+
return RenderedOption(option['loc'], self._convert_one(option))
+
except Exception as e:
+
raise Exception(f"Failed to render option {name}") from e
+
def add_options(self, options: dict[str, Any]) -> None:
for (name, option) in options.items():
-
try:
-
self._options[name] = RenderedOption(option['loc'], self._convert_one(option))
-
except Exception as e:
-
raise Exception(f"Failed to render option {name}") from e
+
self._options[name] = self._render_option(name, option)
@abstractmethod
def finalize(self) -> str: raise NotImplementedError()
···
__option_block_separator__ = ".sp"
_options_by_id: dict[str, str]
+
_links_in_last_description: Optional[list[str]] = None
def __init__(self, revision: str, markdown_by_default: bool):
self._options_by_id = {}
super().__init__({}, revision, markdown_by_default)
+
+
def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption:
+
assert isinstance(self._md.renderer, OptionsManpageRenderer)
+
links = self._md.renderer.link_footnotes = []
+
result = super()._render_option(name, option)
+
self._md.renderer.link_footnotes = None
+
return result._replace(links=links)
def add_options(self, options: dict[str, Any]) -> None:
for (k, v) in options.items():
···
".RS 4",
]
result += opt.lines
+
if links := opt.links:
+
result.append(self.__option_block_separator__)
+
md_links = ""
+
for i in range(0, len(links)):
+
md_links += "\n" if i > 0 else ""
+
if links[i].startswith('#opt-'):
+
md_links += f"{i+1}. see the {{option}}`{self._options_by_id[links[i]]}` option"
+
else:
+
md_links += f"{i+1}. " + md_escape(links[i])
+
result.append(self._render(md_links))
+
result.append(".RE")
result += [
+4 -2
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py
···
OptionLoc = str | dict[str, str]
Option = dict[str, str | dict[str, str] | list[OptionLoc]]
-
RenderedOption = NamedTuple('RenderedOption', [('loc', list[str]),
-
('lines', list[str])])
+
class RenderedOption(NamedTuple):
+
loc: list[str]
+
lines: list[str]
+
links: Optional[list[str]] = None
RenderFn = Callable[[Token, Sequence[Token], int, OptionsDict, MutableMapping[str, Any]], str]
+14
pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py
···
c = Converter({}, { '#foo1': "bar", "#foo2": "bar" })
assert (c._render("[a](#foo1) [](#foo2) [b](#bar1) [](#bar2)") ==
"\\fBa\\fR \\fBbar\\fR \\fBb\\fR \\fB\\fR")
+
+
def test_collect_links() -> None:
+
c = Converter({}, { '#foo': "bar" })
+
assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer)
+
c._md.renderer.link_footnotes = []
+
assert c._render("[a](link1) [b](link2)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[2]\\fR"
+
assert c._md.renderer.link_footnotes == ['link1', 'link2']
+
+
def test_dedup_links() -> None:
+
c = Converter({}, { '#foo': "bar" })
+
assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer)
+
c._md.renderer.link_footnotes = []
+
assert c._render("[a](link) [b](link)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[1]\\fR"
+
assert c._md.renderer.link_footnotes == ['link']