Create a simple multilingual blog in Python with native Markdown support and code highlighting using FastHTML
Posted on 2025-02-21 by Simon Moisselin
I wanted a personal blog to write about my coding adventures. Simple enough, right? But I also wanted to write some posts in French for friends back home. And in markdown, of course. And with great code highlighting. And also one day I would want to add code interactivity.
That's when the "simple blog" turned into a rabbit hole of framework decisions and tech stack debates. And that's when FastHTML solved all my problems.
There is a hidden transaction cost of switching between multiple programming frameworks while coding.
Every blogging framework promises simplicity. Hugo's "just markdown." Astro's "just components." MDX lets you "just embed React." But each "just" adds another layer of syntax, another concept to juggle, another context switch for your brain.
Want to add a simple language switcher? Now you're learning Hugo's templating language, or figuring out how Astro's islands work, or wrestling with React's state management inside your MDX files. Each solution introduces its own special syntax, its own patterns, its own way of thinking. That's a lot of mental overhead.
You should not need a new blogging framework just to show some text in another language, or adding a common section.
As a machine learning engineer, I have one core preference: THIS CANNOT BE IN JAVASCRIPT, IF POSSIBLE
. My code lives in Python - that's where my brain works.
When FastHTML promised web development purely in Python, it felt like finding an oasis in a desert of JavaScript frameworks
(yes - surprisingly nice LLM poetry I decided to keep right here). Finally, a way to build web apps without leaving my native programming language.
I stumbled upon this concept of "locality of behavior" that started to change the way on how I think about web development. Instead of spreading related code across different files and languages, what if everything that makes a feature work lived together?
Let's look at the heart of my blog - the post renderer:
@app.get("/posts/{slug}")
def post(session, slug: str):
post = get_post(slug, session.get('language', DEFAULT_LANG), posts_by_lang)
return (
nav(),
render_post(post)
)
def render_post(post: Post):
return Container(
Div(
Div(
render_dropdown_language_post(post), # Language switcher lives right here
H1(post.metadata["title"], cls="text-3xl font-bold mb-4"),
P(post.metadata["description"], cls="text-gray-600 mb-6"),
cls="bg-gray-50 p-6 rounded-t-lg"
),
Div(render_md(post.content), cls="p-6"),
cls="border rounded-lg shadow-sm mx-auto"
),
id="post-container", # This ID is crucial for HTMX targeting
)
This single function render_post
almost tells you the whole story. You can see:
All the pieces that make this feature work are right here in plain Python. No need to jump between files to understand how things connect. But that's just the static part, just some magic mapping between tags and python functions. Let's look at the dynamic part when implementing the language switcher.
I am mostly using fasthtml common components and the monsterui components:
from fasthtml.common import *
from monsterui.all import *
I didn't want to refresh the entire page if user decided to switch language. I just want the content of the post to be updated. For that, I used HTMX to update the content of the post.
def render_dropdown_language_post(post: Post):
return Form(Select((Option(lang_str(lang), value=lang, selected=lang == post.metadata["language"]) for lang in ["en", "fr"]), cls="flex justify-end", hx_post="/setlanguage",
name="lang"),
Hidden(value=post.metadata["slug"], name="slug"),
hx_target="#post-container",
hx_trigger="change")
The hx_trigger="change"
attribute is used to trigger the request when the user changes the language in the dropdown.
If you scroll, you can see inside render_post
the following id being used:
Container(...,id="post-container",) # This ID is crucial for HTMX targeting
This is the element that will be updated with the new content, by using the hx_target="#post-container"
attribute in the language switcher form.
But what is going to be trigger?
You can see that the language switcher is a form that will send a POST request to the /setlanguage
endpoint with the selected language and the slug of the post. The POST request will trigger the set_language
function, from the attributes hx_post="/setlanguage"
.
Let's look at the set_language
function:
def set_language(session, lang: str, slug: str):
session['language'] = lang
return render_post(get_post(slug, lang, posts_by_lang))
FastHTML lets you store data in sessions
, which are just Python dicts saved in the browser. Cookies would have worked fine too and might even be simpler, but sessions were easy to set up. With sessions I can just write session['language'] = lang
to save and session.get('language')
to load the language choice.
When I write a new English post, I want the French version to update automatically - but only when the source content changes. Here's how the system works:
Using OpenAI's API with Pydantic validation ensures I get exactly the fields I need:
class PostTranslated(BaseModel):
title: str
description: str
content: str
def translate_post(post: Post, dst_lang: str) -> Post:
client = instructor.from_openai(openai.OpenAI())
post_hash = get_hash(post)
# ... (translation logic) ...
return Post(
metadata={
**post.metadata,
"title": post_translated.title,
"hash_src": post_hash # <-- Critical for change tracking
},
content=post_translated.content
)
The PostTranslated
model acts as a contract with GPT-4 - no more guessing about response formats.
My .pre-commit-config.yaml
ensures translations stay in sync:
- repo: local
hooks:
- id: translate-blogs
entry: python3 translate_blogs.py
always_run: true # <-- Runs on every commit
This hook compares content hashes between source and translated posts. A post only translates if:
I should not translate every post at every commit. This would be a waste of money and time. So I need a way to detect meaningful changes. My hashing function considers both content and metadata:
def get_hash(post: Post) -> str:
return hashlib.sha256(
post.content.encode() +
yaml.dump({k: v for k, v in post.metadata.items() if k not in ["date", "tags"]}).encode()
).hexdigest()
I have a translate_blogs.py
script that will translate the posts and save them in the articles/fr
folder, ran at every commit.
if __name__ == "__main__":
posts_by_lang = get_posts_by_language()
lang_src = "en"
lang_dst = "fr"
new_posts = []
for post in tqdm(posts_by_lang[lang_src]):
post_dst = get_post(post.metadata["slug"], lang_dst, posts_by_lang)
if post_dst and post_dst.metadata.get("hash_src") == get_hash(post):
continue
translated_post = translate_post(post, lang_dst)
new_posts.append(translated_post)
for post in new_posts:
save_post(post, pathlib.Path(f"articles/{lang_dst}/{post.metadata['slug']}.md"))
With the simplest .pre-commit-config.yaml possible:
repos:
- repo: local
hooks:
- id: translate-blogs
name: Translate Blogs
entry: python3 translate_blogs.py
language: system
types: [python]
pass_filenames: false
always_run: true
verbose: true
The HTMX
form integration took more time than expected - I initially tried avoiding <form>
elements altogether before realizing they were necessary to send both the language
preference and post_slug
simultaneously. Sometimes web standards exist for good reason.
What's empowering is having complete ownership over the content pipeline. The translate_blogs.py
system I built could easily extend to other transformations - I'm considering adding one-click "simplified explainer" versions of technical posts or auto-generating section-specific questions for readers (and me!).
I know existing frameworks could achieve similar results, but there's a deep satisfaction in crafting a solution from scratch that is done in just a few hundred lines of Python
.