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:
-
Server-side language detection — redirect based on
Accept-Language. Rejected: the site is pure static HTML served from GitHub Pages. No server logic. - 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.
-
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
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
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
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:
-
If the path starts with
/uk/→ remove it (go to EN version) - 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
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
-
The hreflang audit took longer than the translation.
Small inconsistencies — a trailing slash in one link but not
another, a
/uk/vs/ukdiscrepancy — silently break Google's interpretation. The validator script caught 47 such errors before deployment. - Ukrainian STEM terminology is richer than we expected. There are established Ukrainian terms for most physics and mathematics concepts. The difficulty was consistency — using the same term on every page, not the one that happened to come to mind first.
- Organic Ukrainian traffic appeared within 2 weeks of launch. No promotion — just clean hreflang, proper canonicals, and content that wasn't available in Ukrainian elsewhere.