From e8e381c99866847fd78a57f22c2ec973648d533f Mon Sep 17 00:00:00 2001 From: Matt Sturgeon Date: Sat, 4 Jul 2026 19:53:26 +0100 Subject: [PATCH] nixos-render-docs: parse GFM-alerts as admonitions Mostly useful for out-of-tree users of nixos-render-docs, but could also be useful if NixOS/Nixpkgs decides to transition to GFM's flavour of admonition syntax in the future. Both GFM-style and pandoc-fenced-div will now be tokenised as "admonitions". --- .../src/nixos_render_docs/md.py | 53 ++++++++++++ .../nixos-render-docs/src/tests/sample_md.py | 6 ++ .../src/tests/test_asciidoc.py | 12 +++ .../src/tests/test_commonmark.py | 4 + .../nixos-render-docs/src/tests/test_html.py | 8 ++ .../src/tests/test_manpage.py | 12 +++ .../src/tests/test_plugins.py | 84 +++++++++++++++++++ 7 files changed, 179 insertions(+) diff --git a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/md.py b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/md.py index 237c554075bd..508edb28b37a 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/md.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/nixos_render_docs/md.py @@ -579,6 +579,58 @@ def _block_titles(block: str) -> Callable[[markdown_it.MarkdownIt], None]: return do_add + +def _gfm_alerts(md: markdown_it.MarkdownIt) -> None: + + _ALERT_PATTERN = re.compile(r"^\[\!(TIP|NOTE|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)", re.IGNORECASE) + + @dataclasses.dataclass + class Entry: + open: Token + content: Token | None = None + + """ + Find blockquote tokens and convert GFM-alert-style blockquotes to admonition tokens. + """ + def gfm_alert(state: markdown_it.rules_core.StateCore) -> None: + stack: list[Entry] = [] + size = len(state.tokens) + + for i, token in enumerate(state.tokens): + match token.type: + case "blockquote_open": + entry = Entry(token) + # Get the first inline token of the blockquote's first paragraph + if i + 2 < size: + para = state.tokens[i + 1] + inline = state.tokens[i + 2] + if para and para.type == "paragraph_open" and inline and inline.type == "inline": + entry.content = inline + stack.append(entry) + + case "blockquote_close": + entry = stack.pop() + + if entry.content is None: + continue + + m = _ALERT_PATTERN.match(entry.content.content) + if m is None: + continue + + # Remove the alert marker from the rendered text. + entry.content.content = entry.content.content[m.end() :] + + # Rewrite the enclosing blockquote as an admonition. + entry.open.type = "admonition_open" + entry.open.tag = "div" + entry.open.meta["kind"] = m.group(1).lower() + token.type = "admonition_close" + token.tag = "div" + + md.core.ruler.after("block", "github-alerts", gfm_alert) + + TR = TypeVar('TR', bound='Renderer') class Converter(ABC, Generic[TR]): @@ -626,6 +678,7 @@ class Converter(ABC, Generic[TR]): self._md.use(_block_attr) self._md.use(_block_titles("example")) self._md.use(_block_titles("figure")) + self._md.use(_gfm_alerts) self._md.enable(["smartquotes", "replacements"]) def _parse(self, src: str) -> list[Token]: diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/sample_md.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/sample_md.py index 73a64fde8364..af34aecf3e6e 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/sample_md.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/sample_md.py @@ -1,4 +1,10 @@ sample1 = """\ +> [!NOTE] +> This is a *GFM* note. +> +> > [!caution] +> > This is a **nested** GFM alert. + :::: {.warning} foo ::: {.note} diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_asciidoc.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_asciidoc.py index 3cf5b208f392..100604c8329b 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_asciidoc.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_asciidoc.py @@ -45,6 +45,18 @@ f def test_full() -> None: c = Converter({ 'man(1)': 'http://example.org' }) assert c._render(sample1) == """\ +[NOTE] +==== +This is a __GFM__ note{zwsp}. + +[CAUTION] +===== +This is a **nested** GFM alert{zwsp}. +===== + +==== + + [WARNING] ==== foo diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_commonmark.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_commonmark.py index 4ff0bc3095c3..8a4e20d3d564 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_commonmark.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_commonmark.py @@ -27,6 +27,10 @@ def test_indented_fence() -> None: def test_full() -> None: c = Converter({ 'man(1)': 'http://example.org' }) assert c._render(sample1) == """\ +**Note:** This is a *GFM* note\\. + +**Caution:** This is a **nested** GFM alert\\. + **Warning:** foo **Note:** nested diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_html.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_html.py index 9a3e07cb24c7..611a9a826ac6 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_html.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_html.py @@ -149,6 +149,14 @@ def test_footnotes() -> None: def test_full() -> None: c = Converter({ 'man(1)': 'http://example.org' }, {}) assert c._render(sample1) == unpretty(""" +
+

