cd /news/ai-tools/localizing-a-static-site-into-6-lang… · home › topics › ai-tools › article
[ARTICLE · art-165649] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

Localizing a static site into 6 languages at build time with an LLM — and validating every string

A developer built a build-time localization pipeline for the LiveToka static site that uses an LLM to translate English source strings into Japanese, Korean, Vietnamese, Portuguese, and Chinese, generating per-language pages without adopting an i18n framework. The pipeline extracts HTML text and JavaScript t() keys into per-language dictionaries, sends only missing keys to the model in batches of about 40 with IDs, and validates every returned string for placeholders, HTML tags, leading emoji, and brand names before accepting it. The build refuses to emit a localized page if any key is missing, and an identity-dictionary diff test confirmed the HTML walker left the original file intact apart from ampersand normalization.

by read4 min views1 publishedNov 6, 2026

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.

── more in #ai-tools 4 stories · sorted by recency
── more on @livetoka 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/localizing-a-static-…] indexed:0 read:4min 2026-11-06 · —