From bb6526e0de3d2d5da24218827b43d867dae3752d Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 8 Feb 2023 08:23:17 +0100 Subject: [PATCH] nixos-render-docs: add generic attributed-block parsing this is a subset of pandoc's fenced divs. currently we only use this for admonitions (which get a new name to differentiate them from other kinds of blocks), but more users will appear soon. --- .../src/nixos_render_docs/md.py | 73 +++++++++++++++---- .../src/tests/test_plugins.py | 38 ++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) 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 64298e8b6cc0..5c824a25c0c0 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 @@ -1,7 +1,7 @@ from abc import ABC from collections.abc import Mapping, MutableMapping, Sequence from frozendict import frozendict # type: ignore[attr-defined] -from typing import Any, Callable, cast, Iterable, Optional +from typing import Any, Callable, cast, get_args, Iterable, Literal, NoReturn, Optional import dataclasses import re @@ -28,9 +28,13 @@ _md_escape_table = { def md_escape(s: str) -> str: return s.translate(_md_escape_table) +AttrBlockKind = Literal['admonition'] + +AdmonitionKind = Literal["note", "caution", "tip", "important", "warning"] + class Renderer(markdown_it.renderer.RendererProtocol): - _admonitions: dict[str, tuple[RenderFn, RenderFn]] - _admonition_stack: list[str] + _admonitions: dict[AdmonitionKind, tuple[RenderFn, RenderFn]] + _admonition_stack: list[AdmonitionKind] def __init__(self, manpage_urls: Mapping[str, str], parser: Optional[markdown_it.MarkdownIt] = None): self._manpage_urls = manpage_urls @@ -62,8 +66,8 @@ class Renderer(markdown_it.renderer.RendererProtocol): 'dd_open': self.dd_open, 'dd_close': self.dd_close, 'myst_role': self.myst_role, - "container_admonition_open": self.admonition_open, - "container_admonition_close": self.admonition_close, + "admonition_open": self.admonition_open, + "admonition_close": self.admonition_close, "attr_span_begin": self.attr_span_begin, "attr_span_end": self.attr_span_end, "heading_open": self.heading_open, @@ -73,11 +77,11 @@ class Renderer(markdown_it.renderer.RendererProtocol): } self._admonitions = { - "{.note}": (self.note_open, self.note_close), - "{.caution}": (self.caution_open,self.caution_close), - "{.tip}": (self.tip_open, self.tip_close), - "{.important}": (self.important_open, self.important_close), - "{.warning}": (self.warning_open, self.warning_close), + "note": (self.note_open, self.note_close), + "caution": (self.caution_open,self.caution_close), + "tip": (self.tip_open, self.tip_close), + "important": (self.important_open, self.important_close), + "warning": (self.warning_open, self.warning_close), } self._admonition_stack = [] @@ -88,7 +92,7 @@ class Renderer(markdown_it.renderer.RendererProtocol): def admonition_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: - tag = token.info.strip() + tag = token.meta['kind'] self._admonition_stack.append(tag) return self._admonitions[tag][0](token, tokens, i, options, env) def admonition_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, @@ -254,6 +258,8 @@ def _is_escaped(src: str, pos: int) -> bool: # the contents won't be split apart in the regex because spacing rules get messy here _ATTR_SPAN_PATTERN = re.compile(r"\{([^}]*)\}") +# this one is for blocks with attrs. we want to use it with fullmatch() to deconstruct an info. +_ATTR_BLOCK_PATTERN = re.compile(r"\s*\{([^}]*)\}\s*") def _parse_attrs(s: str) -> Optional[tuple[Optional[str], list[str]]]: (id, classes) = (None, []) @@ -269,6 +275,21 @@ def _parse_attrs(s: str) -> Optional[tuple[Optional[str], list[str]]]: return (id, classes) +def _parse_blockattrs(info: str) -> Optional[tuple[AttrBlockKind, Optional[str], list[str]]]: + if (m := _ATTR_BLOCK_PATTERN.fullmatch(info)) is None: + return None + if (parsed_attrs := _parse_attrs(m[1])) is None: + return None + id, classes = parsed_attrs + # check that we actually support this kind of block, and that is adheres to + # whetever restrictions we want to enforce for that kind of block. + if len(classes) == 1 and classes[0] in get_args(AdmonitionKind): + # don't want to support ids for admonitions just yet + if id is not None: + return None + return ('admonition', id, classes) + return None + def _attr_span_plugin(md: markdown_it.MarkdownIt) -> None: def attr_span(state: markdown_it.rules_inline.StateInline, silent: bool) -> bool: if state.src[state.pos] != '[': @@ -395,6 +416,29 @@ def _compact_list_attr(md: markdown_it.MarkdownIt) -> None: md.core.ruler.push("compact_list_attr", compact_list_attr) +def _block_attr(md: markdown_it.MarkdownIt) -> None: + def assert_never(value: NoReturn) -> NoReturn: + assert False + + def block_attr(state: markdown_it.rules_core.StateCore) -> None: + stack = [] + for token in state.tokens: + if token.type == 'container_blockattr_open': + if (parsed_attrs := _parse_blockattrs(token.info)) is None: + # if we get here we've missed a possible case in the plugin validate function + raise RuntimeError("this should be unreachable") + kind, id, classes = parsed_attrs + if kind == 'admonition': + token.type = 'admonition_open' + token.meta['kind'] = classes[0] + stack.append('admonition_close') + else: + assert_never(kind) + elif token.type == 'container_blockattr_close': + token.type = stack.pop() + + md.core.ruler.push("block_attr", block_attr) + class Converter(ABC): __renderer__: Callable[[Mapping[str, str], markdown_it.MarkdownIt], Renderer] @@ -412,10 +456,8 @@ class Converter(ABC): ) self._md.use( container_plugin, - name="admonition", - validate=lambda name, *args: ( - name.strip() in self._md.renderer._admonitions # type: ignore[attr-defined] - ) + name="blockattr", + validate=lambda name, *args: _parse_blockattrs(name), ) self._md.use(deflist_plugin) self._md.use(myst_role_plugin) @@ -424,6 +466,7 @@ class Converter(ABC): self._md.use(_block_comment_plugin) self._md.use(_heading_ids) self._md.use(_compact_list_attr) + self._md.use(_block_attr) self._md.enable(["smartquotes", "replacements"]) def _parse(self, src: str, env: Optional[MutableMapping[str, Any]] = None) -> 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 index ff5eea97700d..db05d6253c8f 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py +++ b/pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py @@ -384,3 +384,41 @@ def test_heading_attributes() -> None: 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_admonitions() -> None: + c = Converter({}) + assert c._parse("::: {.note}") == [ + Token(type='admonition_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup=':::', info=' {.note}', meta={'kind': 'note'}, block=True, + hidden=False), + Token(type='admonition_close', tag='div', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("::: {.caution}") == [ + Token(type='admonition_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup=':::', info=' {.caution}', meta={'kind': 'caution'}, + block=True, hidden=False), + Token(type='admonition_close', tag='div', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("::: {.tip}") == [ + Token(type='admonition_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup=':::', info=' {.tip}', meta={'kind': 'tip'}, block=True, + hidden=False), + Token(type='admonition_close', tag='div', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("::: {.important}") == [ + Token(type='admonition_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup=':::', info=' {.important}', meta={'kind': 'important'}, + block=True, hidden=False), + Token(type='admonition_close', tag='div', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) + ] + assert c._parse("::: {.warning}") == [ + Token(type='admonition_open', tag='div', nesting=1, attrs={}, map=[0, 1], level=0, + children=None, content='', markup=':::', info=' {.warning}', meta={'kind': 'warning'}, + block=True, hidden=False), + Token(type='admonition_close', tag='div', nesting=-1, attrs={}, map=None, level=0, + children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) + ]