LiveToka started as one English page — a hand-written index.html that is the app — plus a few vanilla JS files. No framework, no i18n library. I wanted real localized pages for Japanese, Korean, Vietnamese, Portuguese and Chinese speakers: menus, buttons, error messages, dialogs, FAQ, everything.
Rewriting the site around an i18n framework wasn't worth it. Instead I built a small pipeline that keeps English as the single source of truth and generates /ja/, /ko/, /vi/, /pt/, /zh/ and /zh-tw/ at build time. Here's how it works and what bit me.
public/index.html (English master) public/*.js with t('…')
│ extract strings │ extract t('…') keys
└──────────────► dictionary/<lang>.json ◄───┘
▲ missing keys only
│ LLM, batched, validated
build: translate HTML + inline the JS dictionary → public/<lang>/index.html
Two kinds of text need different handling: what's in the HTML, and what JavaScript creates at runtime.
I walk the HTML with a few careful regexes rather than a DOM, because the output must be the original file with only the text swapped — same whitespace, same attributes, same inline scripts.
Rules that matter:
<head>, <script>, <style>, comments, and the language switcher. placeholder, title, alt, aria-label, data-tooltip and data-name (my language picker reads names from <a href="https://dev.to/pricing">Pricing</a> page." split into three fragments is untranslatable in Japanese or Korean, where word order differs. Block elements (p, li, h2…) whose text is interrupted by a, strong, br etc. become one key that A sanity test I'd recommend to anyone doing this: translate the page with an identity dictionary (every key maps to itself) and diff against the original. Mine came back identical except for raw & characters being normalised to & — harmless, and it proved the walker didn't mangle anything.
Status messages, toasts, dialogs and cookie banners are built in JS. I wrapped them in t():
const t = (text, vars) => (window.LT_t ? window.LT_t(text, vars) : fill(text, vars));
statusEl.textContent = t('Ready to begin translation');
notify(t('Copied {n} lines.', { n: lines.length }));
The key is the English text itself, so English pages need no dictionary and the code stays readable. Placeholders are {name} so translators can move them; template literals with ${} had to be rewritten. Strings that live in data and are translated where they're displayed get a no-op marker, t.key('Starter'), so the extractor still finds them.
At build time each localized page gets an inline <script>window.LT_DICT = {…}</script> with just the JS keys, loaded before the first script that shows text.
Missing keys go to the model in batches of ~40 as JSON:
{"items": [{"id": 0, "text": "Start Translation"}, {"id": 1, "text": "Copied {n} lines."}]}
and come back as {"translations": [{"id": 0, "text": "…"}]}. Ids matter: models occasionally merge or drop items, and without ids you can't tell which.
The prompt carries:
{placeholders}, keep HTML tags and attributes exactly, keep leading emoji, never translate the brand or product names.
Every translation is checked before it enters the dictionary:
function problems(src, out) {
const p = [];
if (!out?.trim()) p.push('empty');
if (placeholders(src) !== placeholders(out)) p.push('placeholders');
if (tags(src).join('') !== tags(out).join('')) p.push('html tags');
if (leadingEmoji(src) && !out.startsWith(leadingEmoji(src))) p.push('emoji');
if (src.includes('LiveToka') && !out.includes('LiveToka')) p.push('brand');
return p;
}
Failures are retried individually once; if they still fail, the script stops. The build then refuses to generate a localized page if any key is missing — no half-English pages in production, and editing the English master later automatically produces a short "translate these 2 new strings" run.
The first Korean pass was fluent and passed every check — and used three different words for "transcription" across the UI (받아쓰기, 전사, 음성 텍스트 변환). The mode button said one thing, the start button another. Vietnamese had the same problem.
Two fixes:
--retranslate <regex> flag that drops matching keys ([Tt]ranscri) so they're redone with the glossary.
Lesson: after the first run, grep your dictionary for your product's core nouns and count the variants.
My footer links to each language version ("日本語", "Tiếng Việt", "Português"). Names written in Latin script looked like ordinary text to the extractor, so on the Japanese page "Tiếng Việt" became "ベトナム語". Links carrying an hreflang attribute are now excluded from translation. Language switchers should always show each language in its own name.
Localized pages are only half the job if the flows bounce people back to English. Three places did that:
/. Login links now pass the current path ( returnTo), validated server-side to be a same-site path.
For the last two, a tiny script in <head> remembers the interface language in localStorage and, on an English page that has a translation, moves to it — using the page's own hreflang alternates to find the right URL (which also keeps zh-CN and zh-TW apart). Choosing "English" in the switcher stores en and stops the redirect. Crawlers don't keep localStorage, so indexing is unaffected.
canonical, og:url, and a generated hreflang block (all languages + x-default) — on the English pages too.
The whole pipeline is a few hundred lines with no dependencies, and adding a language is now: add a locale entry, run the translate script, run the build. If your site is mostly hand-written HTML and you don't want a framework migration, build-time localization with strict validation is a very pragmatic middle ground — just don't skip the validation and the terminology check.