Skip to content

Commit 6aeb26c

Browse files
committed
Allow colon fences, adding space around nested ones
1 parent 888b07d commit 6aeb26c

File tree

4 files changed

+197
-38
lines changed

4 files changed

+197
-38
lines changed

mdformat_myst/_directives.py

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
"""Helpers to handle directives---including their headers and fence syntax."""
2+
13
from __future__ import annotations
24

35
from collections.abc import Mapping, MutableMapping, Sequence
46
import io
57

68
from markdown_it import MarkdownIt
9+
import mdformat
10+
import mdformat.plugins
711
from mdformat.renderer import LOGGER, RenderContext, RenderTreeNode
812
import ruamel.yaml
913

@@ -37,11 +41,14 @@ def fence(node: "RenderTreeNode", context: "RenderContext") -> str:
3741
"""
3842
info_str = node.info.strip()
3943
lang = info_str.split(maxsplit=1)[0] if info_str else ""
44+
is_directive = lang.startswith("{") and lang.endswith("}")
4045
code_block = node.content
4146

4247
# Info strings of backtick code fences can not contain backticks or tildes.
4348
# If that is the case, we make a tilde code fence instead.
44-
if "`" in info_str or "~" in info_str:
49+
if node.type == "colon_fence":
50+
fence_char = ":"
51+
elif "`" in info_str or "~" in info_str:
4552
fence_char = "~"
4653
else:
4754
fence_char = "`"
@@ -60,48 +67,65 @@ def fence(node: "RenderTreeNode", context: "RenderContext") -> str:
6067
f"(line {node.map[0] + 1} before formatting)"
6168
)
6269
# This "elif" is the *only* thing added to the upstream `fence` implementation!
63-
elif lang.startswith("{") and lang.endswith("}"):
64-
code_block = format_directive_content(code_block)
70+
elif is_directive:
71+
code_block = format_directive_content(code_block, context=context)
6572

6673
# The code block must not include as long or longer sequence of `fence_char`s
6774
# as the fence string itself
6875
fence_len = max(3, longest_consecutive_sequence(code_block, fence_char) + 1)
6976
fence_str = fence_char * fence_len
70-
71-
return f"{fence_str}{info_str}\n{code_block}{fence_str}"
72-
73-
74-
def format_directive_content(raw_content: str) -> str:
75-
parse_result = parse_opts_and_content(raw_content)
76-
if not parse_result:
77-
return raw_content
78-
unformatted_yaml, content = parse_result
79-
dump_stream = io.StringIO()
80-
try:
81-
parsed = yaml.load(unformatted_yaml)
82-
yaml.dump(parsed, stream=dump_stream)
83-
except ruamel.yaml.YAMLError:
84-
LOGGER.warning("Invalid YAML in MyST directive options.")
85-
return raw_content
86-
formatted_yaml = dump_stream.getvalue()
87-
88-
# Remove the YAML closing tag if added by `ruamel.yaml`
89-
if formatted_yaml.endswith("\n...\n"):
90-
formatted_yaml = formatted_yaml[:-4]
91-
92-
# Convert empty YAML to most concise form
93-
if formatted_yaml == "null\n":
94-
formatted_yaml = ""
95-
96-
formatted = "---\n" + formatted_yaml + "---\n"
77+
formatted_fence = f"{fence_str}{info_str}\n"
78+
if code_block.startswith(":::"):
79+
formatted_fence += "\n"
80+
formatted_fence += f"{code_block}{fence_str}"
81+
return formatted_fence
82+
83+
84+
def format_directive_content(raw_content: str, context) -> str:
85+
unformatted_yaml, content = parse_opts_and_content(raw_content)
86+
formatted = ""
87+
if unformatted_yaml is not None:
88+
dump_stream = io.StringIO()
89+
try:
90+
parsed = yaml.load(unformatted_yaml)
91+
yaml.dump(parsed, stream=dump_stream)
92+
except ruamel.yaml.YAMLError:
93+
LOGGER.warning("Invalid YAML in MyST directive options.")
94+
return raw_content
95+
formatted_yaml = dump_stream.getvalue()
96+
97+
# Remove the YAML closing tag if added by `ruamel.yaml`
98+
if formatted_yaml.endswith("\n...\n"):
99+
formatted_yaml = formatted_yaml[:-4]
100+
101+
# Convert empty YAML to most concise form
102+
if formatted_yaml == "null\n":
103+
formatted_yaml = ""
104+
105+
formatted += "---\n" + formatted_yaml + "---\n"
97106
if content:
98-
formatted += content + "\n"
107+
# Get currently active plugin modules
108+
active_plugins = context.options.get("parser_extension", [])
109+
110+
# Resolve modules back to their string names
111+
# mdformat.text() requires names (str), not objects
112+
extension_names = [
113+
name
114+
for name, plugin in mdformat.plugins.PARSER_EXTENSIONS.items()
115+
if plugin in active_plugins
116+
]
117+
formatted += mdformat.text(
118+
content, options=context.options, extensions=extension_names
119+
)
120+
formatted = formatted.rstrip("\n") + "\n"
121+
if formatted.endswith(":::\n"):
122+
formatted += "\n"
99123
return formatted
100124

101125

102-
def parse_opts_and_content(raw_content: str) -> tuple[str, str] | None:
126+
def parse_opts_and_content(raw_content: str) -> tuple[str | None, str]:
103127
if not raw_content:
104-
return None
128+
return None, raw_content
105129
lines = raw_content.splitlines()
106130
line = lines.pop(0)
107131
yaml_lines = []
@@ -111,15 +135,17 @@ def parse_opts_and_content(raw_content: str) -> tuple[str, str] | None:
111135
if all(c == "-" for c in line) and len(line) >= 3:
112136
break
113137
yaml_lines.append(line)
114-
elif line.lstrip().startswith(":"):
138+
elif line.lstrip().startswith(":") and not line.lstrip().startswith(":::"):
115139
yaml_lines.append(line.lstrip()[1:])
116140
while lines:
117-
if not lines[0].lstrip().startswith(":"):
141+
if not lines[0].lstrip().startswith(":") or lines[0].lstrip().startswith(
142+
":::"
143+
):
118144
break
119145
line = lines.pop(0).lstrip()[1:]
120146
yaml_lines.append(line)
121147
else:
122-
return None
148+
return None, raw_content
123149

124150
first_line_is_empty_but_second_line_isnt = (
125151
len(lines) >= 2 and not lines[0].strip() and lines[1].strip()

mdformat_myst/plugin.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from markdown_it import MarkdownIt
77
import mdformat.plugins
88
from mdformat.renderer import RenderContext, RenderTreeNode
9+
from mdit_py_plugins.colon_fence import colon_fence_plugin
910
from mdit_py_plugins.dollarmath import dollarmath_plugin
1011
from mdit_py_plugins.myst_blocks import myst_block_plugin
1112
from mdit_py_plugins.myst_role import myst_role_plugin
@@ -45,6 +46,9 @@ def update_mdit(mdit: MarkdownIt) -> None:
4546
# Enable dollarmath markdown-it extension
4647
mdit.use(dollarmath_plugin)
4748

49+
# Enable support for the colon fence syntax
50+
mdit.use(colon_fence_plugin)
51+
4852
# Trick `mdformat`s AST validation by removing HTML rendering of code
4953
# blocks and fences. Directives are parsed as code fences and we
5054
# modify them in ways that don't break MyST AST but do break
@@ -117,7 +121,6 @@ def _escape_paragraph(text: str, node: RenderTreeNode, context: RenderContext) -
117121
lines = text.split("\n")
118122

119123
for i in range(len(lines)):
120-
121124
# Three or more "+" chars are interpreted as a block break. Escape them.
122125
space_removed = lines[i].replace(" ", "")
123126
if space_removed.startswith("+++"):
@@ -144,15 +147,17 @@ def _escape_text(text: str, node: RenderTreeNode, context: RenderContext) -> str
144147
return text
145148

146149

150+
CHANGES_AST = True
147151
RENDERERS = {
148152
"blockquote": _math_block_safe_blockquote_renderer,
153+
"colon_fence": fence,
154+
"fence": fence,
149155
"myst_role": _role_renderer,
150156
"myst_line_comment": _comment_renderer,
151157
"myst_block_break": _blockbreak_renderer,
152158
"myst_target": _target_renderer,
153159
"math_inline": _math_inline_renderer,
154160
"math_block_label": _math_block_label_renderer,
155161
"math_block": _math_block_renderer,
156-
"fence": fence,
157162
}
158163
POSTPROCESSORS = {"paragraph": _escape_paragraph, "text": _escape_text}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ keywords = "mdformat,markdown,formatter,gfm"
2020

2121
requires-python = ">=3.10"
2222
requires = [
23+
"markdown-it-py >= 2.0.0",
2324
"mdformat >=0.7.0",
2425
"mdit-py-plugins >=0.3.0",
2526
"mdformat-front-matters >= 1.0.0",

tests/data/fixtures.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,3 +504,130 @@ MyST directive, no opts or content
504504
```{some-directive} args
505505
```
506506
.
507+
MyST colon fenced directive with title
508+
.
509+
:::{admonition} MyST colon fenced directive with a title
510+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
511+
incididunt ut labore et dolore magna aliqua.
512+
:::
513+
.
514+
:::{admonition} MyST colon fenced directive with a title
515+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
516+
incididunt ut labore et dolore magna aliqua.
517+
:::
518+
.
519+
520+
MyST colon fenced directive with metadata
521+
.
522+
:::{admonition} MyST colon fenced directive with metadata
523+
:class: foo
524+
:truc: bla
525+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
526+
incididunt ut labore et dolore magna aliqua.
527+
:::
528+
.
529+
:::{admonition} MyST colon fenced directive with metadata
530+
---
531+
class: foo
532+
truc: bla
533+
---
534+
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
535+
incididunt ut labore et dolore magna aliqua.
536+
:::
537+
.
538+
539+
MyST colon fenced directive with nested directive
540+
.
541+
::::{admonition} Parent directive
542+
:::{image} foo.png
543+
:class: foo
544+
:alt: An image
545+
:::
546+
::::
547+
.
548+
::::{admonition} Parent directive
549+
550+
:::{image} foo.png
551+
---
552+
class: foo
553+
alt: An image
554+
---
555+
:::
556+
557+
::::
558+
.
559+
560+
MyST colon fenced directive with multiple nested admonitions
561+
.
562+
::::{admonition} Multiple nested admonitions
563+
:::{admonition}
564+
First nested admonition content.
565+
:::
566+
:::{admonition}
567+
Second nested admonition content.
568+
:::
569+
:::{admonition}
570+
Third nested admonition content.
571+
:::
572+
::::
573+
.
574+
::::{admonition} Multiple nested admonitions
575+
576+
:::{admonition}
577+
First nested admonition content.
578+
:::
579+
580+
:::{admonition}
581+
Second nested admonition content.
582+
:::
583+
584+
:::{admonition}
585+
Third nested admonition content.
586+
:::
587+
588+
::::
589+
.
590+
591+
MyST colon fenced directive with mixed content and nested directives
592+
.
593+
::::{hint} A hint with nested tips and paragraphs
594+
This is some introductory text.
595+
:::{tip}
596+
A nested tip with content.
597+
:::
598+
More text between directives.
599+
:::{tip}
600+
Another nested tip.
601+
:::
602+
Concluding text.
603+
::::
604+
.
605+
::::{hint} A hint with nested tips and paragraphs
606+
This is some introductory text.
607+
608+
:::{tip}
609+
A nested tip with content.
610+
:::
611+
612+
More text between directives.
613+
614+
:::{tip}
615+
Another nested tip.
616+
:::
617+
618+
Concluding text.
619+
::::
620+
.
621+
622+
MyST colon fenced directive nested in list
623+
.
624+
- Item with directive
625+
:::{tip} Nested tip in list item
626+
Tip content inside a list item.
627+
:::
628+
.
629+
- Item with directive
630+
:::{tip} Nested tip in list item
631+
Tip content inside a list item.
632+
:::
633+
.

0 commit comments

Comments
 (0)