1+ """Helpers to handle directives---including their headers and fence syntax."""
2+
13from __future__ import annotations
24
35from collections .abc import Mapping , MutableMapping , Sequence
46import io
57
68from markdown_it import MarkdownIt
9+ import mdformat
10+ import mdformat .plugins
711from mdformat .renderer import LOGGER , RenderContext , RenderTreeNode
812import 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 ()
0 commit comments