Note

+

This is a GFM note.

+
+

Caution

+

This is a nested GFM alert.

+
+

Warning

foo

diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manpage.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manpage.py index b6e4a94ef1c6..c843991dd47e 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manpage.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_manpage.py @@ -41,6 +41,18 @@ def test_full() -> None: assert c._render(sample1) == """\ .sp .RS 4 +\\fBNote\\fP +.br +This is a \\fIGFM\\fR note\\&. +.sp +.RS 4 +\\fBCaution\\fP +.br +This is a \\fBnested\\fR GFM alert\\&. +.RE +.RE +.sp +.RS 4 \\fBWarning\\fP .br foo diff --git a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_plugins.py b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_plugins.py index c38f82afd67b..107d351f1d04 100644 --- a/pkgs/by-name/ni/nixos-render-docs/src/tests/test_plugins.py +++ b/pkgs/by-name/ni/nixos-render-docs/src/tests/test_plugins.py @@ -429,6 +429,90 @@ def test_admonitions() -> None: children=None, content='', markup=':::', info='', meta={}, block=True, hidden=False) ] +@pytest.mark.parametrize( + ("alert", "kind"), + [ + ("NOTE", "note"), + ("TIP", "tip"), + ("IMPORTANT", "important"), + ("WARNING", "warning"), + ("CAUTION", "caution"), + ], +) +def test_gfm_alert_kinds(alert: str, kind: str) -> None: + c = Converter({}) + assert c._parse(f"> [!{alert}]\n> body") == [ + Token(type='admonition_open', tag='div', markup='>', meta={'kind': kind}, nesting=1, map=[0, 2], block=True), + Token(type='paragraph_open', tag='p', nesting=1, map=[0, 2], level=1, block=True), + Token(type='inline', tag='', nesting=0, map=[0, 2], level=2, block=True, content='body', children=[ + Token(type='text', tag='', nesting=0, content='body'), + ]), + Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True), + Token(type='admonition_close', tag='div', nesting=-1, markup='>', block=True) + ] + +def test_gfm_alert_basic_structure_preserved() -> None: + c = Converter({}) + tokens = c._parse("> [!NOTE]\n> body text") + assert any(t.type == "inline" for t in tokens) + +@pytest.mark.parametrize( + "md", + [ + "> # [!NOTE]", + "> - [!NOTE]", + "> - [!WARNING] body", + "> ### [!TIP]", + "> ```\n> [!NOTE]\n> ```", + "> This is not [!NOTE] an alert", + "> [!NOTE] with trailing text is ignored", + ], +) +def test_gfm_alert_rejected(md: str) -> None: + c = Converter({}) + tokens = c._parse(md) + assert all(t.type != "admonition_open" for t in tokens) + assert tokens[0].type == "blockquote_open" + assert tokens[-1].type == "blockquote_close" + +def test_gfm_alert_multi_paragraph() -> None: + c = Converter({}) + assert c._parse( + "> [!NOTE]\n" + "> line 1\n" + ">\n" + "> line 2" + ) == [ + Token(type='admonition_open', tag='div', markup='>', meta={'kind': 'note'}, nesting=1, map=[0, 4], block=True), + Token(type='paragraph_open', tag='p', nesting=1, map=[0, 2], level=1, block=True), + Token(type='inline', tag='', nesting=0, map=[0, 2], level=2, block=True, content='line 1', children=[ + Token(type='text', tag='', nesting=0, content='line 1'), + ]), + Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True), + Token(type='paragraph_open', tag='p', nesting=1, map=[3, 4], level=1, block=True), + Token(type='inline', tag='', nesting=0, map=[3, 4], level=2, block=True, content='line 2', children=[ + Token(type='text', tag='', nesting=0, content='line 2') + ]), + Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True), + Token(type='admonition_close', tag='div', markup='>', block=True, nesting=-1), + ] + +def test_gfm_alert_whitespace_tolerant() -> None: + c = Converter({}) + tokens = c._parse("> [!NOTE] \n> body") + assert tokens[0].type == "admonition_open" + assert tokens[0].meta["kind"] == "note" + +def test_gfm_alert_empty_body_allowed() -> None: + c = Converter({}) + assert c._parse("> [!NOTE]\n>") == [ + Token(type='admonition_open', tag='div', markup='>', meta={'kind': 'note'}, nesting=1, map=[0, 2], block=True), + Token(type='paragraph_open', tag='p', nesting=1, map=[0, 1], level=1, block=True), + Token(type='inline', tag='', nesting=0, map=[0, 1], level=2, block=True, children=[]), + Token(type='paragraph_close', tag='p', nesting=-1, level=1, block=True), + Token(type='admonition_close', tag='div', markup='>', nesting=-1, block=True), + ] + def test_example() -> None: c = Converter({}) assert c._parse("::: {.example}\n# foo") == [