lol

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.

authored by

pennae and committed by
pennae
78052a22 3c7fd940

+58 -7
+14 -1
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py
··· 81 81 # mainly used by the options manpage converter to not emit extra quotes in defaults 82 82 # and examples where it's already clear from context that the following text is code. 83 83 inline_code_is_quoted: bool = True 84 + link_footnotes: Optional[list[str]] = None 84 85 85 86 _href_targets: dict[str, str] 86 87 88 + _link_stack: list[str] 87 89 _do_parbreak_stack: list[bool] 88 90 _list_stack: list[List] 89 91 _font_stack: list[str] ··· 92 94 parser: Optional[markdown_it.MarkdownIt] = None): 93 95 super().__init__(manpage_urls, parser) 94 96 self._href_targets = href_targets 97 + self._link_stack = [] 95 98 self._do_parbreak_stack = [] 96 99 self._list_stack = [] 97 100 self._font_stack = [] ··· 154 157 def link_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, 155 158 env: MutableMapping[str, Any]) -> str: 156 159 href = cast(str, token.attrs['href']) 160 + self._link_stack.append(href) 157 161 text = "" 158 162 if tokens[i + 1].type == 'link_close' and href in self._href_targets: 159 163 # TODO error or warning if the target can't be resolved ··· 162 166 return f"\\fB{text}\0 <" 163 167 def link_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, 164 168 env: MutableMapping[str, Any]) -> str: 169 + href = self._link_stack.pop() 170 + text = "" 171 + if self.link_footnotes is not None: 172 + try: 173 + idx = self.link_footnotes.index(href) + 1 174 + except ValueError: 175 + self.link_footnotes.append(href) 176 + idx = len(self.link_footnotes) 177 + text = "\\fR" + man_escape(f"[{idx}]") 165 178 self._font_stack.pop() 166 - return f">\0 {self._font_stack[-1]}" 179 + return f">\0 {text}{self._font_stack[-1]}" 167 180 def list_item_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, 168 181 env: MutableMapping[str, Any]) -> str: 169 182 self._enter_block()
+26 -4
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
··· 148 148 149 149 return [ l for part in blocks for l in part ] 150 150 151 + def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption: 152 + try: 153 + return RenderedOption(option['loc'], self._convert_one(option)) 154 + except Exception as e: 155 + raise Exception(f"Failed to render option {name}") from e 156 + 151 157 def add_options(self, options: dict[str, Any]) -> None: 152 158 for (name, option) in options.items(): 153 - try: 154 - self._options[name] = RenderedOption(option['loc'], self._convert_one(option)) 155 - except Exception as e: 156 - raise Exception(f"Failed to render option {name}") from e 159 + self._options[name] = self._render_option(name, option) 157 160 158 161 @abstractmethod 159 162 def finalize(self) -> str: raise NotImplementedError() ··· 277 280 __option_block_separator__ = ".sp" 278 281 279 282 _options_by_id: dict[str, str] 283 + _links_in_last_description: Optional[list[str]] = None 280 284 281 285 def __init__(self, revision: str, markdown_by_default: bool): 282 286 self._options_by_id = {} 283 287 super().__init__({}, revision, markdown_by_default) 288 + 289 + def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption: 290 + assert isinstance(self._md.renderer, OptionsManpageRenderer) 291 + links = self._md.renderer.link_footnotes = [] 292 + result = super()._render_option(name, option) 293 + self._md.renderer.link_footnotes = None 294 + return result._replace(links=links) 284 295 285 296 def add_options(self, options: dict[str, Any]) -> None: 286 297 for (k, v) in options.items(): ··· 356 367 ".RS 4", 357 368 ] 358 369 result += opt.lines 370 + if links := opt.links: 371 + result.append(self.__option_block_separator__) 372 + md_links = "" 373 + for i in range(0, len(links)): 374 + md_links += "\n" if i > 0 else "" 375 + if links[i].startswith('#opt-'): 376 + md_links += f"{i+1}. see the {{option}}`{self._options_by_id[links[i]]}` option" 377 + else: 378 + md_links += f"{i+1}. " + md_escape(links[i]) 379 + result.append(self._render(md_links)) 380 + 359 381 result.append(".RE") 360 382 361 383 result += [
+4 -2
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py
··· 7 7 OptionLoc = str | dict[str, str] 8 8 Option = dict[str, str | dict[str, str] | list[OptionLoc]] 9 9 10 - RenderedOption = NamedTuple('RenderedOption', [('loc', list[str]), 11 - ('lines', list[str])]) 10 + class RenderedOption(NamedTuple): 11 + loc: list[str] 12 + lines: list[str] 13 + links: Optional[list[str]] = None 12 14 13 15 RenderFn = Callable[[Token, Sequence[Token], int, OptionsDict, MutableMapping[str, Any]], str]
+14
pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py
··· 27 27 c = Converter({}, { '#foo1': "bar", "#foo2": "bar" }) 28 28 assert (c._render("[a](#foo1) [](#foo2) [b](#bar1) [](#bar2)") == 29 29 "\\fBa\\fR \\fBbar\\fR \\fBb\\fR \\fB\\fR") 30 + 31 + def test_collect_links() -> None: 32 + c = Converter({}, { '#foo': "bar" }) 33 + assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer) 34 + c._md.renderer.link_footnotes = [] 35 + assert c._render("[a](link1) [b](link2)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[2]\\fR" 36 + assert c._md.renderer.link_footnotes == ['link1', 'link2'] 37 + 38 + def test_dedup_links() -> None: 39 + c = Converter({}, { '#foo': "bar" }) 40 + assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer) 41 + c._md.renderer.link_footnotes = [] 42 + assert c._render("[a](link) [b](link)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[1]\\fR" 43 + assert c._md.renderer.link_footnotes == ['link']