diff --git a/pkgs/tools/nix/nixos-render-docs/default.nix b/pkgs/tools/nix/nixos-render-docs/default.nix index 1e1036537c23..55bbbd9ffa56 100644 --- a/pkgs/tools/nix/nixos-render-docs/default.nix +++ b/pkgs/tools/nix/nixos-render-docs/default.nix @@ -44,6 +44,7 @@ python.pkgs.buildPythonApplication { nativeBuildInputs = [ python.pkgs.setuptools + python.pkgs.pytestCheckHook ]; propagatedBuildInputs = [ @@ -52,6 +53,8 @@ python.pkgs.buildPythonApplication { python.pkgs.frozendict ]; + pytestFlagsArray = [ "-vvrP" "tests/" ]; + meta = with lib; { description = "Renderer for NixOS manual and option docs"; license = licenses.mit; diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py index 22ce896782cd..39ce50cc0421 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py +++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py @@ -200,3 +200,6 @@ class DocBookRenderer(Renderer): else: return ref 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 f'' 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 4bb42c5a8f77..501bc92ff71c 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 @@ -3,6 +3,8 @@ from collections.abc import Mapping, MutableMapping, Sequence from frozendict import frozendict # type: ignore[attr-defined] from typing import Any, Callable, Optional +import re + from .types import RenderFn import markdown_it @@ -61,6 +63,7 @@ class Renderer(markdown_it.renderer.RendererProtocol): 'myst_role': self.myst_role, "container_admonition_open": self.admonition_open, "container_admonition_close": self.admonition_close, + "inline_anchor": self.inline_anchor, } self._admonitions = { @@ -212,6 +215,51 @@ class Renderer(markdown_it.renderer.RendererProtocol): def myst_role(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: raise RuntimeError("md token not supported", token) + def inline_anchor(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, + env: MutableMapping[str, Any]) -> str: + raise RuntimeError("md token not supported", token) + +def _is_escaped(src: str, pos: int) -> bool: + found = 0 + while pos >= 0 and src[pos] == '\\': + found += 1 + pos -= 1 + return found % 2 == 1 + +_INLINE_ANCHOR_PATTERN = re.compile(r"\{\s*#([\w-]+)\s*\}") + +def _inline_anchor_plugin(md: markdown_it.MarkdownIt) -> None: + def inline_anchor(state: markdown_it.rules_inline.StateInline, silent: bool) -> bool: + if state.src[state.pos] != '[': + return False + if _is_escaped(state.src, state.pos - 1): + return False + + # treat the inline span like a link label for simplicity. + label_begin = state.pos + 1 + label_end = markdown_it.helpers.parseLinkLabel(state, state.pos) + input_end = state.posMax + if label_end < 0: + return False + + # match id + match = _INLINE_ANCHOR_PATTERN.match(state.src[label_end + 1 : ]) + if not match: + return False + + if not silent: + token = state.push("inline_anchor", "", 0) # type: ignore[no-untyped-call] + token.attrs['id'] = match[1] + + state.pos = label_begin + state.posMax = label_end + state.md.inline.tokenize(state) + + state.pos = label_end + match.end() + 1 + state.posMax = input_end + return True + + md.inline.ruler.before("link", "inline_anchor", inline_anchor) class Converter(ABC): __renderer__: Callable[[Mapping[str, str], markdown_it.MarkdownIt], Renderer] @@ -237,6 +285,7 @@ class Converter(ABC): ) self._md.use(deflist_plugin) self._md.use(myst_role_plugin) + self._md.use(_inline_anchor_plugin) self._md.enable(["smartquotes", "replacements"]) def _post_parse(self, tokens: list[Token]) -> list[Token]: diff --git a/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py b/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py new file mode 100644 index 000000000000..3866adbf8b15 --- /dev/null +++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py @@ -0,0 +1,171 @@ +import nixos_render_docs + +from markdown_it.token import Token + +class Converter(nixos_render_docs.md.Converter): + # actual renderer doesn't matter, we're just parsing. + __renderer__ = nixos_render_docs.docbook.DocBookRenderer + +def test_inline_anchor_simple() -> None: + c = Converter({}) + assert c._parse("[]{#test}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, content='[]{#test}', + markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'test'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=True, hidden=False) + ] + +def test_inline_anchor_formatted() -> None: + c = Converter({}) + assert c._parse("a[b c `d` ***e***]{#test}f") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + content='a[b c `d` ***e***]{#test}f', markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, + children=None, content='a', markup='', info='', meta={}, block=False, hidden=False), + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'test'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='b c ', markup='', info='', meta={}, block=False, hidden=False), + Token(type='code_inline', tag='code', nesting=0, attrs={}, map=None, level=0, + children=None, content='d', markup='`', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content=' ', markup='', info='', meta={}, block=False, hidden=False), + Token(type='em_open', tag='em', nesting=1, attrs={}, map=None, level=0, children=None, + content='', markup='*', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=1, children=None, + content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='strong_open', tag='strong', nesting=1, attrs={}, map=None, level=1, + children=None, content='', markup='**', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=2, children=None, + content='e', markup='', info='', meta={}, block=False, hidden=False), + Token(type='strong_close', tag='strong', nesting=-1, attrs={}, map=None, level=1, + children=None, content='', markup='**', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=1, children=None, + content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='em_close', tag='em', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='*', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='f', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ] + +def test_inline_anchor_in_heading() -> None: + c = Converter({}) + # inline anchors in headers are allowed, but header attributes should be preferred + assert c._parse("# foo []{#bar} baz") == [ + Token(type='heading_open', tag='h1', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='#', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + content='foo []{#bar} baz', markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='foo ', markup='', info='', meta={}, block=False, hidden=False), + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'bar'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content=' baz', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='heading_close', tag='h1', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='#', info='', meta={}, block=True, hidden=False) + ] + +def test_inline_anchor_on_links() -> None: + c = Converter({}) + assert c._parse("[ [a](#bar) ]{#foo}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, content='[ [a](#bar) ]{#foo}', + markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'foo'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content=' ', markup='', info='', meta={}, block=False, hidden=False), + Token(type='link_open', tag='a', nesting=1, attrs={'href': '#bar'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=1, children=None, + content='a', markup='', info='', meta={}, block=False, hidden=False), + Token(type='link_close', tag='a', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content=' ', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ] + +def test_inline_anchor_nested() -> None: + # inline anchors may contain more anchors (even though this is a bit pointless) + c = Converter({}) + assert c._parse("[ [a]{#bar} ]{#foo}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + content='[ [a]{#bar} ]{#foo}', markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'foo'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content=' ', markup='', info='', meta={}, block=False, hidden=False), + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'bar'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='a ', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ] + +def test_inline_anchor_escaping() -> None: + c = Converter({}) + assert c._parse("\\[a]{#bar}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + content='\\[a]{#bar}', markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='[a]{#bar}', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("\\\\[a]{#bar}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + content='\\\\[a]{#bar}', markup='', info='', meta={}, block=True, hidden=False, + children=[ + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='\\', markup='', info='', meta={}, block=False, hidden=False), + Token(type='inline_anchor', tag='', nesting=0, attrs={'id': 'bar'}, map=None, level=0, + children=None, content='', markup='', info='', meta={}, block=False, hidden=False), + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='a', markup='', info='', meta={}, block=False, hidden=False) + ]), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("\\\\\\[a]{#bar}") == [ + Token(type='paragraph_open', tag='p', nesting=1, attrs={}, map=[0, 1], level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False), + Token(type='inline', tag='', nesting=0, attrs={}, map=[0, 1], level=1, + children=[ + Token(type='text', tag='', nesting=0, attrs={}, map=None, level=0, children=None, + content='\\[a]{#bar}', markup='', info='', meta={}, block=False, hidden=False) + ], + content='\\\\\\[a]{#bar}', markup='', info='', meta={}, block=True, hidden=False), + Token(type='paragraph_close', tag='p', nesting=-1, attrs={}, map=None, level=0, children=None, + content='', markup='', info='', meta={}, block=True, hidden=False) + ]