DEV Community

SnapViewer
SnapViewer

Posted on Fully Autonomous

Shipping a 1,500-page, 17-language static site with plain Python and Cloudflare Pages

We recently launched a small web tool in 17 languages. The site has about 1,500 pages. There is no framework, no CMS and no build service, only one Python script that writes HTML files and Cloudflare Pages to serve them.

This post covers the parts that turned out to matter. They are mostly boring details that break silently when you get them wrong.

1. One JSON file per page, one folder per language

The content is laid out like this:

content/
  en/
    _ui.json          # nav labels, buttons, footer text
    home.json
    about.json
    story-viewer.json
  fr/
    _ui.json
    home.json
    ...
Enter fullscreen mode Exit fullscreen mode

Each page file has a slug, a title, a description, a keyword and a list of typed blocks (p, h2, list, faq, figure). The builder never guesses a language. A language exists only if its folder has a _ui.json:

def langs():
    only = os.environ.get("PD_LANGS")
    have = [p.name for p in CONTENT.iterdir() if p.is_dir() and (p / "_ui.json").exists()]
    if only:
        return [l for l in only.split(",") if l in have]
    return sorted(have, key=lambda l: (l != "en", l))
Enter fullscreen mode Exit fullscreen mode

The PD_LANGS override exists because of a mistake we made. Translations were running in the background, and we deployed while half of a language was written. For about two minutes /vi/ had pages but no homepage. Now the rule is simple: while translation is in progress, build with an explicit list of the finished languages.

PD_LANGS=en,ar,fr,es,de python build.py
Enter fullscreen mode Exit fullscreen mode

English lives at the root and every other language gets a prefix:

def page_url(lang, slug):
    prefix = "" if lang == "en" else f"/{lang}"
    return f"{prefix}/{slug + '/' if slug else ''}" or "/"
Enter fullscreen mode Exit fullscreen mode

2. hreflang: reciprocal, self-referencing, with x-default

Search engines ignore hreflang tags that don't point back at each other. Every page therefore lists all of its translations, itself included, plus an x-default that points to English:

link_alt = "".join(
    f'<link rel="alternate" hreflang="{l}" href="{SITE}{u}">'
    for l, u in sorted(alts.items()))
link_alt += f'<link rel="alternate" hreflang="x-default" href="{SITE}{alts.get("en", url)}">'
Enter fullscreen mode Exit fullscreen mode

alts is built once per page id across all languages. When a page is missing in one language, it is missing from every page's list, so the sets stay consistent.

3. Sitemap index, split into chunks

A single sitemap can hold 50,000 URLs, but smaller files are easier to debug in Search Console because you can see which chunk has problems. We split at 1,000 URLs and write a sitemap index:

parts = [sm[i:i + SITEMAP_CHUNK] for i in range(0, len(sm), SITEMAP_CHUNK)] or [[]]
for n, part in enumerate(parts, 1):
    (DIST / f"sitemap_{n}.xml").write_text(...)
(DIST / "sitemap.xml").write_text(
    '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
    "".join(f"<sitemap><loc>{SITE}/sitemap_{n}.xml</loc></sitemap>"
            for n in range(1, len(parts) + 1)) + "</sitemapindex>")
Enter fullscreen mode Exit fullscreen mode

Each <url> entry also repeats the hreflang alternates as xhtml:link. It's redundant with the HTML tags, but it costs nothing.

4. Measuring text width in SVG without a browser

Every page has a few generated SVG figures, such as a phone mockup or a comparison table. SVG has no text wrapping, and the same label might be in English, Japanese, Hindi or Arabic. We needed a rough width estimate in Python:

def text_w(s, size, weight=600):
    """rough rendered width: CJK/fullwidth = 1em, Indic/Thai 0.75em, others ~0.55-0.72em"""
    w = 0.0
    for ch in str(s):
        o = ord(ch)
        if 0x2E80 <= o <= 0xA4CF or 0xAC00 <= o <= 0xD7A3 or 0xFF00 <= o <= 0xFF60:
            w += 1.0
        elif 0x0900 <= o <= 0x0E7F:
            w += 0.75
        elif ch in " .,:;!|il'1":
            w += 0.3
        elif ch.isupper() or ch in "mwMW@%":
            w += 0.72
        else:
            w += 0.6 if weight >= 700 else 0.55
    return w * size
Enter fullscreen mode Exit fullscreen mode

It isn't exact. It doesn't need to be: the job is to decide when to shrink the font or wrap to a second line. Before we had it, Japanese labels overflowed their boxes and Thai labels were cut in half.

5. An audit script that blocks the deploy

This was the most useful piece. audit.py reads the built folder, or the live site, and exits with code 1 on any failure. For every URL in the sitemap it checks:

  • a self-referencing https canonical, and no stray noindex
  • exactly one <h1>, with no skipped heading levels
  • a title and description that exist, aren't duplicated, and fit their length limits
  • the page's keyword appears in the title, the H1 and the first paragraph
  • JSON-LD that parses, has no dangling @id, and includes the types each page kind needs (Organization, WebSite, BreadcrumbList, FAQPage, and so on)
  • reciprocal hreflang with x-default
  • every image has alt, width and height
  • every internal link resolves to a built file
  • robots.txt and llms.txt are well-formed

The first full run on 1,581 pages reported about a hundred failures. They were mostly translated descriptions that were too long and H2s that had lost the keyword in translation. You would not catch those by clicking around, especially in languages you can't read.

6. Cloudflare Pages + Functions

The static output goes to Cloudflare Pages with one command:

npx wrangler pages deploy dist --project-name mysite --branch main
Enter fullscreen mode Exit fullscreen mode

The few dynamic endpoints live in functions/api/*.js as Pages Functions, on the same domain with no CORS. robots.txt disallows /api/ so crawlers don't waste their budget on it.

7. Telling search engines

After every deploy, one small script reads the sitemaps and sends all URLs to IndexNow (Bing and Yandex), in batches of up to 10,000 URLs per request:

urls = [u for f in sorted(glob.glob(f"{d}/sitemap_[0-9]*.xml"))
          for u in re.findall(r"<loc>(.*?)</loc>", Path(f).read_text(encoding="utf-8"))]
body = json.dumps({"host": HOST, "key": KEY,
                   "keyLocation": f"https://{HOST}/{KEY}.txt", "urlList": urls})
Enter fullscreen mode Exit fullscreen mode

Google doesn't use IndexNow, so for Google we submit the sitemap index in Search Console and wait.

What we would do again

  • Content as JSON, layout in code. Translators and scripts touch only the JSON. The HTML can't drift between languages.
  • An audit gate before every deploy. It costs a few seconds and catches the errors you can't see.
  • An explicit language list while translation is running. Never deploy "whatever is in the folder".

The site is SnapViewer, a free viewer for public Snapchat stories and profiles. The build approach doesn't depend on what the site does. A Python script, a folder of JSON and a static host take you a long way.

This article was written with AI assistance and checked against the real build code.

Top comments (0)