Parsing Markdown by alternating on odd and even
I worked through boot.dev’s Build a Static Site Generator, a guided project that has you write the thing that turns **bold** into <b>bold</b>. The architecture is the course’s: a TextNode type for chunks of formatted text, an HTMLNode tree to render, and separate passes for block-level and inline parsing.
This is a write-up of the piece I found most elegant, not something I invented. The inline parser is a genuinely neat idea, and it took me a while to see why it works, so it seemed worth explaining properly.
The technique
Take a line and split it on the delimiter:
"a **bold** word".split("**")
# ['a ', 'bold', ' word']
Look at the indices. Section 0 is plain, section 1 was between the delimiters, section 2 is plain again. That holds for any number of pairs: even indices are outside the delimiters, odd indices are inside them. Once you see that, the parser is a loop:
def split_nodes_delimiter(old_nodes, delimiter, text_type):
new_nodes = []
for old_node in old_nodes:
if old_node.text_type != TextType.TEXT:
new_nodes.append(old_node)
continue
sections = old_node.text.split(delimiter)
if len(sections) % 2 == 0:
raise ValueError("invalid markdown, formatted section not closed")
for i, section in enumerate(sections):
if section == "":
continue
node_type = TextType.TEXT if i % 2 == 0 else text_type
new_nodes.append(TextNode(section, node_type))
return new_nodes
No regex, no character-by-character state machine, no lookahead. Split, then alternate.
The validation falls out for free
The line I like most is the length check. If the delimiters are balanced, splitting always produces an odd number of sections: n pairs give 2n+1 pieces. An even count means a delimiter was opened and never closed:
if len(sections) % 2 == 0:
raise ValueError("invalid markdown, formatted section not closed")
You get syntax validation from the same operation that does the parsing, with no separate scanning pass. That is the kind of thing that makes a small algorithm feel right: one property of the data doing two jobs.
The first if matters too. Nodes that already carry a type get passed through untouched, so each pass only ever subdivides plain text. That is what makes it safe to chain the passes:
nodes = split_nodes_delimiter(nodes, "**", TextType.BOLD)
nodes = split_nodes_delimiter(nodes, "*", TextType.ITALIC)
nodes = split_nodes_delimiter(nodes, "`", TextType.CODE)
Bold runs before italic deliberately. ** contains *, so parsing italics first would tear every bold marker in half.
Where it breaks, and why
This design cannot handle nested formatting like **_bold italic_**. That is not an oversight; it is a direct consequence of the trick. Each pass only subdivides plain text nodes, so once the bold pass has turned that span into a BOLD node, the italic pass skips over it by design. The very rule that makes chained passes safe is the rule that prevents nesting.
Supporting it properly means giving up the flat model: either recurse into the newly created nodes with the remaining delimiters, or let a text node carry a set of styles rather than a single type. Both are reasonable, and both are a different program from this one. Sequential single-purpose passes over a flat list is a real design choice with a real ceiling, and working out where that ceiling sits, and why, was the most useful part of the whole exercise.
For the record, links and images do need regex, because their structure is genuinely nested rather than delimited:
def extract_markdown_images(text):
return re.findall(r"!\[([^\[\]]*)\]\(([^\(\)]*)\)", text)
def extract_markdown_links(text):
return re.findall(r"(?<!!)\[([^\[\]]*)\]\(([^\(\)]*)\)", text)
The negative lookbehind on the link pattern is the only reason images do not also match as links.
Worth doing
Writing a Markdown parser is a good exercise precisely because you use Markdown constantly and never think about it. Doing it yourself puts a floor under your understanding of every tool built on top: I now know why some generators refuse **_this_**, why unclosed delimiters produce such odd output, and why bold has to be handled before italic. Building the small version of a thing you rely on is rarely wasted time, even when, especially when, someone else drew the map.
Code: B3ardBr0/Static_Site_Generator, my implementation of the boot.dev guided project. The design is the course’s; the write-up and the appreciation are mine.
