The Index, Tags, and an RSS Feed
You have pages, but no front door. A visitor landing on your site right now gets a directory listing, which says "file server," not "blog." This phase generates the three kinds of derived pages - pages built not from one post but from knowledge of all posts: the index, per-tag pages, and an RSS feed. This is also the phase where you'll feel exactly where a four-line template engine stops being enough, which is a lesson worth having in your bones.
The index - and the wall our engine hits
The front page is a heading plus a list of links, newest first. Create templates/index.html:
{{ heading }}
{{ items }}
Notice what this template doesn't do: it doesn't loop over posts. It can't. Our render() replaces placeholders; it has no concept of "repeat this bit for each item." A real engine would write {% for post in posts %} right in the template - that loop syntax is precisely what Jinja2 sells.
Our honest workaround: build the repeated part in Python, where loops live, and hand the finished block to the template as one value:
=
return
💡 Key point: this is the line where you now know when to reach for Jinja2 - the moment your templates need repetition or conditionals, not before. Until then, an engine you fully understand beats one you don't. For a blog this size, the workaround costs eight lines.
The template takes a heading instead of hardcoding "All posts" for a reason that pays off immediately: tag pages are the same shape - a heading and a list of posts - so one template serves both.
Tag pages
A tag page is "the index, filtered." The build collects a mapping of tag → posts, then renders the index template once per tag into site/tags/<tag>.html:
=
setdefault(tag, []) means "give me the list for this tag, creating an empty one first if it's new" - the standard idiom for building a dict of lists without checking if tag in tags every time.
⚠️ Gotcha: the tag becomes a filename. tags: web dev would produce site/tags/web dev.html - a URL with a space in it, which works but looks broken and gets percent-encoded into web%20dev. Keep tags to single lowercase words (python, web, meta). Slugifying multi-word tags properly is on the extensions list in phase 6.
The RSS feed
What RSS actually is. A machine-readable table of contents for your site: one XML file listing your recent posts with titles, links, and dates. Feed readers and other sites poll it to learn you've published. It's the oldest open "subscribe" mechanism on the web, it never died, and a blog without one is invisible to the people most likely to actually follow you.
An RSS document looks like this - a <channel> describing the site, containing <item>s describing posts:
My Blog
https://example.com
Posts from my hand-built blog.
Why I Went Static
https://example.com/2026-07-02-why-static-sites.html
https://example.com/2026-07-02-why-static-sites.html
Thu, 02 Jul 2026 00:00:00 -0000
Two details in there are where hand-rolled feeds usually go wrong, and the standard library has the answer to both:
pubDatemust be RFC 822 format -Thu, 02 Jul 2026 00:00:00 -0000, the ancient email date style. Don't hand-format it;email.utils.format_datetime()exists precisely because this format is fussy (English day names, specific ordering, a zone offset).- Titles must be XML-escaped. Browsers forgive broken HTML; XML parsers do not. One post titled
Cats & Dogswith a raw&makes the entire feed invalid, and every subscriber's reader silently stops updating.xml.sax.saxutils.escape()converts&,<, and>into their safe entities.
📝 Terminology: <guid> is the item's permanent unique identifier - readers use it to remember which items you've already seen. Using the post's URL as its guid is the standard move.
The complete build.py
This is the full file as it stands at the end of this phase - everything above post_list_items is unchanged from phase 3:
=
=
=
=
=
= # change this when you deploy (phase 6)
=
, , =
=
, , =
=
return
=
=
return
return
return f
= /
=
return
=
= f
return
=
=
=
=
# one page per post
=
=
# the front page
=
=
# one page per tag
=
=
=
# the feed
A few deliberate choices to notice:
posts[:10]in the feed - RSS convention is recent posts, not your life's work. Readers that want the archive have the site.- The feed reuses the already-sorted
postslist, so items come out newest-first, which is what readers expect. - Tag pages sort alphabetically (
sorted(tags.items())) so rebuilds are deterministic - the same input always produces byte-identical output, which makes diffs ofsite/meaningful.
Run it
(.venv) $ python build.py
built site/2026-07-02-why-static-sites.html
built site/2026-06-28-hello-world.html
built site/index.html
built site/tags/meta.html
built site/tags/web.html
built site/feed.xml
Serve it again - python -m http.server 8000 --directory site - and open http://localhost:8000. No more directory listing: your front page lists both posts, newest on top, and each link works. Try http://localhost:8000/tags/web.html - only the post tagged web appears. And http://localhost:8000/feed.xml shows the feed (some browsers render XML as a collapsible tree; that's the browser being helpful, the bytes are yours).
What just happened: the build now produces every kind of page a small blog needs - six files from two posts, all derived from the same parsed data, all regenerated from scratch in a fraction of a second.
What you have now
A complete blog: posts, a front page, tag archives, and a subscribable feed. The remaining friction is workflow - that build-serve-refresh dance every time you edit. Phase 5 collapses it into "save the file, refresh the browser."
Three checks on the load-bearing ideas:
[
{
"q": "Why is the index's list of <li> lines built in Python instead of in the template?",
"choices": ["HTML lists cannot be templated", "Our four-line engine has no loops - repetition is exactly the feature real engines like Jinja2 add on top of substitution", "Python string building is faster than templating"],
"answer": 1,
"explain": "render() only substitutes placeholders. The moment templates need loops or conditionals is the moment a real template engine earns its dependency."
},
{
"q": "Why must post titles go through escape() in the RSS feed?",
"choices": ["To convert them to lowercase", "XML parsers are strict - one raw & or < in a title invalidates the whole feed and subscribers silently stop getting updates", "RSS requires base64-encoded titles"],
"answer": 1,
"explain": "Browsers forgive malformed HTML; feed readers do not forgive malformed XML. escape() turns &, <, and > into safe entities."
},
{
"q": "What format does RSS's pubDate use, and what produces it here?",
"choices": ["ISO 8601 (2026-07-02), via datetime.isoformat()", "A Unix timestamp, via time.time()", "RFC 822 style (Thu, 02 Jul 2026 00:00:00 -0000), via email.utils.format_datetime()"],
"answer": 2,
"explain": "RSS inherited the old email date format. format_datetime() exists in the standard library precisely so you never hand-build it."
}
]
Check your understanding 3 questions
1. Why is the index's list of <li> lines built in Python instead of in the template?
2. Why must post titles go through escape() in the RSS feed?
3. What format does RSS's pubDate use, and what produces it here?