Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions mdformat_myst/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

import re
import textwrap
from typing import Dict
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Expand Down Expand Up @@ -45,13 +48,80 @@ 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
# CommonMark AST, so we need to do this to make validation pass.
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., `<p id="a" key1="value1">` as equivalent to `<p key1="value1" id="a">` 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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__future__.annotations is used, so we don't need Dict

Suggested change
def _reconstruct_attrs(attrs: Dict[str, str | int | float]) -> str:
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"] + "}"
Expand Down Expand Up @@ -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("+++"):
Expand Down Expand Up @@ -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,
}
34 changes: 34 additions & 0 deletions tests/data/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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$
Expand Down
Loading