nixos-render-docs: add --admonition-style gfm to commonmark renderer

Support rendering markdown admonishments using GitHub-flavoured markdown
"alerts" syntax. This is useful for targets like mkBook which also
support the GFM-alerts extension.
This commit is contained in:
Matt Sturgeon
2026-07-05 20:33:15 +01:00
parent 085ed21c70
commit 858eb3b15c
8 changed files with 210 additions and 12 deletions
@@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import cast, Optional
from .md import md_escape, md_make_code, Renderer
from .types import AdmonitionStyle
from markdown_it.token import Token
@@ -20,12 +21,14 @@ class Par:
class CommonMarkRenderer(Renderer):
__output__ = "commonmark"
_admonition_style: AdmonitionStyle
_parstack: list[Par]
_link_stack: list[str]
_list_stack: list[List]
def __init__(self, manpage_urls: Mapping[str, str]):
def __init__(self, manpage_urls: Mapping[str, str], admonition_style: AdmonitionStyle = AdmonitionStyle.PLAIN):
super().__init__(manpage_urls)
self._admonition_style = admonition_style
self._parstack = [ Par("") ]
self._link_stack = []
self._list_stack = []
@@ -44,11 +47,23 @@ class CommonMarkRenderer(Renderer):
return result
def _admonition_open(self, kind: str) -> str:
pbreak = self._maybe_parbreak()
self._enter_block("")
return f"{pbreak}**{kind}:** "
match self._admonition_style:
case AdmonitionStyle.PLAIN:
pbreak = self._maybe_parbreak()
self._enter_block("")
return f"{pbreak}**{kind}:** "
case AdmonitionStyle.GFM:
pbreak = self._maybe_parbreak()
lbreak = self._break()
self._enter_block("> ")
return f"{pbreak}> [!{kind}]{lbreak}> "
def _admonition_close(self) -> str:
self._leave_block()
match self._admonition_style:
case AdmonitionStyle.PLAIN:
self._leave_block()
case AdmonitionStyle.GFM:
self._leave_block()
return ""
def _indent_raw(self, s: str) -> str:
@@ -21,7 +21,7 @@ from .html import HTMLRenderer
from .manpage import ManpageRenderer, man_escape
from .manual_structure import make_xml_id, XrefTarget
from .md import Converter, md_escape, md_make_code
from .types import OptionLoc, Option, RenderedOption, AnchorStyle
from .types import OptionLoc, Option, RenderedOption, AnchorStyle, AdmonitionStyle
def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]:
if key not in option:
@@ -317,18 +317,20 @@ class OptionsCommonMarkRenderer(OptionDocsRestrictions, CommonMarkRenderer):
class CommonMarkConverter(BaseConverter[OptionsCommonMarkRenderer]):
__option_block_separator__ = ""
_admonition_style: AdmonitionStyle
_anchor_style: AnchorStyle
_anchor_prefix: str
def __init__(self, manpage_urls: Mapping[str, str], revision: str, anchor_style: AnchorStyle = AnchorStyle.NONE, anchor_prefix: str = ""):
def __init__(self, manpage_urls: Mapping[str, str], revision: str, anchor_style: AnchorStyle = AnchorStyle.NONE, anchor_prefix: str = "", admonition_style: AdmonitionStyle = AdmonitionStyle.PLAIN):
super().__init__(revision)
self._renderer = OptionsCommonMarkRenderer(manpage_urls)
self._renderer = OptionsCommonMarkRenderer(manpage_urls, admonition_style)
self._admonition_style = admonition_style
self._anchor_style = anchor_style
self._anchor_prefix = anchor_prefix
def _parallel_render_prepare(self) -> Any:
return (self._renderer._manpage_urls, self._revision, self._anchor_style, self._anchor_prefix)
return (self._renderer._manpage_urls, self._revision, self._anchor_style, self._anchor_prefix, self._admonition_style)
@classmethod
def _parallel_render_init_worker(cls, a: Any) -> CommonMarkConverter:
return cls(*a)
@@ -514,6 +516,15 @@ def parse_anchor_style(value: str|AnchorStyle) -> AnchorStyle:
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid value {value}\nExpected one of {', '.join(style.value for style in AnchorStyle)}")
def parse_admonition_style(value: str|AdmonitionStyle) -> AdmonitionStyle:
if isinstance(value, AdmonitionStyle):
# Used by `argparse.add_argument`'s `default`
return value
try:
return AdmonitionStyle(value.lower())
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid value {value}\nExpected one of {', '.join(style.value for style in AdmonitionStyle)}")
def _build_cli_commonmark(p: argparse.ArgumentParser) -> None:
p.add_argument('--manpage-urls', required=True)
p.add_argument('--revision', required=True)
@@ -529,6 +540,13 @@ def _build_cli_commonmark(p: argparse.ArgumentParser) -> None:
default="",
help="(default: no prefix) String to prepend to anchor ids. Not used when anchor style is none."
)
p.add_argument(
'--admonition-style',
required=False,
default=AdmonitionStyle.PLAIN.value,
choices = [style.value for style in AdmonitionStyle],
help = "(default: %(default)s) Admonition style to use for notes, warnings, etc. \nOnly plain is standard CommonMark."
)
p.add_argument("infile")
p.add_argument("outfile")
@@ -566,7 +584,9 @@ def _run_cli_commonmark(args: argparse.Namespace) -> None:
md = CommonMarkConverter(json.load(manpage_urls),
revision = args.revision,
anchor_style = parse_anchor_style(args.anchor_style),
anchor_prefix = args.anchor_prefix)
anchor_prefix = args.anchor_prefix,
admonition_style = parse_admonition_style(args.admonition_style),
)
with open(args.infile, 'r') as f:
md.add_options(json.load(f))
@@ -17,3 +17,7 @@ RenderFn = Callable[[Token, Sequence[Token], int], str]
class AnchorStyle(Enum):
NONE = "none"
LEGACY = "legacy"
class AdmonitionStyle(Enum):
PLAIN = "plain"
GFM = "gfm"
@@ -0,0 +1,17 @@
{
"services.frobnicator.types.<name>.enable": {
"declarations": [
"nixos/modules/services/frobnicator.nix"
],
"description": "Whether to enable the frobnication of this (`<name>`) type.\n::: {.important}\n\nAdmonition.\n\n:::",
"loc": [
"services",
"frobnicator",
"types",
"<name>",
"enable"
],
"readOnly": false,
"type": "boolean"
}
}
@@ -0,0 +1,16 @@
## services\.frobnicator\.types\.\<name>\.enable
Whether to enable the frobnication of this (` <name> `) type\.
> [!Important]
> Admonition\.
*Type:*
boolean
*Declared by:*
- [\<nixpkgs/nixos/modules/services/frobnicator\.nix>](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/frobnicator.nix)
@@ -0,0 +1,15 @@
## services\.frobnicator\.types\.\<name>\.enable
Whether to enable the frobnication of this (` <name> `) type\.
**Important:** Admonition\.
*Type:*
boolean
*Declared by:*
- [\<nixpkgs/nixos/modules/services/frobnicator\.nix>](https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/frobnicator.nix)
@@ -1,3 +1,5 @@
import pytest
import nixos_render_docs as nrd
from sample_md import sample1
@@ -6,9 +8,14 @@ from typing import Mapping
class Converter(nrd.md.Converter[nrd.commonmark.CommonMarkRenderer]):
def __init__(self, manpage_urls: Mapping[str, str]):
def __init__(
self,
manpage_urls: Mapping[str, str],
admonition_style: nrd.types.AdmonitionStyle = nrd.types.AdmonitionStyle.PLAIN,
):
super().__init__()
self._renderer = nrd.commonmark.CommonMarkRenderer(manpage_urls)
self._renderer = nrd.commonmark.CommonMarkRenderer(manpage_urls, admonition_style)
# NOTE: in these tests we represent trailing spaces by ` ` and replace them with real space later,
# since a number of editors will strip trailing whitespace on save and that would break the tests.
@@ -24,6 +31,84 @@ def test_indented_fence() -> None:
""".replace(' ', ' ')
assert c._render(s) == s
@pytest.mark.parametrize(
("style", "expected"),
[
(
nrd.types.AdmonitionStyle.PLAIN,
"""\
**Warning:** foo
**Note:** nested
**Important:** nested GFM
:::
**Caution:** GFM caution
**Important:** nested fenced
**Note:** nested GFM\
""",
),
(
nrd.types.AdmonitionStyle.GFM,
f"""\
> [!Warning]
> foo
>{" "}
> > [!Note]
> > nested
> [!Important]
> nested GFM
:::
> [!Caution]
> GFM caution
>{" "}
> > [!Important]
> > nested fenced
>{" "}
> > [!Note]
> > nested GFM\
""",
),
],
)
def test_admonition_styles(
style: nrd.types.AdmonitionStyle,
expected: str,
) -> None:
c = Converter({}, admonition_style=style)
assert c._render("""\
:::: {.warning}
foo
::: {.note}
nested
:::
::::
> [!important]
> nested GFM
:::
> [!caution]
> GFM caution
>
> ::: {.important}
> nested fenced
> :::
>
> > [!note]
> > nested GFM
""") == expected
def test_full() -> None:
c = Converter({ 'man(1)': 'http://example.org' })
assert c._render(sample1) == """\
@@ -39,3 +39,29 @@ def test_options_commonmark_legacy_anchors() -> None:
c.add_options(opts)
s = c.finalize()
assert s == expected
@pytest.mark.parametrize(
("style", "expected_file"),
[
(nixos_render_docs.types.AdmonitionStyle.PLAIN,
"tests/sample_options_admonition_plain.md"),
(nixos_render_docs.types.AdmonitionStyle.GFM,
"tests/sample_options_admonition_gfm.md"),
],
)
def test_options_commonmark_admonition_style(style, expected_file):
c = nixos_render_docs.options.CommonMarkConverter(
{},
"local",
admonition_style=style,
)
with Path("tests/sample_options_admonition.json").open() as f:
opts = json.load(f)
with Path(expected_file).open() as f:
expected = f.read()
c.add_options(opts)
assert c.finalize() == expected