diff --git a/mdformat_myst/plugin.py b/mdformat_myst/plugin.py index 88f2a1a..c12de21 100644 --- a/mdformat_myst/plugin.py +++ b/mdformat_myst/plugin.py @@ -2,10 +2,13 @@ import re import textwrap +from typing import Dict from markdown_it import MarkdownIt +from markdown_it.rules_core.state_core import StateCore import mdformat.plugins from mdformat.renderer import RenderContext, RenderTreeNode +from mdit_py_plugins.attrs import attrs_block_plugin from mdit_py_plugins.dollarmath import dollarmath_plugin from mdit_py_plugins.myst_blocks import myst_block_plugin from mdit_py_plugins.myst_role import myst_role_plugin @@ -45,6 +48,9 @@ def update_mdit(mdit: MarkdownIt) -> None: # Enable dollarmath markdown-it extension mdit.use(dollarmath_plugin) + # Enable support for attribute tagging for paragraphs and other "blocks" + mdit.use(attrs_block_plugin) + # Trick `mdformat`s AST validation by removing HTML rendering of code # blocks and fences. Directives are parsed as code fences and we # modify them in ways that don't break MyST AST but do break @@ -52,6 +58,70 @@ def update_mdit(mdit: MarkdownIt) -> None: mdit.add_render_rule("fence", render_fence_html) mdit.add_render_rule("code_block", render_fence_html) + # Force `mdformat` to treat "equivalent" attribute sets in a given HTML element + # (e.g., `
` as equivalent to `
` by + # just sorting all such key/value groups. + # + # Multiple block attributes that are stacked on top of each other can create output + # HTML attribute orderings (from mdit_py_plugins.attrs's parsing logic) that cannot + # be replicated if we (nicely, for a formatter) collapse those blocks into a single + # nicely-ordered attr dict. Therefore, there is no way to avoid doing this sorting, + # i.e., we cannot just "preserve" the input ordering. + mdit.core.ruler.push("sort_attrs", _sort_attrs) + + +def _sort_attrs(state: StateCore) -> None: + """Sort attributes in all tokens to ensure deterministic HTML rendering. + + This fixes validation errors where `mdformat` thinks the HTML has changed + simply because the attribute order flipped (e.g. `id="..." class="..."` + vs `class="..." id="..."`). + """ + for token in state.tokens: + if token.attrs: + token.attrs = dict(sorted(token.attrs.items())) + + +def _reconstruct_attrs(attrs: Dict[str, str | int | float]) -> str: + if not attrs: + return "" + + parts = [] + if "id" in attrs: + parts.append(f"#{attrs['id']}") + if "class" in attrs: + assert isinstance(attrs["class"], str), ( + "mdit_py_plugins.attrs guarantees a string here." + ) + for cls in attrs["class"].split(): + parts.append(f".{cls}") + for k, v in attrs.items(): + if k in {"id", "class"}: + continue + parts.append(f'{k}="{v}"') + + if not parts: + return "" + return "{" + " ".join(parts) + "}" + + +def _append_attrs_postprocessor( + text: str, node: RenderTreeNode, context: RenderContext +) -> str: + """Prepend MyST attributes to the already-rendered text.""" + attrs_str = _reconstruct_attrs(node.attrs) + if attrs_str: + return f"{attrs_str}\n{text}" + return text + + +def _paragraph_postprocessor( + text: str, node: RenderTreeNode, context: RenderContext +) -> str: + """Encapsulate all paragraph post-processing.""" + text = _escape_paragraph(text, node, context) + return _append_attrs_postprocessor(text, node, context) + def _role_renderer(node: RenderTreeNode, context: RenderContext) -> str: role_name = "{" + node.meta["name"] + "}" @@ -117,7 +187,6 @@ def _escape_paragraph(text: str, node: RenderTreeNode, context: RenderContext) - lines = text.split("\n") for i in range(len(lines)): - # Three or more "+" chars are interpreted as a block break. Escape them. space_removed = lines[i].replace(" ", "") if space_removed.startswith("+++"): @@ -155,4 +224,14 @@ def _escape_text(text: str, node: RenderTreeNode, context: RenderContext) -> str "math_block": _math_block_renderer, "fence": fence, } -POSTPROCESSORS = {"paragraph": _escape_paragraph, "text": _escape_text} +POSTPROCESSORS = { + "blockquote": _append_attrs_postprocessor, + "colon_fence": _append_attrs_postprocessor, + "fence": _append_attrs_postprocessor, + "heading": _append_attrs_postprocessor, + "table": _append_attrs_postprocessor, + # Paragraphs require special handling to escape strings like "++", but also need to + # be able to have attrs added. + "paragraph": _paragraph_postprocessor, + "text": _escape_text, +} diff --git a/tests/data/fixtures.md b/tests/data/fixtures.md index 0b70e44..505df47 100644 --- a/tests/data/fixtures.md +++ b/tests/data/fixtures.md @@ -137,6 +137,40 @@ That's a myst target^ The escape has no effect . +Document block attribute order stability +. +{ #id2 key1="value1" .class1 .class2 key2="value2" } +block +. +{#id2 .class1 .class2 key1="value1" key2="value2"} +block +. + +Block attribute collapsing +. + {#id1 .class1 key1="value1"} +{#id2 .class2 key2="value2"} +block +. +{#id2 .class1 .class2 key1="value1" key2="value2"} +block +. + +Attribute attached to flowchart +. +{caption="`GROUPS` - Description."} +```mermaid +flowchart LR + id +``` +. +{caption="`GROUPS` - Description."} +```mermaid +flowchart LR + id +``` +. + Dollarmath inline . Inline math: $a=1$