Devlog #11 — Adding Ukrainian in 4 Days

No CMS. No translation API. Just Python scripts, a lot of careful hreflang, and a language switcher built from scratch. Here's how we shipped the full Ukrainian version of the site in a single sprint.

293
Ukrainian pages created
4
days from start to deploy
0
translation API calls

Why Ukrainian?

This project serves science students worldwide. After English, the largest underserved audience — in terms of STEM education resources in their native language — was Ukrainian. The decision was also personal: the project maintainers are Ukrainian.

The question wasn't whether to do it — it was how to manage 293 HTML files across a build-tool-free static site without making the English versions a maintenance nightmare.

The Architecture Decision

Three approaches were considered:

  1. Server-side language detection — redirect based on Accept-Language. Rejected: the site is pure static HTML served from GitHub Pages. No server logic.
  2. Single-page JavaScript i18n (i18next, etc.) — one HTML file, JS swaps strings. Rejected: terrible for SEO, requires a JS bundle on every page, and every simulation already loads Three.js — adding an i18n library to 225 sim pages wasn't acceptable.
  3. Parallel static file tree at /uk/ — exact mirror of the English structure with translated HTML. Each page has its own URL, its own meta, its own hreflang pointing back to the English canonical. This is what we built.

The tradeoff: parallel trees double your file count. 293 UK pages on top of 326 EN pages = 619 total. But URLs are stable, SEO is clean, and there are zero JS dependencies for translation. Worth it.

Day 1 — Scaffolding the File Structure

📅 Day 1

The first task was writing a Python script that duplicated every simulation folder under /uk/ and adjusted all the relative paths. The tricky part: paths like ../../shared/theme.css (2 levels up from /categories/) become ../../../../shared/theme.css (4 levels up from /uk/categories/). Getting this wrong silently breaks every stylesheet.

import os, re, shutil

def fix_paths(html: str, extra_depth: int) -> str:
    prefix = '../' * extra_depth
    # Fix relative src/href that start with ../../ but not //
    return re.sub(
        r'((?:src|href)=["\'])(?!https?://|//|#)(\.\./)',
        lambda m: m.group(1) + prefix + m.group(2),
        html
    )

Day 2 — Translation Workflow

📅 Day 2

Each simulation page has three key text blocks to translate: the page <title>, the meta description, and the simulation description panel (the "What does this show / How to use / Did you know?" section). Everything else — UI controls, axis labels, code snippets — stays in English.

The translation was done manually with assistance from language tools for proofreading, prioritising natural Ukrainian over literal translation. Scientific terminology follows the Ukrainian academic standard where established equivalents exist, and uses the English term in parentheses otherwise.

The hreflang Requirement

Every EN page needed to gain a <link rel="alternate" hreflang="uk"> pointing to its Ukrainian counterpart. Every UK page needed the reciprocal EN link. And both needed an hreflang="x-default" pointing at the EN canonical. Without the reciprocal pair, Google ignores hreflang entirely.

<!-- In /boids/index.html (English) -->
<link rel="alternate" hreflang="en" href="https://www.mysimulator.uk/boids/">
<link rel="alternate" hreflang="uk" href="https://www.mysimulator.uk/uk/boids/">
<link rel="alternate" hreflang="x-default" href="https://www.mysimulator.uk/boids/">

<!-- In /uk/boids/index.html (Ukrainian) -->
<link rel="alternate" hreflang="en" href="https://www.mysimulator.uk/boids/">
<link rel="alternate" hreflang="uk" href="https://www.mysimulator.uk/uk/boids/">
<link rel="alternate" hreflang="x-default" href="https://www.mysimulator.uk/boids/">

A validation script checked every page against a regex: all three hreflang tags must exist and must be consistent with the file's path. Zero pages published until the audit showed 0 errors.

Day 3 — Language Switcher Component

📅 Day 3

The language switcher is a single <a> tag injected by components.js into the navbar. It reads the current URL and constructs the alternate-language URL by:

  1. If the path starts with /uk/ → remove it (go to EN version)
  2. Otherwise → prepend /uk/ (go to UK version)
function buildLangUrl(base) {
  const path = window.location.pathname;
  if (path.startsWith('/uk/')) {
    return base + path.slice(3);   // strip /uk prefix
  }
  return base + 'uk' + path;       // add /uk prefix
}

This is fragile in exactly one case: pages that exist in EN but not yet in UK. The switcher shows anyway, linking to a 404. A preprocessing step generates a JSON manifest of all existing UK paths and the switcher checks it before showing the button.

Day 4 — Sitemap, Robots, Deploy

📅 Day 4

The sitemap had to include both EN and UK URLs with their <xhtml:link> hreflang alternates in each entry. The Python script that generates sitemap.xml was updated to walk both the EN and UK trees together and emit the correct alternates.

The final deploy pushed 293 new files and modified 326 existing EN files (to add hreflang). Total diff: ~1 500 files changed across the repository. GitHub Pages served them without any configuration change — static HTML just works.

What Surprised Us