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 noindex until 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.txt that 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.

  • #python
  • #static-site
  • #jinja2
  • #hreflang
  • #seo
  • #multilingual

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