Renderers
Two renderers ship in the box: MarkdownRenderer and HTMLRenderer. Both are subclassable Python classes — override individual node handlers to customise output.
MarkdownRenderer
Round-trips an AST back into Markdown. Useful for:
- Rendering AST stored in a database back into a textarea / code editor.
- Re-emitting normalised Markdown after running transforms.
- Debugging —
print(doc.to_markdown())is more readable than the JSON.
from markast import MarkdownRenderer, parse
doc = parse("# Hi\n\nA paragraph.")
print(MarkdownRenderer().render(doc.root))
For convenience, Document.to_markdown() does the same thing.
Roundtrip stability
parse(text).to_markdown() reproduces the source for every Markdown construct markast supports, with these normalisations:
- Excess blank lines collapse to a single blank line.
- Setext headings (
====underline) become ATX (# title). - Loose-list marker positions normalise to flush-left.
- List indentation normalises to 2 spaces per level.
The roundtrip is stable: parse(parse(text).to_markdown()).to_markdown() equals parse(text).to_markdown() for every supported construct.
HTMLRenderer
Conservative, opinion-free HTML. No CSS classes are emitted by default beyond the structural ones widgets explicitly add.
from markast import HTMLRenderer, parse
doc = parse("# Hi\n\n:::tip\nA tip\n:::")
print(HTMLRenderer().render(doc.root))
Set wrap_root=True to wrap the output in an <article class="markast">:
HTMLRenderer(wrap_root=True).render(doc.root)
# <article class="markast">...</article>
Special characters
Plain text nodes are escaped: <, >, & become <, >, &. Code blocks are escaped the same way. HTML attributes are escaped including ".
html_block nodes (raw HTML appearing in source Markdown) are emitted verbatim — the parser already validated nothing about their content. Treat this as opt-in: if your source allows raw HTML, you trust the authors.
Subclassing
Both renderers dispatch by method name: _block_<type> for block nodes, _inline_<type> for inline nodes. Override only what you care about:
from markast import MarkdownRenderer
class FlushDividers(MarkdownRenderer):
"""Use a stronger divider syntax."""
def _block_divider(self, node):
return "* * *"
print(FlushDividers().render(doc.root))
HTMLRenderer follows the same convention. Override e.g. _block_heading to emit anchored headings:
class AnchoredHTMLRenderer(HTMLRenderer):
def _block_heading(self, node):
lvl = max(1, min(6, node.get("level", 1)))
slug = node.get("id", "")
anchor = f'<a href="#{slug}">#</a>' if slug else ""
body = self._inline(node.get("children", []))
return f'<h{lvl} id="{slug}">{body}{anchor}</h{lvl}>'
Pair this with the slugify transform so every heading carries an id.
Custom renderers
If you want a third format (terminal? a different document model?), the easiest path is to extend the same dispatch pattern:
class AnsiRenderer:
def render(self, doc):
return "".join(self._block(c) for c in doc.get("children", []))
def _block(self, node):
method = getattr(self, f"_block_{node.get('type')}", None)
return method(node) if method else ""
def _block_heading(self, node):
text = "".join(c.get("value", "") for c in node["children"])
return f"\033[1m{'#' * node['level']} {text}\033[0m\n\n"
def _block_paragraph(self, node):
text = "".join(c.get("value", "") for c in node["children"])
return text + "\n\n"
Aim for the same _block_<type> / _inline_<type> convention so your users can extend your renderer the same way they extend the built-in ones.