A Rust Markdown parser and MDHTML renderer.
The parser is tree-oriented. It preserves the structure and attributes needed for MDHTML output, but it does not try to round-trip source text. The dialect is CommonMark/GFM for the core and GFM features, with Pandoc-leaning choices where extension families disagree.
mdhtml is largely implemented using AI, except for the tests. The tests are largely adapted from cmark-gfm, PHP Markdown Extra, kramdown, Pandoc, and Mistlefoot. Credit for mdhtml really belongs to the authors of these tests, and of the CommonMark docs, which is where the hard work was done.
- Core block syntax: paragraphs, ATX/setext headings, thematic breaks, block quotes, ordered/unordered lists, indented code, raw HTML, link reference definitions.
- Tables: GFM/PHP Extra pipe tables with alignment, and Pandoc grid tables with alignment, headerless tables, block cell content, row spans, column spans, and footers.
- GFM: task lists,
~~x~~strikethrough, angle and bare autolinks, plus opt-in tagfiltering. Bare URL and email autolinking is on by default and can be disabled withbare_autolinks=False; explicit CommonMark angle autolinks remain enabled. - Code: backtick/tilde fenced code blocks, info strings, and Pandoc-style code attributes.
- HTML-in-Markdown: block containers opened with
markdown="1"; the control attribute is stripped, indented code blocks are disabled inside the container, and fenced code is the code-block syntax there. - Math: four modes:
bracketsfor\(...\),\[...\], and$$...$$,dollarsfor those plus$...$using Pandoc's non-space/digit dollar rules,onto preserve\(...\)and\[...\]delimiters for client-side renderers such as KaTeX, andoff. Brackets mode is the default. - Attributes and inline spans: Pandoc/kramdown-style
{#id .class key="value"}, block IALs{: ...}, span IALs, ALDs such as{:note: #id .class}with references, superscript^x^, subscript~x~, and highlight==x==. - Definition lists: PHP Markdown Extra/Pandoc-style
Termfollowed by: definitionor~ definition. - Footnotes:
[^id]references to defined[^id]:definitions with indented continuation blocks. - Abbreviations:
*[HTML]: Hyper Text Markup Languagedefinitions render matching text as<abbr>. - Fenced divs: Pandoc/Quarto/Djot-style
:::containers with attributes or a single class word. - Raw passthrough: a Pandoc-style raw attribute names the format a payload is written for. A fenced code block whose info string is exactly
{=name}, or inline code followed immediately by{=name}, renders as an inert<script type="application/vnd.mdhtml.raw" data-format="name">. Payload text stays literal unless it contains an HTML script-data hazard; the dialect specification defines the encoding rule. - Template tokens: configured Jinja, Mustache, or similar delimiters are preserved as inert HTML template elements. Recognition is opt-in; overlapping openers use the longest match, and optional balanced scanning handles nested expression syntax.
- Cross-references: Quarto-style bracketed references to identified elements.
[@sec-pay]renders as<a data-ref href="#sec-pay"></a>, a symbolic carrier each converter resolves its own way.[-@sec-pay]adds the independentbaretoken,[Clause @sec-pay]carries override text, and[@sec-a; @sec-b]groups references in aspanmarked withdata-refs. A trailing{ref=page}selects thepagevariant. The parser never resolves numbers or checks that targets exist. - Table captions and figures: a
: caption {attrs}line glued directly under a table's last row captions it (attrs apply to the table; Quarto's caption format, glued-only and after-only in ours). Withimplicit_figures=True, a paragraph that is exactly one image becomes a<figure>with the alt text as<figcaption>. The image's id and classes move to the figure, and the promoted image getsalt=""so assistive technology does not announce the caption twice. - Inline footnotes: pandoc-style
^[an inline note], numbered together with[^id]references. - Smart punctuation (opt-in
smart=True):---and--to em and en dashes,...to an ellipsis, and quote curling, in text only; code, math, and raw payloads are untouched.
A braced group is an attribute list only when it starts with :, #, ., or a key=value pair. Anything else in braces is ordinary text, so prose like use {braces} freely keeps its content. The marker forms follow Pandoc: {#id .class key="value"}. The colon form follows kramdown: {:note} and {: note} apply the attribute definition named note, and an unknown name in a colon-marked list is ignored while the list itself is still consumed.
ALDs (attribute list definitions) are kramdown's named bundles. {:note: #id .class} on its own line defines note; a reference resolves either as a colon-marked list ({:note}) or as a bare token inside a list already recognized by its markers ({.x note}).
Attribute lists attach to:
- Headings, ATX and setext:
# Head {#h}. Withauto_ids=True, headings without an explicit id get a pandoc-style one derived from their text (lowercased, punctuation dropped, spaces to hyphens,-1suffixes on duplicates). Automatic ids are off by default. - Fenced code: in the info string,
python {.numberLines}after the opening fence. - Fenced divs: in the
:::opener. - Tables: a trailing list on the glued
: captionline applies to the table. - Link reference definitions:
[r]: /url "title" {.external}applies the attributes to every link resolved through that reference. - Any block, via a standalone IAL line
{: ...}. IALs bind by adjacency: glued directly under a block (including the last row of a table) they modify it, glued directly above a block they modify that one, and an isolated IAL with blank lines on both sides is literal text. This is also the only way to attribute a paragraph; a brace group at the end of a paragraph's own text is always literal. - Inline constructs, when the list follows immediately with no space: spans
[x]{.c}, links, images, code spans, emphasis, strong, strikethrough, superscript, subscript, highlight, and math.
Raw HTML blocks take no attribute lists; write attributes in the HTML itself.
Install via pip to get both the Python API and the native mdhtml CLI:
pip install mdhtmlThe CLI reads Markdown from stdin or from an optional file path and writes an MDHTML fragment to stdout:
echo '# Hello' | mdhtml
mdhtml input.md > out.html
mdhtml --math=on input.md > out.html
mdhtml --math=dollars input.md > out.html
mdhtml --auto-ids --implicit-figures input.md > out.html
mdhtml --no-bare-autolinks input.md > out.htmlPython API:
from mdhtml import to_mdhtml
html = to_mdhtml(r"\(x^2\)")
html_for_katex = to_mdhtml(r"\(x^2\)", math="on")
html_with_dollars = to_mdhtml("$x$", math="dollars")
html_with_inferred_structure = to_mdhtml(markdown, auto_ids=True, implicit_figures=True)
html_without_bare_links = to_mdhtml(markdown, bare_autolinks=False)TemplateDelimiter preserves template-language tokens without executing or interpreting them. The token body becomes text inside an inert HTML template element; Markdown and HTML inside it are not parsed.
from mdhtml import TemplateDelimiter, to_mdhtml
mustache = [
TemplateDelimiter("mustachebare", "{{{", "}}}"),
TemplateDelimiter("mustache", "{{", "}}"),
]
html = to_mdhtml("Hello {{ name }} and {{{ bio }}}", templates=mustache)This produces:
<p>Hello <template data-template="mustache"> name </template> and <template data-template="mustachebare"> bio </template></p>Configuration order does not matter: the longest matching opener wins. Opening delimiters must be unique, but syntax names need not be. Use balance=("{", "}") for expressions with nested braces:
expressions = [TemplateDelimiter("expression", "${", "}", balance=("{", "}"))]
html = to_mdhtml('${make({"x": 1})}', templates=expressions)form="auto", the default, makes a token on an otherwise blank source line a block and an embedded token inline. form="inline" always keeps the token inline. form="block" recognizes it only on its own line.
to_dom renders Markdown directly to a mutable JustHTML DOM:
from justhtml import Text
from mdhtml import to_dom
doc = to_dom("Hello *world*")
paragraph = doc.children[0]
paragraph.attrs["class"] = "intro"
paragraph.children[1].children[0].data = "everyone"
paragraph.append_child(Text("!"))
html = doc.to_html(pretty=False)Use parse_mdhtml(source) when the input is already MDHTML. Both functions parse as an HTML body fragment with sanitization disabled, which is the processing context defined by the dialect. JustHTML exposes parsed template contents as template.template_content. See its DOM API for node creation, mutation, traversal, querying, and serialization.
rewrite changes recognized Markdown constructs without regenerating the rest of the document. A callback returns None to leave a construct alone, a string to replace the whole construct, or a dict to replace one of its named fields.
This converts inline dollar math to bracket math:
from mdhtml import rewrite
def bracket_math(node):
if node["delimiter"] != "$": return None
return rf"\({node['tex']}\)"
markdown = rewrite(markdown, {"math_inline": bracket_math}, math="dollars")An image callback can save a data URL and replace only its destination. The alt text, title, attributes, and original spacing are preserved.
from base64 import b64decode
from pathlib import Path
from mdhtml import rewrite
def save_image(node):
if not node["url"].startswith("data:image/png;base64,"): return None
path = Path("images/plot.png")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b64decode(node["url"].split(",", 1)[1]))
return {"url": path.as_posix()}
markdown = rewrite(markdown, {"image": save_image})Callbacks run in source order. Their edits are checked first and then applied from the end of the document, so an early replacement cannot invalidate a later source position. Exceptions from callbacks are passed through unchanged.
Every callback node is a dict with these common fields:
type: callback name, currentlyimageormath_inline.source: the exact source text for the construct.start,end: half-open character offsets into the original Python string.
An image node has:
form: currently alwaysinline.alt: plain alt text.url: the decoded image destination.title: decoded title text, orNone.
An image callback may return {"url": "new destination"}. Other image fields are read-only. Reference-style images such as ![alt][id] are not callback targets.
A math_inline node has:
delimiter:$,$$,\(, or\[.tex: content without delimiters.display:Truefor$$and\[, otherwiseFalse.
A math callback may return {"tex": "new TeX"} to preserve the delimiters, or a string to replace the entire construct. Dollar math is recognized only with math="dollars", using the same dollar rules as rendering.
Rewriting is confined to inline-capable prose regions. Inline code, fenced and indented code blocks, raw HTML blocks, block math, link reference definitions, and grid tables are left untouched. Inline images and math inside paragraphs, headings, lists, block quotes, definition bodies, footnotes, and pipe tables are supported.
Python callers can override rendered nodes with callbacks. Each callback receives a node dict and the default MDHTML for that node. Return None to keep the default, or return replacement MDHTML.
Callback names:
- Blocks:
paragraph,heading,block_quote,list,definition_list,code_block,html_block,html_container,thematic_break,table,div,math_block,raw_block,figure - Inlines:
text,soft_break,hard_break,emph,strong,strike,superscript,subscript,highlight,code,link,image,autolink,abbr,html_inline,math_inline,footnote_ref,span,note,raw_inline - Either form:
template_token
Children are transformed before their enclosing block callback. Image callbacks receive the plain alt text and form="inline" or form="figure"; inline callbacks do not run inside alt attributes. With implicit_figures=True, a Figure callback also receives the original image url, alt, and title, plus caption_html and content_html. content_html is the transformed image rendered on its own with usable default alt text, so returning it unwraps the Figure. The default Figure rendering clears default image alt text and emits the non-empty caption; an image callback's replacement is used verbatim.
A template_token callback receives syntax, exact source, delimiter-free body, and form="inline" or form="block". Both forms use the same callback name.
from fastpylight import highlight
from mdhtml import to_mdhtml
def highlight_code(node, default_html):
if node["lang"] != "python": return None
return highlight(node["text"], node["lang"]) + "\n"
html = to_mdhtml(markdown, callbacks={"code_block": highlight_code})Callbacks can also render bracket math as MathML:
from math_core import LatexToMathML
from mdhtml import to_mdhtml
mathml = LatexToMathML()
def render_math(node, default_html):
html = mathml.convert_with_local_state(node["tex"], displaystyle=node["type"] == "math_block")
return html + ("\n" if node["type"] == "math_block" else "")
html = to_mdhtml(markdown, callbacks={"math_inline": render_math, "math_block": render_math})blocks reports where each top-level block sits in the source, so callers can split a document into per-block source slices without regenerating Markdown from a tree. Each dict has type (the callback names above, plus link_ref, abbr_def, attr_def, and footnote_def) and half-open 0-based start/end line indices; code and math blocks also carry their inner text, and fences carry info/lang. Headings carry level, id, and attr-stripped text, tables id and caption, and figures id, text (the alt), url, and title. An image-only paragraph is a paragraph by default and a figure when called with implicit_figures=True, matching to_mdhtml. Pass the same templates configuration to report standalone tokens as template_token blocks.
from mdhtml import blocks
src = open("input.md").read()
lines = src.split("\n")
for b in blocks(src):
print(b["type"], "\n".join(lines[b["start"]:b["end"]]))to_mdhtml output is deliberately symbolic: cross-reference anchors are empty, captions and headings are unnumbered, raw payloads sit in inert script carriers, and code blocks are plain pre > code. to_html lowers all of that to finished HTML a browser renders directly:
from mdhtml import to_html, to_mdhtml
html = to_html(to_mdhtml(markdown), number_headings='legal')The result is still a body fragment (a str subclass carrying a warnings list; pass dest= to also write a file). to_html accepts an MDHTML string or a JustHTML DocumentFragment, never mutates its input, and applies:
- Cross-references become real links with baked text:
[@sec-pay]renders as<a href="#sec-pay">Section 1.</a>, groups join as "Sections 1. and 1.(a)", and figure and table targets get "Figure 1"-style text.reftypes=dict(exh=('Exhibit', 'Exhibits'))adds prefix words beyond the built-insec,fig, andtbl. A missing target, an unknown token, or an unknown type needing a prefix raises. The Word-onlypageandrelvariants render as the full number.refs='ids'is the other mode, for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (<a href="#sec-pay" class="xref">sec-pay</a>, author text kept as a prefix, variants ignored), with no registry, numbering, or failure modes.id_prefix='md-'namespaces the output against the ids of a host page: every element id is prefixed (the original kept indata-id, e.g. for CSSattr()markers), along with ref hrefs and any link to an in-fragment id; links to outside ids are untouched. - Headings are numbered when
number_headingsis given ('legal', 'decimal', or a{lvlText: numFmt}dict as in mdhtml2docx), or automatically with 'decimal' when some reference needs a heading number. Numbers bake in as<span class="heading-number">, and full-context reference text ("3.(c)(iii)") is computed Word-style from the scheme. - Figures and tables number independently and always: a caption or an id earns a
<span class="caption-label">Figure 1</span>:in thefigcaptionorcaption. {=html}raw data is decoded and spliced in place; raw data for other formats is removed. Malformed payloads are dropped with a warning.- A
colwidthsattribute lowers to a<colgroup>;frvalues share the width remaining after fixed lengths. - Code blocks with a language are highlighted when fastpylight is installed:
hl='spans'(default) emitshl-*classed spans,hl='api'wraps the block in the<hl-code>element for the CSS Custom Highlight API, andhl=Noneleaves code untouched. Two per-block hooks customize this:hl_lang(text, lang)may return a corrected language before highlighting (e.g. mapping a%%sqlfirst line tosql), andcode_wrap(html, lang, text)may return replacement markup for the finished block (a copy-button wrapper, a mermaidpre). toc=Trueprepends a<nav class="toc">of the headings.
to_html emits no styles or scripts. The assets each feature needs are supplied by your own pipeline:
- Spans-mode code colors:
fastpylight.theme_css(theme, "pre code", "hl-"). - Highlight-API code colors:
fastpylight.theme_css(theme)plus the<hl-code>component fromfastpylight.component_js(). - Math: KaTeX (or similar) plus
mdhtml.math_js(fn=None, **opts), which emits a guarded per-node render function for eachspan.math/div.mathcarrier (fnnames it for dynamic pages to re-run per swap; baremath_js()renders the document immediately;optsmerge into thekatex.renderoptions); the carriers themselves are plain HTML.
to_md lowers Markdown to portable GFM-plus-footnotes for renderers such as GitHub that know nothing of the mdhtml dialect. It is a source-preserving rewrite, not a re-rendering: only mdhtml-specific constructs change, and every other byte of the source passes through untouched.
from mdhtml import to_md
portable = to_md(markdown, number_headings='legal')Cross-references become plain text ("See Section 1.(a)"), with the same reftypes, number_headings, and auto-numbering rules as to_html; heading numbers bake into the heading text and attribute lists are stripped from it. A glued : caption line becomes a "Table 1: caption" paragraph, and with implicit_figures=True an image-only paragraph gains a "Figure 1: alt" paragraph. Span, link, image, code, and math attribute lists are stripped ([x]{.note} becomes x); IAL, ALD, and abbreviation definition lines are deleted; fenced-div ::: lines are removed with their content kept. Raw {=md} blocks and inlines are spliced verbatim, other formats are removed, and grid tables (which have no GFM equivalent) drop to their rendered HTML table. References are plain text rather than links deliberately: text works on every renderer, while anchor links depend on per-platform id handling and slug rules.
Inline constructs are recognized at any nesting depth with the parser's own grammar, so code spans, links, and escapes are honored, and use {braces} freely stays literal. Block constructs are rewritten wherever their lines carry no container marker, which includes markdown="1" containers and fenced divs; a heading or table caption inside a blockquote or list passes through unchanged, with a warning when it needed numbering or stripping.
Command-line usage (the mdhtml script is installed with the package):
mdhtml input.md > out.html
cat input.md | mdhtml --math=dollarsThe parser uses the two-phase strategy described in the CommonMark parsing-strategy appendix: first build the block tree and collect link reference definitions, then parse raw inline text with the completed reference table. It tracks visual columns and byte offsets for each line and builds blocks with an arena-backed open-container stack. The stack has typed nodes for block quotes, lists, paragraphs/setext candidates, fenced and indented code, raw HTML, table candidates, grid tables, math, footnote definitions, definition lists, fenced divs, and markdown-in-HTML containers. Inlines are scanned into atoms, bracket openers, and delimiter runs; links/images/spans resolve through the bracket stack, while emphasis/strong/strikethrough resolve through the delimiter stack. Inputs that can otherwise explode have explicit bounds: inline nesting, block/container nesting, link label length, and link parenthesis nesting.
The link parser uses raw reference-label scanning, bounded parenthesis nesting, bounded link labels, URI escaping for rendered href/src attributes, and a plain-text fast path for inputs with no possible inline constructs. This keeps adversarial inputs such as deeply nested brackets, long blockquote runs, repeated ![[](), and unclosed comments in predictable time.
Raw HTML is preserved structurally. Supported raw HTML container tags such as div, section, table, svg, math, and custom elements stay open across blank lines until their matching close tag, with same-tag nesting counted; void and self-closing tags do not open Markdown containers. Markdown inside raw HTML remains raw unless the open tag that starts the Markdown block uses markdown="1"; this crate does not recursively look for markdown controls inside otherwise-raw HTML. Options::default().tagfilter is false; enabling it applies GFM-style filtering for tags such as script, style, xmp, and textarea. This is compatibility protection, not sanitization.
After rendering and callbacks, mdhtml passes the complete provisional fragment through JustHTML once, using FragmentContext("body"), sanitize=False, and pretty=False serialization. WHATWG tree construction therefore supplies implied elements, repairs misnesting, normalizes names, and handles foreign SVG and MathML content. Raw HTML passes through as DOM structure rather than byte-for-byte source.
maturin develop && pytest -qThe spec-conformance suite is tests/test_conformance.py: it renders the fixtures under tests/source/ and compares normalized HTML trees. Run just that file with pytest tests/test_conformance.py -v to see per-example ids.