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".
This commit is contained in:
Matt Sturgeon
2026-07-05 20:33:14 +01:00
parent 1774c9744a
commit e8e381c998
7 changed files with 179 additions and 0 deletions
@@ -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]:
@@ -1,4 +1,10 @@
sample1 = """\
> [!NOTE]
> This is a *GFM* note.
>
> > [!caution]
> > This is a **nested** GFM alert.
:::: {.warning}
foo
::: {.note}
@@ -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
@@ -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
@@ -149,6 +149,14 @@ def test_footnotes() -> None:
def test_full() -> None:
c = Converter({ 'man(1)': 'http://example.org' }, {})
assert c._render(sample1) == unpretty("""
<div class="note">
<h3 class="title">Note</h3>
<p>This is a <span class="emphasis"><em>GFM</em></span> note.</p>
<div class="caution">
<h3 class="title">Caution</h3>
<p>This is a <span class="strong"><strong>nested</strong></span> GFM alert.</p>
</div>
</div>
<div class="warning">
<h3 class="title">Warning</h3>
<p>foo</p>
@@ -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
@@ -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") == [