Parsing Posts: Frontmatter and Markdown
Your generator can find posts. Now it learns to read one - and "read" means three separate jobs stacked in one file: peel the frontmatter off the top, turn its key: value lines into data, and convert the Markdown body to HTML. By the end of this phase, build.py turns every post file into a clean Python dictionary with everything later phases need.
What a post file actually is
Look at one of your posts as a machine would:
--- <- marker: frontmatter starts
title: Hello, World <- metadata, key: value per line
date: 2026-06-28
tags: meta
--- <- marker: frontmatter ends
<- everything below: the body, in Markdown
This is the first post...
So parsing a post is: split the text at the first two --- markers, treat the middle as metadata, treat the rest as Markdown. Python's str.split can do the splitting in one line - if you know its second argument.
Splitting on the markers
text.split("---", 2) splits on --- at most twice, producing three pieces: whatever is before the first marker (an empty string, since the file starts with it), the frontmatter, and the whole rest of the file.
That 2 is doing quiet, important work. Markdown uses --- for horizontal rules, so a post body might legitimately contain another ---. Without the limit, that rule would be treated as a third split point and shred your body. With maxsplit=2, the split stops after the frontmatter fence, and any later --- stays safely inside the body.
⚠️ Gotcha: this parser assumes the file starts with --- on the first line. A post that's missing its frontmatter - or has a stray blank line above it - shouldn't limp through and publish a broken page. It should stop the build with a message naming the file. A build tool's job is to fail loudly at build time so nothing fails silently in front of readers.
Parsing the key: value lines
Frontmatter lines look like title: Hello, World. The tempting parse is line.split(":") - and it's wrong, because a title like Docker: A Love Story contains a colon and would be cut in half. The right tool is str.partition(":"), which splits on the first colon only and always gives back three pieces: key, the colon, value.
For tags, we'll use a comma-separated convention (tags: meta, web) and split it into a real Python list.
Converting the Markdown
The markdown library does the body. Watch it work before wiring it in - start a Python session:
(.venv) $ python
>>> import markdown
>>> markdown.markdown("This is **bold** and this is `code`.")
'<p>This is <strong>bold</strong> and this is <code>code</code>.</p>'
>>> exit()
What just happened: one function call, Markdown in, HTML out. That string of HTML is exactly what we'll pour into a template in phase 3.
⚠️ Gotcha: out of the box, the markdown library follows the original 2004 Markdown spec - which means fenced code blocks (```) are not supported by default. Your second post has one. The fix is one argument: markdown.markdown(body, extensions=["fenced_code"]). Forget it, and your code blocks render as garbled paragraphs with backticks in them - a classic first-hour bug with this library.
The new build.py
Replace the contents of build.py with this:
=
=
, , =
=
, , =
=
return
=
=
Walk through parse_post, because it's the heart of the input side:
read_text(encoding="utf-8")reads the whole file as text. Always name the encoding - on Windows, the default can be something else entirely, and an em-dash in a post becomes mojibake.- The
startswithcheck plusraise ValueErroris the loud failure from above. The error names the file, so future-you knows which post to fix. - The metadata loop:
partition(":")splits each line at the first colon;.strip()on both sides forgives stray spaces. meta.get("title", path.stem)- a missing title falls back to the filename. Butmeta["date"]uses square brackets on purpose: a post without a date can't be sorted, so a missing date should crash with aKeyErrornaming the problem, not get silently guessed.datetime.strptime(meta["date"], "%Y-%m-%d")parses2026-06-28into a realdatetimeobject. Real dates sort correctly and can be reformatted for display later; strings that merely look like dates eventually betray you.path.stemis the filename without.md-2026-06-28-hello-world- and becomes the slug, the post's future URL name.- The tags line splits on commas and drops empties, so
tags: meta, web,tags: meta,web, and no tags line at all are all handled.
📝 Terminology: a slug is the URL-safe identifier for a piece of content - the hello-world part of /hello-world.html. Ours comes straight from the filename, so filenames should stay lowercase-with-hyphens.
main() now parses every post and sorts by date, newest first - which is exactly the order the index page will want in phase 4.
Run it
(.venv) $ python build.py
2026-07-02 Why I Went Static [meta, web]
2026-06-28 Hello, World [meta]
What just happened: both files were parsed into dictionaries - real dates, real tag lists, HTML bodies ready and waiting - and printed newest-first. The HTML is in there too; it's simply not printed. If you're curious, add a print(post["html"][:80]) line for one run and look at it.
Now prove the loud failure works, because untested error handling is decoration. Create posts/broken.md containing only the word oops, and run the build:
(.venv) $ python build.py
Traceback (most recent call last):
...
ValueError: broken.md: no frontmatter block at the top
The build refused, and the message names the file. Delete posts/broken.md and re-run to see the clean output again.
What you have now
A generator that reads every post into structured data: title, date, tags, slug, and a converted HTML body - with malformed posts stopped at the door. What it doesn't have is anywhere to put that HTML. That's phase 3: templates, and the four-line engine that fills them.
Two of these questions are about the exact bugs this phase defused - worth thirty seconds:
[
{
"q": "Why does the parser use text.split(\"---\", 2) instead of text.split(\"---\")?",
"choices": ["It limits splitting to the first two markers, so a --- horizontal rule later in the body is left alone", "It splits the text into exactly two pieces", "It is faster for large files"],
"answer": 0,
"explain": "maxsplit=2 yields three pieces: before the frontmatter, the frontmatter, and the entire rest of the file - later --- lines stay in the body."
},
{
"q": "Your posts use fenced code blocks. What does the markdown library need to render them?",
"choices": ["Nothing - fences work out of the box", "The \"fenced_code\" extension passed to markdown.markdown()", "A separate pip package"],
"answer": 1,
"explain": "By default the library follows the original Markdown spec, which predates fences. extensions=[\"fenced_code\"] turns them on."
},
{
"q": "A post is missing its date: line. What does this build do, and why is that the right call?",
"choices": ["Uses today's date so the build succeeds", "Skips the post silently", "Crashes with a KeyError - a build tool should fail loudly at build time, not publish something wrong"],
"answer": 2,
"explain": "meta[\"date\"] uses square brackets deliberately. A guessed date or a silently missing post is a bug you find weeks later; a crash naming the file is one you fix in ten seconds."
}
]
Check your understanding 3 questions
1. Why does the parser use text.split("---", 2) instead of text.split("---")?
2. Your posts use fenced code blocks. What does the markdown library need to render them?
3. A post is missing its date: line. What does this build do, and why is that the right call?