Skip to content

Commit 0c79aab

Browse files
authored
✨ More/improved syntax extensions (GFM alerts, GFM autolink, ...) (#1128)
1 parent 6fac4d0 commit 0c79aab

12 files changed

Lines changed: 329 additions & 37 deletions

File tree

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ repos:
3535
additional_dependencies:
3636
- types-urllib3
3737
- sphinx~=8.2
38-
- markdown-it-py~=4.0
39-
- mdit-py-plugins~=0.5.0
38+
- markdown-it-py~=4.2
39+
- mdit-py-plugins~=0.6.0
4040
files: >
4141
(?x)^(
4242
myst_parser/.*py|

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
# -- MyST settings ---------------------------------------------------
9797

9898
myst_enable_extensions = [
99+
"alert",
99100
"dollarmath",
100101
"amsmath",
101102
"deflist",

docs/syntax/admonitions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Admonitions (also known as callouts) highlight a particular block of text,
44
that exists slightly apart from the narrative of your page, such as a note or a warning.
55

6+
:::{seealso}
7+
The [alert extension](syntax/alerts) provides GitHub-style alerts as a lightweight alternative syntax for admonitions.
8+
:::
9+
610
Admonitions are a special case of {{directive}} extensions.
711
It is advised to use admonitions with the [colon_fence](#syntax/colon_fence) extension, which signify that the content of the block is also MyST Markdown.
812

docs/syntax/optional.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ To enable all the syntaxes explained below:
2828

2929
```python
3030
myst_enable_extensions = [
31+
"alert",
3132
"amsmath",
3233
"attrs_inline",
3334
"colon_fence",
3435
"deflist",
3536
"dollarmath",
3637
"fieldlist",
38+
"gfm_autolink",
3739
"html_admonition",
3840
"html_image",
3941
"linkify",
@@ -89,6 +91,12 @@ text | converted
8991
The `strikethrough` extension allows text within `~~` delimiters to have a strikethrough (horizontal line) placed over it.
9092
For example, `~~strikethrough with *emphasis*~~` renders as: ~~strikethrough with *emphasis*~~.
9193

94+
```{versionadded} 5.1.0
95+
`myst_strikethrough_single_tilde` option
96+
```
97+
98+
To also allow single tilde delimiters (e.g. `~strikethrough~`), set `myst_strikethrough_single_tilde = True` in your {{ confpy }}.
99+
92100
:::{warning}
93101
This extension is currently only supported for HTML output,
94102
and you will need to suppress the `myst.strikethrough` warning
@@ -296,6 +304,27 @@ This extension requires that [linkify-it-py](https://github.com/tsutsu3/linkify-
296304
Either directly; `pip install linkify-it-py` or *via* `pip install myst-parser[linkify]`.
297305
:::
298306

307+
(syntax/gfm-autolink)=
308+
## GFM Autolinks
309+
310+
```{versionadded} 5.1.0
311+
```
312+
313+
Adding `"gfm_autolink"` to `myst_enable_extensions` (in the {{ confpy }}) will enable the [GitHub Flavored Markdown autolink extension](https://github.github.com/gfm/#autolinks-extension-).
314+
This recognises bare `www.` URLs, `http(s)://` URLs, and email addresses, converting them to hyperlinks.
315+
316+
Unlike the `linkify` extension, `gfm_autolink` does **not** require any additional package to be installed.
317+
It follows the GFM specification for determining URL boundaries.
318+
319+
:::{note}
320+
`gfm_autolink` and `linkify` should not both be enabled at the same time, as they serve the same purpose with different matching algorithms.
321+
322+
Key differences:
323+
324+
- **`linkify`** matches schema-less domains (e.g. `example.com` with `fuzzy_link=True`), supports many protocols, and requires `linkify-it-py`.
325+
- **`gfm_autolink`** only matches `www.`-prefixed URLs, `http(s)://`/`mailto:`/`xmpp:` URLs, and email addresses. It has no extra dependency and follows GFM URL boundary rules exactly.
326+
:::
327+
299328
(syntax/substitutions)=
300329

301330
## Substitutions (with Jinja2)
@@ -412,6 +441,13 @@ you can also use `:::` delimiters to denote directives, instead of ```` ``` ````
412441
Using colons instead of back-ticks has the benefit of allowing the content to be rendered correctly, when you are working in any standard Markdown editor.
413442
It is ideal for admonition type directives (as documented in [Directives](syntax/directives)) or tables with titles, for example:
414443

444+
```{versionadded} 5.1.0
445+
`myst_colon_fence_exact_match` option
446+
```
447+
448+
By default, a closing colon fence with *at least* as many colons as the opening fence will close the block (matching the behaviour of backtick fences).
449+
To require the closing fence to have *exactly* the same number of colons as the opening, set `myst_colon_fence_exact_match = True` in your {{ confpy }}.
450+
415451
:::::{myst-example}
416452

417453
:::{note}
@@ -590,6 +626,38 @@ and are applied to markdown list items starting with `[ ]` or `[x]`:
590626
- [x] An item that is complete
591627
:::
592628

629+
(syntax/alerts)=
630+
## Alerts (GitHub-style callouts)
631+
632+
```{versionadded} 5.1.0
633+
```
634+
635+
By adding `"alert"` to `myst_enable_extensions` (in the {{ confpy }}),
636+
you can use [GitHub-style alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) (also known as callouts).
637+
These are rendered as admonition nodes in the output.
638+
639+
The syntax is a blockquote beginning with a special marker like `[!NOTE]`:
640+
641+
:::{myst-example}
642+
> [!NOTE]
643+
> This is a note alert.
644+
645+
> [!TIP]
646+
> This is a tip alert.
647+
648+
> [!IMPORTANT]
649+
> This is an important alert.
650+
651+
> [!WARNING]
652+
> This is a warning alert.
653+
654+
> [!CAUTION]
655+
> This is a caution alert.
656+
:::
657+
658+
The five supported alert types map to the following docutils admonition nodes:
659+
`NOTE`, `TIP`, `IMPORTANT`, `WARNING`, and `CAUTION`.
660+
593661
(syntax/fieldlists)=
594662
## Field Lists
595663

myst_parser/config/main.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def check_extensions(inst: "MdParserConfig", field: dc.Field, value: Any) -> Non
2828
raise TypeError(f"'{field.name}' not iterable: {value}")
2929
diff = set(value).difference(
3030
[
31+
"alert",
3132
"amsmath",
3233
"attrs_image",
3334
"attrs_inline",
@@ -36,6 +37,7 @@ def check_extensions(inst: "MdParserConfig", field: dc.Field, value: Any) -> Non
3637
"deflist",
3738
"dollarmath",
3839
"fieldlist",
40+
"gfm_autolink",
3941
"html_admonition",
4042
"html_image",
4143
"linkify",
@@ -440,6 +442,24 @@ def __repr__(self) -> str:
440442
},
441443
)
442444

445+
strikethrough_single_tilde: bool = dc.field(
446+
default=False,
447+
metadata={
448+
"validator": instance_of(bool),
449+
"help": "Allow single tilde (~) for strikethrough, in addition to double (~~)",
450+
"extension": "strikethrough",
451+
},
452+
)
453+
454+
colon_fence_exact_match: bool = dc.field(
455+
default=False,
456+
metadata={
457+
"validator": instance_of(bool),
458+
"help": "Require closing colon fence to have exactly the same number of colons as the opening",
459+
"extension": "colon_fence",
460+
},
461+
)
462+
443463
# docutils only (replicating aspects of sphinx config)
444464

445465
suppress_warnings: Sequence[str] = dc.field(

myst_parser/mdit_to_docutils/base.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,40 @@ def render_list_item(self, token: SyntaxTreeNode) -> None:
515515
self.copy_attributes(token, item_node, keys=("class", "id"))
516516
self.add_line_and_source_path(item_node, token)
517517
with self.current_node_context(item_node, append=True):
518-
self.render_children(token)
518+
# Handle built-in tasklist checkbox (markdown-it-py >= 4.1)
519+
meta = token.meta if token.meta else {}
520+
if "checked" in meta:
521+
checked = meta["checked"]
522+
editable = self.md.options.get("tasklists_editable", False)
523+
if editable:
524+
item_node["classes"].append("enabled")
525+
checked_attr = 'checked="checked" ' if checked else ""
526+
disabled_attr = "" if editable else 'disabled="disabled" '
527+
checkbox_html = (
528+
f'<input class="task-list-item-checkbox" '
529+
f'{checked_attr}{disabled_attr}type="checkbox">'
530+
)
531+
self.render_children(token)
532+
# The first child is always a paragraph: markdown-it-py emits
533+
# paragraph_open/close for both tight and loose lists (tight
534+
# lists mark them hidden, but we render them regardless).
535+
# Tasklist items by definition start with `[ ]`/`[x]` text
536+
# content, so a paragraph is always the first block child.
537+
if item_node.children and isinstance(
538+
item_node.children[0], nodes.paragraph
539+
):
540+
item_node.children[0].insert(
541+
0, nodes.raw("", checkbox_html, format="html")
542+
)
543+
else:
544+
self.create_warning(
545+
"Tasklist item has no leading paragraph for checkbox",
546+
MystWarnings.RENDER_METHOD,
547+
line=token_line(token, 0),
548+
append_to=self.current_node,
549+
)
550+
else:
551+
self.render_children(token)
519552

520553
def render_em(self, token: SyntaxTreeNode) -> None:
521554
node = nodes.emphasis()
@@ -552,6 +585,30 @@ def render_blockquote(self, token: SyntaxTreeNode) -> None:
552585
inline=True,
553586
)
554587

588+
_alert_node_mapping: dict[str, type[nodes.Element]] = {
589+
"NOTE": nodes.note,
590+
"TIP": nodes.tip,
591+
"IMPORTANT": nodes.important,
592+
"WARNING": nodes.warning,
593+
"CAUTION": nodes.caution,
594+
}
595+
596+
def render_alert(self, token: SyntaxTreeNode) -> None:
597+
"""Render a GitHub-style alert as a docutils admonition."""
598+
kind = token.info.upper() if token.info else ""
599+
node_cls = self._alert_node_mapping.get(kind, nodes.admonition)
600+
admonition = node_cls()
601+
if node_cls is nodes.admonition:
602+
# Generic admonition needs a title
603+
title = nodes.title("", kind.capitalize())
604+
admonition += title
605+
self.add_line_and_source_path(admonition, token)
606+
with self.current_node_context(admonition, append=True):
607+
self.render_children(token)
608+
609+
def render_alert_title(self, token: SyntaxTreeNode) -> None:
610+
"""Skip the alert title — docutils admonitions generate their own."""
611+
555612
def render_hr(self, token: SyntaxTreeNode) -> None:
556613
node = nodes.transition()
557614
self.add_line_and_source_path(node, token)

myst_parser/parsers/mdit.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,19 @@
88

99
from markdown_it import MarkdownIt
1010
from markdown_it.renderer import RendererProtocol
11+
from markdown_it.rules_block import make_fence_rule
1112
from mdit_py_plugins.amsmath import amsmath_plugin
1213
from mdit_py_plugins.attrs import attrs_block_plugin, attrs_plugin
13-
from mdit_py_plugins.colon_fence import colon_fence_plugin
1414
from mdit_py_plugins.deflist import deflist_plugin
1515
from mdit_py_plugins.dollarmath import dollarmath_plugin
1616
from mdit_py_plugins.field_list import fieldlist_plugin
1717
from mdit_py_plugins.footnote import footnote_plugin
1818
from mdit_py_plugins.front_matter import front_matter_plugin
19+
from mdit_py_plugins.gfm import gfm_plugin
20+
from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin
1921
from mdit_py_plugins.myst_blocks import myst_block_plugin
2022
from mdit_py_plugins.myst_role import myst_role_plugin
2123
from mdit_py_plugins.substitution import substitution_plugin
22-
from mdit_py_plugins.tasklists import tasklists_plugin
2324
from mdit_py_plugins.wordcount import wordcount_plugin
2425

2526
from myst_parser.config.main import MdParserConfig
@@ -43,16 +44,13 @@ def create_md_parser(
4344

4445
if config.gfm_only:
4546
# see https://github.github.com/gfm/
46-
md = (
47-
MarkdownIt("commonmark", renderer_cls=renderer)
48-
# note, strikethrough currently only supported tentatively for HTML
49-
.enable("strikethrough")
50-
.enable("table")
51-
.use(tasklists_plugin, enabled=config.enable_checkboxes)
52-
.enable("linkify")
53-
.use(wordcount_plugin, per_minute=config.words_per_minute)
47+
md = MarkdownIt("commonmark", renderer_cls=renderer)
48+
md.use(
49+
gfm_plugin,
50+
tasklists_editable=config.enable_checkboxes,
5451
)
55-
md.options.update({"linkify": True, "myst_config": config})
52+
md.use(wordcount_plugin, per_minute=config.words_per_minute)
53+
md.options.update({"myst_config": config})
5654
return md
5755

5856
md = (
@@ -76,8 +74,11 @@ def create_md_parser(
7674
md.enable("linkify")
7775
if md.linkify is not None:
7876
md.linkify.set({"fuzzy_link": config.linkify_fuzzy_links})
77+
if "gfm_autolink" in config.enable_extensions:
78+
md.use(gfm_autolink_plugin)
7979
if "strikethrough" in config.enable_extensions:
8080
md.enable("strikethrough")
81+
md.options["strikethrough_single_tilde"] = config.strikethrough_single_tilde
8182
if "dollarmath" in config.enable_extensions:
8283
md.use(
8384
dollarmath_plugin,
@@ -87,15 +88,28 @@ def create_md_parser(
8788
double_inline=config.dmath_double_inline,
8889
)
8990
if "colon_fence" in config.enable_extensions:
90-
md.use(colon_fence_plugin)
91+
colon_fence = make_fence_rule(
92+
markers=(":",),
93+
token_type="colon_fence",
94+
exact_match=config.colon_fence_exact_match,
95+
)
96+
md.block.ruler.before(
97+
"fence",
98+
"colon_fence",
99+
colon_fence,
100+
{"alt": ["paragraph", "reference", "blockquote", "list", "footnote_def"]},
101+
)
91102
if "amsmath" in config.enable_extensions:
92103
md.use(amsmath_plugin)
93104
if "deflist" in config.enable_extensions:
94105
md.use(deflist_plugin)
95106
if "fieldlist" in config.enable_extensions:
96107
md.use(fieldlist_plugin)
97108
if "tasklist" in config.enable_extensions:
98-
md.use(tasklists_plugin, enabled=config.enable_checkboxes)
109+
md.options["tasklists"] = True
110+
md.options["tasklists_editable"] = config.enable_checkboxes
111+
if "alert" in config.enable_extensions:
112+
md.options["alerts"] = True
99113
if "substitution" in config.enable_extensions:
100114
md.use(substitution_plugin, *config.sub_delimiters)
101115
if "attrs_inline" in config.enable_extensions:

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ requires-python = ">=3.11"
3636
dependencies = [
3737
"docutils>=0.20,<0.23",
3838
"jinja2", # required for substitutions, but let sphinx choose version
39-
"markdown-it-py~=4.0",
40-
"mdit-py-plugins~=0.5",
39+
"markdown-it-py~=4.2",
40+
"mdit-py-plugins~=0.6",
4141
"pyyaml",
4242
"sphinx>=8,<10",
4343
]
@@ -88,8 +88,8 @@ mypy = [
8888
"mypy==1.19.1",
8989
"types-urllib3",
9090
"sphinx~=8.2",
91-
"markdown-it-py~=4.0",
92-
"mdit-py-plugins~=0.5.0",
91+
"markdown-it-py~=4.2",
92+
"mdit-py-plugins~=0.6.0",
9393
]
9494
ruff = ["ruff==0.14.11"]
9595

0 commit comments

Comments
 (0)