A Rust Markdown parser and XHTML renderer.
The parser is tree-oriented. It preserves the structure and attributes needed for XHTML 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.
xhtmlmd 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 xhtmlmd 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. - 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.
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}. - Paragraphs: a trailing list at the end of the last line.
- Fenced code: in the info string,
python {.numberLines}after the opening fence. - Fenced divs: in the
:::opener. - Link reference definitions:
[r]: /url "title" {.external}applies the attributes to every link resolved through that reference. - Any block, via a standalone IAL line
{: ...}: it modifies the preceding block, including directly after the last row of a table; with no preceding block it applies to the next one. - 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 xhtmlmd CLI:
pip install xhtmlmdThe CLI reads Markdown from stdin or from an optional file path and writes an XHTML fragment to stdout:
echo '# Hello' | xhtmlmd
xhtmlmd input.md > out.xhtml
xhtmlmd --math=on input.md > out.xhtml
xhtmlmd --math=dollars input.md > out.xhtmlPython API:
from xhtmlmd import to_xhtml
html = to_xhtml(r"\(x^2\)")
html_for_katex = to_xhtml(r"\(x^2\)", math="on")
html_with_dollars = to_xhtml("$x$", math="dollars")Python callers can override rendered nodes with callbacks. Each callback receives a node dict and the default XHTML for that node. Return None to keep the default, or return replacement XHTML.
Callback names:
- Blocks:
paragraph,heading,block_quote,list,definition_list,code_block,html_block,html_container,thematic_break,table,div,math_block - Inlines:
text,soft_break,hard_break,emph,strong,strike,superscript,subscript,highlight,code,link,image,autolink,abbr,html_inline,math_inline,footnote_ref,span
from fastpylight import highlight
from xhtmlmd import to_xhtml
def highlight_code(node, default_html):
if node["lang"] != "python": return None
return highlight(node["text"], node["lang"]) + "\n"
html = to_xhtml(markdown, callbacks={"code_block": highlight_code})Callbacks can also render bracket math as MathML:
from math_core import LatexToMathML
from xhtmlmd import to_xhtml
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_xhtml(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.
from xhtmlmd 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"]]))Command-line usage (the xhtmlmd script is installed with the package):
xhtmlmd input.md > out.xhtml
cat input.md | xhtmlmd --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 by default. 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 balanced 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 and extra protection, not a replacement for sanitizing untrusted rendered HTML.
Raw HTML passthrough means unbalanced source HTML produces an unbalanced fragment, exactly as CommonMark specifies. The opt-in balance option (Options::default().balance is false; --balance on the CLI) restores well-formedness after rendering: unclosed elements are closed at the end of the fragment, stray closing tags are dropped, a closing tag that skips over open elements closes them first, void elements are rewritten to self-closing form, and rawtext elements such as script are copied verbatim to their real close. It deliberately does not apply HTML5 implied-end-tag rules (no <p> auto-close) or rewrite attributes.
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.