Building a Blog Platform with Docker #3: Markdown Rendering
Quick one-liner: Stop writing HTML for your posts. Parse .md files with YAML frontmatter and render them in Flask.
Why This Matters
Last time you built a blog that looks like a blog. Teal nav, dark background, editorial post list. Nice.
But the post in that list? It's hardcoded HTML. Every time you want a new post, you have to edit a template. That's not a blog platform, that's a website.
A real blog platform lets you write a file, drop it in a folder, and have it appear. That's what Markdown gives you.
Why Markdown?
- You write in plain text: no
<p>tags, no&, no forgetting to close a<div> - It's readable as-is, even before rendering
- Every major writing tool supports it
- Easy to version control in git
By the end of this post, you'll be able to drop a .md file into a folder and have Flask render it as a proper HTML page.
Starting Point
You should have this from last time:
tiohub-blog/
├── app.py
├── static/
│ └── js/
│ └── tailwind.config.js
└── templates/
└── index.html
If you don't have this, go back to Episode 2 first.
Step 1: Install the Packages
You need two libraries:
-
Markdown: converts Markdown syntax to HTML -
PyYAML: parses the YAML frontmatter at the top of each post
Make sure your venv is active first:
$ source venv/bin/activate
Install both:
$ pip install Markdown PyYAML
Save them to requirements.txt:
$ pip freeze > requirements.txt
Why pip freeze? It saves every installed package with its exact version. Anyone cloning your repo can run pip install -r requirements.txt and get the exact same environment. We'll need this when we add Docker later.
Step 2: Create Your First Post
Create the content folder:
$ mkdir -p content/posts
Create content/posts/hey-markdown.md:
---
title: "Hey Markdown"
date: 2026-04-25
tags: [meta, blog]
---
This is my first post written in Markdown.
No more HTML by hand. No more forgetting to close a `<div>`. Just write, save, done.
Here's what Markdown looks like in practice:
- **Bold** with double asterisks
- *Italic* with single asterisks
- `Code` with backticks
And a code block:
\```
bash
$ source venv/bin/activate
# apt update
\
```
python
print("First Markdown post, and it actually works")
\
That's it. Clean to write, clean to read.
The section between the --- markers at the top is called frontmatter. It's YAML, a simple key-value format. The title, date, and tags fields are metadata about the post. The Markdown content starts after the second ---.
Step 3: Write the Parse Function
Open app.py. Add the imports at the top:
import yaml
import markdown
Now add a parse_post() function below the imports:
def parse_post(filepath):
with open(filepath, 'r') as f:
content = f.read()
if content.startswith('---'):
parts = content.split('---', 2)
meta = yaml.safe_load(parts[1])
body = parts[2].strip()
else:
meta = {}
body = content
meta['content'] = markdown.markdown(
body,
extensions=['fenced_code', 'tables']
)
return meta
What this does:
- Reads the
.mdfile - Checks if it starts with
---(frontmatter present) - Splits on
---to separate the YAML from the Markdown - Parses the YAML into a Python dict (
meta) - Converts the Markdown body to HTML and stores it as
meta['content'] - Returns the whole thing: metadata and rendered HTML together
Why fenced_code and tables? The base markdown library is minimal. The fenced_code extension enables code blocks with triple backticks. The tables extension adds table support. Both are included with the library, no extra install needed.
Step 4: Add a Route
Add a new route to app.py:
@app.route('/2026/04/hey-markdown')
def hey_markdown_post():
post = parse_post('content/posts/hey-markdown.md')
return render_template('post.html', post=post)
Why this URL format? This matches Blogger's URL pattern: /<year>/<month>/<slug>. My current blog is on Blogger, and I'm planning to migrate it to this platform. If the URLs match from the start, existing posts won't need 301 redirects when I move them over.
In Episode 4, this becomes a dynamic route, /<int:year>/<int:month>/<slug>, that looks up any post by its date and slug. For now, we're hardcoding it to prove the parsing works.
Your full app.py should now look like this:
import yaml
import markdown
from flask import Flask, render_template
app = Flask(__name__)
def parse_post(filepath):
with open(filepath, 'r') as f:
content = f.read()
if content.startswith('---'):
parts = content.split('---', 2)
meta = yaml.safe_load(parts[1])
body = parts[2].strip()
else:
meta = {}
body = content
meta['content'] = markdown.markdown(
body,
extensions=['fenced_code', 'tables']
)
return meta
@app.route('/')
def index():
return render_template('index.html')
@app.route('/2026/04/hey-markdown')
def hey_markdown_post():
post = parse_post('content/posts/hey-markdown.md')
return render_template('post.html', post=post)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000, debug=True)
Step 5: Create the Post Template
Create templates/post.html:
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ post.title }}</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script src="{{ url_for('static', filename='js/tailwind.config.js') }}"></script>
</head>
<body class="bg-slate-950 text-gray-100 font-sans min-h-screen flex flex-col">
<!-- Navbar -->
<nav class="bg-brand-700 border-b border-brand-600">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="{{ url_for('index') }}" class="font-serif text-xl text-white">
David Tio's Blog
</a>
<div class="flex items-center space-x-6">
<a href="/" class="text-teal-100 hover:text-white font-medium text-sm transition duration-200">Home</a>
<a href="#" class="text-teal-100 hover:text-white font-medium text-sm transition duration-200">Series</a>
<a href="#" class="text-teal-100 hover:text-white font-medium text-sm transition duration-200">About</a>
</div>
</div>
</nav>
<!-- Post -->
<main class="max-w-3xl mx-auto px-6 py-16 flex-1 w-full">
<p class="text-gray-500 text-sm mb-4">{{ post.date }}</p>
<h1 class="font-serif text-4xl text-white leading-tight mb-8">{{ post.title }}</h1>
<div class="border-t border-slate-800 mb-10"></div>
<div class="prose prose-invert max-w-none
prose-headings:font-serif prose-headings:text-white
prose-p:text-gray-300 prose-p:leading-relaxed
prose-a:text-brand-500 prose-a:no-underline hover:prose-a:underline
prose-code:text-brand-500 prose-code:bg-slate-800 prose-code:px-1 prose-code:rounded
prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0
prose-strong:text-white
prose-li:text-gray-300">
{{ post.content | safe }}
</div>
</main>
<!-- Footer -->
<footer class="bg-brand-700 border-t border-brand-600">
<div class="max-w-6xl mx-auto px-6 py-6 flex items-center justify-between">
<p class="text-teal-100 text-sm">© 2026 David Tio.</p>
<div class="flex space-x-6">
<a href="#" class="text-teal-100 hover:text-white text-sm transition-colors duration-200">LinkedIn</a>
<a href="#" class="text-teal-100 hover:text-white text-sm transition-colors duration-200">Twitter</a>
<a href="#" class="text-teal-100 hover:text-white text-sm transition-colors duration-200">GitHub</a>
</div>
</div>
</footer>
</body>
</html>
A few things to note:
{{ post.content | safe }}: The | safe filter tells Flask's template engine not to escape the HTML. Without it, you'd see raw <p> and <h2> tags on the page instead of rendered HTML. This is safe here because you control the content, it's your own Markdown files, not user input.
prose classes: These are Tailwind Typography utility classes that style HTML content you don't control directly. Without them, the rendered Markdown (<h1>, <p>, <ul>, etc.) would inherit no styles at all from Tailwind, because Tailwind resets all browser defaults by design. The prose classes restore readable typography for body content.
prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0: These override the default <pre> styling that prose applies. In the next step we'll add a JavaScript file that wraps each code block in a styled container with a language label and a Copy button. Those wrapper elements provide the background, padding, and border, so we strip the defaults here to avoid double-styling. If you're not planning to add the code-block wrapper yet, you can swap these for prose-pre:bg-slate-800 prose-pre:border prose-pre:border-slate-700 for now.
Wait, do I need to install anything for prose? No package install is needed for this tutorial because the Tailwind Play CDN can enable first-party plugins through the script URL. The ?plugins=typography part is what makes the prose classes available.
Step 6: Format the Date
Right now, {{ post.date }} outputs exactly what's in your YAML: 2026-04-25.
I generally use YYYY-MM-DD in my coding files. It's great for sorting and it keeps everything in chronological order. But it doesn't read like a blog post date. Let's format it into something easier for readers.
Open app.py. Add the datetime import at the top:
from datetime import datetime
Now add a format_date() function below parse_post():
def format_date(date_value):
"""Convert YYYY-MM-DD to '01 January 2026' format."""
if isinstance(date_value, str):
date_value = datetime.strptime(date_value, '%Y-%m-%d').date()
return date_value.strftime('%d %B %Y')
What this does:
- Accepts either a string (
"2026-04-25") or adateobject (PyYAML sometimes parses dates automatically) - Converts string input to a proper date object
- Returns it formatted as
25 April 2026
Now update app.py to apply the formatting when returning the post:
@app.route('/2026/04/hey-markdown')
def hey_markdown_post():
post = parse_post('content/posts/hey-markdown.md')
post['date'] = format_date(post.get('date', ''))
return render_template('post.html', post=post)
The date in your template will now read 25 April 2026 instead of 2026-04-25.
Step 7: Better Code Blocks
At this point the page works. Markdown renders, the layout matches the rest of the site. But the code blocks are plain, just a dark box with no label and no way to copy the content.
Since we're building a platform for technical content, this matters. Readers want to copy commands and snippets without selecting text manually.
We'll fix this with a small JavaScript file. Create it in static/js/.
Create static/js/code-blocks.js:
document.querySelectorAll('pre').forEach(function(pre) {
var code = pre.querySelector('code');
var lang = '';
if (code && code.className) {
var match = code.className.match(/language-([\w-]+)/);
if (match) lang = match[1];
}
function copyText() {
var text = code.innerText;
var shellLanguages = ['bash', 'sh', 'shell', 'zsh', 'console'];
if (shellLanguages.indexOf(lang) !== -1) {
return text
.split('\n')
.map(function(line) {
return line.replace(/^\s*[$#]\s+/, '');
})
.join('\n')
.trimEnd();
}
return text;
}
var wrapper = document.createElement('div');
wrapper.className = 'rounded-lg border border-slate-700 overflow-hidden my-6';
var header = document.createElement('div');
header.className = 'flex items-center justify-between bg-slate-800 px-4 py-2 border-b border-slate-700';
var label = document.createElement('span');
label.className = 'text-xs text-gray-400 font-mono';
label.innerText = lang || 'code';
var btn = document.createElement('button');
btn.innerText = 'Copy';
btn.className = 'text-xs text-gray-400 hover:text-white bg-slate-700 hover:bg-slate-600 px-2 py-1 rounded transition-colors duration-200';
btn.addEventListener('click', function() {
navigator.clipboard.writeText(copyText()).then(function() {
btn.innerText = 'Copied!';
setTimeout(function() { btn.innerText = 'Copy'; }, 2000);
});
});
header.appendChild(label);
header.appendChild(btn);
pre.className = 'bg-slate-900 p-4 overflow-x-auto m-0 text-sm';
pre.parentNode.insertBefore(wrapper, pre);
wrapper.appendChild(header);
wrapper.appendChild(pre);
});
What this does:
- Finds every
<pre>block on the page after it loads - Reads the language from the
class="language-python"attribute added by thefenced_codeextension - Wraps the block in a styled container with a header bar showing the language label and a Copy button
- Uses
navigator.clipboard.writeText()to copy the code text - Strips leading
$and#prompts from shell snippets before copying, so readers can paste commands directly - Changes the button to "Copied!" for 2 seconds as feedback
Now update post.html with two changes. First, override the prose-pre defaults so they don't conflict with the custom wrapper the script builds:
prose-pre:p-0 prose-pre:bg-transparent prose-pre:border-0
Second, add the script tag just before </body>:
<script src="{{ url_for('static', filename='js/code-blocks.js') }}"></script>
</body>
The script tag goes at the bottom, after the page content has loaded, so that querySelectorAll('pre') finds the rendered blocks.
Your prose-pre classes from Step 5 already have this covered, so you don't need to change anything in the <div class="prose ..."> block.
Step 8: Test It
Run the app:
$ python app.py
Visit http://localhost:8000/2026/04/hey-markdown.
It should look like this:
Try the Copy button on the bash block. It should leave out the $ and # prompts, so the copied text is ready to paste into your terminal.
The homepage at http://localhost:8000 still shows the hardcoded post list from Episode 2. That's fine for now, we'll wire everything together in Episode 4.
What You've Built
Your file structure is now:
tiohub-blog/
├── app.py
├── requirements.txt
├── static/
│ └── js/
│ ├── tailwind.config.js
│ └── code-blocks.js
├── templates/
│ ├── index.html
│ └── post.html
└── content/
└── posts/
└── hey-markdown.md
You've added:
✅ Markdown and PyYAML installed and saved to requirements.txt
✅ parse_post(): reads a .md file, splits frontmatter from content, returns both
✅ format_date(): converts 2026-04-25 to 25 April 2026 for display
✅ /<year>/<month>/<slug> URL format matching Blogger's pattern
✅ post.html template with matching nav, footer, and Tailwind Typography styles
✅ Code blocks styled as boxes with a language label and one-click copy button
Coming Up
Next time: multiple posts. We'll scan the content/posts/ folder for all .md files, sort them by date, and list them on the homepage. We'll also make the route dynamic, /<int:year>/<int:month>/<slug>, so any post can be reached by its date and filename.
The hardcoded post in index.html goes away. The hardcoded route in app.py goes away too.
Found this helpful? Share it with your network or drop a comment below.
SEO Metadata
- Title: Building a Blog Platform with Docker #3: Markdown Rendering (2026)
- Meta Description: Stop writing HTML for every blog post. Parse Markdown files with YAML frontmatter in Flask using the Markdown and PyYAML libraries.
- Target Keywords: flask markdown rendering, pyyaml frontmatter flask, python markdown blog, flask blog markdown tutorial 2026
- Word Count: ~1,100

Top comments (0)