Skip to main content

xhtmlmd

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.

Implemented syntax

  • 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: brackets for \(...\), \[...\], and $$...$$, dollars for those plus $...$ using Pandoc's non-space/digit dollar rules, on to preserve \(...\) and \[...\] delimiters for client-side renderers such as KaTeX, and off. 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 Term followed by : definition or ~ definition.
  • Footnotes: [^id] references to defined [^id]: definitions with indented continuation blocks.
  • Abbreviations: *[HTML]: Hyper Text Markup Language definitions render matching text as <abbr>.
  • Fenced divs: Pandoc/Quarto/Djot-style ::: containers with attributes or a single class word.

Attributes

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.

Usage

Install via pip to get both the Python API and the native xhtmlmd CLI:

pip install xhtmlmd

The 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.xhtml

Python 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")

Markdown rewriting

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 xhtmlmd 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 xhtmlmd 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, currently image or math_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 always inline.
  • alt: plain alt text.
  • url: the decoded image destination.
  • title: decoded title text, or None.

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: True for $$ and \[, otherwise False.

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.

Callbacks

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})

Block spans

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=dollars

Parsing strategy

The 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.

Tests

maturin develop && pytest -q

The 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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

xhtmlmd-0.1.11.tar.gz (150.0 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

xhtmlmd-0.1.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (480.0 kB view details)

Uploaded CPython 3.13manylinux: glibc 2.17+ x86-64

xhtmlmd-0.1.11-cp313-cp313-macosx_11_0_arm64.whl (433.2 kB view details)

Uploaded CPython 3.13macOS 11.0+ ARM64

xhtmlmd-0.1.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (480.1 kB view details)

Uploaded CPython 3.12manylinux: glibc 2.17+ x86-64

xhtmlmd-0.1.11-cp312-cp312-macosx_11_0_arm64.whl (433.3 kB view details)

Uploaded CPython 3.12macOS 11.0+ ARM64

xhtmlmd-0.1.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (481.1 kB view details)

Uploaded CPython 3.11manylinux: glibc 2.17+ x86-64

xhtmlmd-0.1.11-cp311-cp311-macosx_11_0_arm64.whl (435.3 kB view details)

Uploaded CPython 3.11macOS 11.0+ ARM64

xhtmlmd-0.1.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (481.7 kB view details)

Uploaded CPython 3.10manylinux: glibc 2.17+ x86-64

xhtmlmd-0.1.11-cp310-cp310-macosx_11_0_arm64.whl (435.7 kB view details)

Uploaded CPython 3.10macOS 11.0+ ARM64

File details

Details for the file xhtmlmd-0.1.11.tar.gz.

File metadata

  • Download URL: xhtmlmd-0.1.11.tar.gz
  • Upload date:
  • Size: 150.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for xhtmlmd-0.1.11.tar.gz
Algorithm Hash digest
SHA256 e16c2a65833cb1b1df71fbf64da9086eb9ee6f7e6938b75878c47c83636555a1
MD5 8288ee4fc58f9183e3cf949efaa82519
BLAKE2b-256 322546aeeb443e857a87da8710e974d94dfb7557f0edbddd969fbe2d720eb3ca

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11.tar.gz:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 61e8e47156748d9a0e3c6b266aff06e19e8d4e79712167229bedf69c52187fe5
MD5 f61f25e14def2c5b10898ef48c34e403
BLAKE2b-256 499ae8b37cb8fd5a1be5db65baa31e470d2a5dc6498991e65eac8101f4eb9a76

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp313-cp313-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp313-cp313-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 22e10cd610343d18f0a5eabc21e5e7ca6eaf2265a110021572031fa43a433d3d
MD5 dcaa9977036bf5bbd19ff91f584e9471
BLAKE2b-256 8011b323029b1ebd682f67146f352cbd536a302c639d90bcb637e6cbc59514ee

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp313-cp313-macosx_11_0_arm64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 d4ae3f018b1da47a6fc982c0ea32afd4ddffd9d893aba8b2f282fb09d06549c4
MD5 6d820def31f9b3777878ad729fbcbe0f
BLAKE2b-256 84909df392669278ddd6fce2ccf366993f8c23006923f7452c1ef83152444c60

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp312-cp312-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp312-cp312-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 4333b6bf41a1fc75e8e7d51e7d203d6c3bbf7bd26260fea32cf66ab8767f555d
MD5 a9d5652be0fec273ff4463308eec3708
BLAKE2b-256 e64b47dac46d12c41d13eab3faa4eb0f8d42573c05da2374dc3b528104d09300

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp312-cp312-macosx_11_0_arm64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 1bc194a2a66cb0e845ae0952758504177a59e306388f2f7fcf2a190b83b0f570
MD5 1e46ba4150df95995fcbb49cefbddc26
BLAKE2b-256 f374944edf8bed86be9589b6761d8369913900680032d2d1280682c820fd19e7

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp311-cp311-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp311-cp311-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 5123a9c7db893d89576e766aad3c3a3cb8e9cbd349593c179bdc76573410909c
MD5 9d62f71e3285ff6d9a954e440f1e4fe8
BLAKE2b-256 6213d47022b752b73586fbdbdbfe20c69dd84877c809f6c5ddde79e4ce4f4c03

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp311-cp311-macosx_11_0_arm64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 de27697851360e1925f8cb63cc016bf457c4ab75655f3dad6cf50d654163c488
MD5 fd4ce2b1bc9725adec5d2fdc8d140a63
BLAKE2b-256 2345a6cc0ca7c841ac077d9f1d7d3e4e9e81023cefeec3c067adbbf2393d2e21

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file xhtmlmd-0.1.11-cp310-cp310-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for xhtmlmd-0.1.11-cp310-cp310-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 aa06ed19447737b3881a111e1b09edd9b190c8b8f874348a0e690b2e736898f2
MD5 5882a9b44af4dd9b2f943687dcaa3f8e
BLAKE2b-256 acedf09a2f4bd3a2535161a3a632ce66a6523a0707b2a5e7957912bece7d789d

See more details on using hashes here.

Provenance

The following attestation bundles were made for xhtmlmd-0.1.11-cp310-cp310-macosx_11_0_arm64.whl:

Publisher: ci.yml on AnswerDotAI/xhtmlmd

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page