Parsers¶
A parser takes a Markdown string and returns a Document. Two adapters ship with the package:
| Registry name | Class | Module | Built on |
|---|---|---|---|
markdown-it |
MarkdownItParser |
marktripy.parsers.markdown_it |
markdown-it-py |
mistletoe |
MistletoeParser |
marktripy.parsers.mistletoe_parser |
mistletoe |
Use markdown-it unless you have a reason not to. It is the adapter the test suite exercises most, and it is the only one that takes configuration. The mistletoe adapter drops several constructs; see The mistletoe adapter.
Create a parser¶
Each adapter module registers itself with ParserRegistry when it is imported. Nothing is registered until then.
from marktripy.core.parser import ParserRegistry
from marktripy.parsers import markdown_it, mistletoe_parser # both register now
print(ParserRegistry.list_parsers()) # ['markdown-it', 'mistletoe']
parser = ParserRegistry.create("markdown-it", {"preset": "default"})
ParserRegistry.create(name, config=None) looks up the class and calls it with config. If the name is unknown, ParserRegistry.get raises KeyError and lists the names that are registered, for example Parser 'mistletoe' not registered. Available: ['markdown-it'] when you forgot the import.
Constructing the class directly works the same way:
from marktripy.parsers.markdown_it import MarkdownItParser
parser = MarkdownItParser({"preset": "default"})
What parse() does¶
parse(text) runs three steps:
preprocess(text)converts\r\nand\rline endings to\nand adds a final newline if missing.- The backend parses the text, and the adapter converts the backend's tokens into
ASTNodeobjects. postprocess(doc)returns the document. The base implementation does not change it; subclasses can override it.
Any exception raised inside parse() is re-raised as ParserError with the message Failed to parse Markdown: <original message>. The original exception is available as __cause__. ParserError also has line and column attributes, but the shipped adapters do not set them.
validate_markdown(text) calls parse() and returns a list with the error message if parsing fails, or an empty list if it succeeds.
The markdown-it adapter¶
MarkdownItParser accepts a configuration dictionary with these keys:
| Key | Default | Effect |
|---|---|---|
preset |
"commonmark" |
markdown-it-py preset name: "commonmark", "default" or "zero". |
options |
{} |
Options passed to MarkdownIt(preset, options), for example {"typographer": True}. |
plugins |
[] |
Plugin names to load from mdit_py_plugins: front_matter, footnote, deflist, tasklists, attrs. Unknown names are logged and skipped. |
disable |
[] |
Rule names passed to md.disable(). |
enable |
[] |
Rule names passed to md.enable(). |
The adapter always enables the html_inline and html_block rules unless options["html"] is False.
Choose a preset¶
The preset decides which syntax is recognised. The commonmark default does not recognise tables or ~~strikethrough~~. Use "default" for those:
ParserRegistry.create("markdown-it").get_capabilities()["tables"] # False
ParserRegistry.create("markdown-it", {"preset": "default"}).get_capabilities()["tables"] # True
Token mapping¶
The adapter maps markdown-it-py tokens to nodes as follows:
| Token | Node |
|---|---|
heading_open |
Heading(level=…) from the tag (h2 → 2) |
paragraph_open |
Paragraph |
blockquote_open |
BlockQuote |
hr |
HorizontalRule |
fence, code_block |
CodeBlock(content, language=info) |
bullet_list_open / ordered_list_open |
List(ordered=…, start=…, tight=…) |
list_item_open |
ListItem |
em_open, strong_open |
Emphasis, Strong |
s_open |
a plain ASTNode(type="strikethrough") |
link_open |
Link(href, title) |
image |
Image(src, alt, title) |
code_inline |
InlineCode |
text |
Text |
softbreak, hardbreak |
Text("\n") |
html_inline, html_block |
Text holding the raw HTML |
table_open, tr_open, th_open/td_open |
Table, TableRow, TableCell(header=…, align=…) |
Tokens not in this list are skipped. Two consequences:
- Raw HTML becomes ordinary text.
HTMLRendererescapes it, so<b>x</b>in the source becomes<b>x</b>in the output. - A hard line break (two trailing spaces) becomes the same
Text("\n")as a soft break, so it renders as a plain newline.
Plugins¶
Plugins add syntax to the tokenizer, but the adapter only converts the token types listed above. For example, with {"plugins": ["front_matter"]} the front matter block is recognised and then skipped, so it does not appear in the tree:
p = ParserRegistry.create("markdown-it", {"plugins": ["front_matter"]})
[n.type for n in p.parse("---\ntitle: x\n---\n\n# H\n").walk()]
# ['document', 'heading', 'text']
The mistletoe adapter¶
MistletoeParser takes no configuration keys. It calls mistletoe.Document(text) and converts headings, paragraphs, lists, code blocks, quotes, tables, emphasis, strong, inline code and links.
With mistletoe 1.6 the adapter has these gaps:
- Fenced code blocks (
```) and~~strikethrough~~are dropped from the tree with a logged warning. Indented code blocks are converted. - Any image makes
parse()fail withParserError: Failed to parse Markdown: 'Image' object has no attribute 'target'. - Table column alignment is copied as
mistletoe's numeric code instead of"left","center"or"right". A right-aligned column, for example, producesalign="1"in HTML.
Prefer the markdown-it adapter for real documents until these are fixed.
Capabilities¶
get_capabilities() returns a dictionary of feature flags: tables, strikethrough, task_lists, footnotes, definition_lists, math, smart_quotes and custom_extensions.
For MarkdownItParser the values are computed from the active markdown-it-py rules, so they change with the preset and plugins. For MistletoeParser they are fixed values and do not reflect the gaps listed above.
Write your own adapter¶
Subclass Parser, implement parse() and get_capabilities(), and register the class:
from marktripy.core.ast import Document, Paragraph, Text
from marktripy.core.parser import Parser, ParserRegistry
class PlainTextParser(Parser):
"""Treat every blank-line-separated block as a paragraph."""
def parse(self, text: str) -> Document:
doc = Document()
for block in self.preprocess(text).split("\n\n"):
if block.strip():
para = Paragraph()
para.add_child(Text(block.strip()))
doc.add_child(para)
return self.postprocess(doc)
def get_capabilities(self) -> dict[str, bool]:
return {"tables": False, "custom_extensions": False}
ParserRegistry.register("plain", PlainTextParser)
ParserRegistry.register raises TypeError if the class is not a Parser subclass. Registering an existing name replaces the earlier class.