A 411-Line Python Site Generator for a 3-Language Blog
Why we wrote our own static site generator instead of using Hugo, and how it handles hreflang, noindex legacy posts, ad slots, JSON-LD and safe incremental FTP deploys.
This blog runs on a static site generator we wrote ourselves: one Python file of 411 lines, Jinja2 templates and Markdown files. It builds 167 pages in three languages in about 0.8 seconds. This post explains why we did not use an existing generator, the parts that took the most thought (hreflang, legacy content and ads), and the checks that run before anything reaches the server.
Why not Hugo
Rendering Markdown to HTML is the easy part, and any generator does it well. Our hard requirements were all about this particular site:
- App-store URLs. 24 store listings point at an old privacy URL and several support URLs. Those pages are generated from app data and served at the old addresses.
- Privacy pages from data. Each app has a list of data flags (
ads,push,camera…). Its support and privacy pages, and one combined policy table, are rendered from that list in three languages. - Legacy posts. 67 tutorials from 2021 are published but marked
noindexuntil revised. They must stay out of sitemaps and hreflang, but remain readable. - A shared server. The same document root also serves app APIs and an ad-config endpoint. The deploy step must refuse to touch them.
None of this is impossible in Hugo. But each item was a few lines of Python instead of a theme override or a plugin search, and we can read the whole generator in one sitting.
Content model
content/en/*.md content/ja/*.md content/ko/*.md content/ko/_legacy/*.md
---
title: "Dart http Does Not Follow 301 on POST: Measured"
description: "…"
slug: dart-http-post-redirect-not-followed # same slug in every language
date: 2026-11-09
category: flutter # a key, not a display name
translation_key: dart-http-post-redirect # links the three language versions
faq: [{q: "…", a: "…"}]
---
Two choices pay off later. Categories are keys (flutter), and each language's display name comes from site.yaml, so "the same category" is something the machine can check. And translation_key is the only thing that links language versions. File names and titles can differ freely.
The parser fails loudly: a missing required field, an unknown category, a non-ASCII slug or a duplicate slug stops the build with the file name.
hreflang only between indexable siblings
hreflang tells search engines "this page has equivalents in other languages". If you point it at a page that is noindex, or emit it for a page with no translation, you send contradictory signals. The rule in the generator:
siblings = by_key.get(p.get("translation_key"), {})
hreflangs = []
if not p["noindex"] and len(siblings) > 1:
idx = {l: s for l, s in siblings.items() if not s["noindex"]}
if len(idx) > 1:
hreflangs = [{"hreflang": l, "url": s["url"]} for l, s in idx.items()]
xd = idx.get(site["default_lang"]) or next(iter(idx.values()))
hreflangs.append({"hreflang": "x-default", "url": xd["url"]})
A legacy Korean post has no hreflang until its revision ships with Japanese and English versions. Then all three link to each other automatically. Readers get a visible "Also available in" link from the same data, because hreflang alone is invisible to people.
Sections for ad placement
AdSense placements in an article are usually "above the content", "after the table of contents", "before a heading" and "at the end". The generator splits the rendered HTML at each <h2>:
def split_sections(body):
return [p for p in re.split(r"(?=<h2[ >])", body) if p.strip()]
def ad_positions(n):
return {i for i in (2, 5) if i < n} # at most two in-article slots
The template interleaves sections and ad slots. Slots render only when a slot ID is configured and the page is indexable, so legacy and preview pages carry no ad code at all. Until a site is approved, every slot ID is empty and no in-article ad markup is emitted.
Structured data from the same frontmatter
Each article gets a single JSON-LD @graph with a TechArticle, a BreadcrumbList and, if the frontmatter has faq, an FAQPage whose answers are the same text shown on the page. The JSON is escaped against </script>, and every block is parsed back with json.loads by the checker, so one bad quote cannot break a page's structured data silently.
The checker is the real product
check_content.py (186 lines) runs on the built output, and the deploy script refuses to run if it reports anything:
| Check | What it caught for us |
|---|---|
Protected paths: no output file may collide with server-owned paths (tlabo-ad/, api/, app/*/index.php, ads.txt…) |
Verified by planting three such files on purpose: three errors |
| Every store-registered URL's target page exists | Guards the 24 listings |
Internal links and #anchors resolve |
Nothing yet; it runs on every build |
Every sitemap URL exists and is not noindex |
A preview build that listed 38 noindex pages in its sitemap |
<title> length, description, canonical, lang |
English titles over 70 characters, in several batches |
The preview build (--drafts) marks every page noindex and writes no sitemap, so a preview uploaded by mistake cannot be indexed.
Deploying to shared hosting safely
The site is uploaded over FTPS with a 235-line script:
- Incremental. A manifest stores the MD5 of every uploaded file. The last About-page edit uploaded 11 of 294 files; a new article batch typically uploads 30 to 40.
- Allow-list. Only paths the generator owns (
en/,ja/,ko/,static/, the fixed pages,app/<id>/support|privacy/) can be uploaded. Anything else aborts the whole deploy. - Foreign files. If a file exists on the server but is not in our manifest, the script stops. It continues only with an explicit flag, and it downloads the original first. This saved a
robots.txtthat carried a search engine's verification line. - No deletes by default. Pruning removes only files we uploaded earlier and that the build no longer produces.
Numbers
| Item | Value |
|---|---|
| Generator | 411 lines (build.py) + 186 (check_content.py) + 235 (deploy.py) |
| Templates, CSS, JS | about 900 lines |
| Build | 0.8 s for 167 HTML pages, 3 languages, 4 sitemaps, 3 feeds |
| Output | 4.8 MB, of which 2.2 MB is static assets (mostly legacy images) |
| CSS / JS per page | 13.4 KB / 3.6 KB; the 72 KB syntax highlighter loads only on pages with code |
| Time to first byte (live) | about 0.13–0.14 s |
When a custom generator makes sense
| Situation | Choose |
|---|---|
| A standard blog or docs site | Hugo, Eleventy, Astro: themes and plugins are worth more than control |
| Content must coexist with other systems on the same server | A small custom generator plus a strict deploy allow-list |
| Pages generated from business data (apps, policies, redirects) | Custom: the data model is the point |
| You will not maintain code | An existing generator, always |
The generator is not the interesting part; it is short because it does little. What made the site safe to run is the checker in front of the deploy, and the rule that the deploy may only touch what the generator owns.
FAQ
Why not use Hugo or another existing static site generator?
The content was the easy part. Our requirements were site-specific: serving app-store-registered URLs, generating app privacy pages from data, keeping 67 legacy posts noindex until revised, and refusing to deploy over files that other systems own. A small generator made each of those a few lines instead of a plugin hunt.
How do you generate hreflang correctly for partially translated content?
Group posts by a translation_key in the frontmatter, and emit hreflang only among siblings that are all indexable. A page that is noindex, or has no translation, gets no hreflang at all. x-default points at the English version when it exists.
How fast is it?
About 0.8 seconds to build 167 HTML pages in three languages, plus sitemaps and feeds, on a laptop. Deploys upload only files whose MD5 changed; a typical content change uploads around 10 to 40 of 294 files.