{"slug": "localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every", "title": "Localizing a static site into 6 languages at build time with an LLM — and validating every string", "summary": "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.", "body_md": "[LiveToka](https://livetoka.com) 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.\n\nRewriting 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.\n\n```\npublic/index.html (English master)          public/*.js with t('…')\n          │ extract strings                           │ extract t('…') keys\n          └──────────────► dictionary/<lang>.json ◄───┘\n                               ▲  missing keys only\n                               │  LLM, batched, validated\n          build: translate HTML + inline the JS dictionary → public/<lang>/index.html\n```\n\nTwo kinds of text need different handling: what's in the HTML, and what JavaScript creates at runtime.\n\nI 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.\n\nRules that matter:\n\n`<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.\n\nStatus messages, toasts, dialogs and cookie banners are built in JS. I wrapped them in `t()`:\n\n``` js\nconst t = (text, vars) => (window.LT_t ? window.LT_t(text, vars) : fill(text, vars));\n\nstatusEl.textContent = t('Ready to begin translation');\nnotify(t('Copied {n} lines.', { n: lines.length }));\n```\n\nThe 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.\n\nAt 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.\n\nMissing keys go to the model in batches of ~40 as JSON:\n\n```\n{\"items\": [{\"id\": 0, \"text\": \"Start Translation\"}, {\"id\": 1, \"text\": \"Copied {n} lines.\"}]}\n```\n\nand come back as `{\"translations\": [{\"id\": 0, \"text\": \"…\"}]}`. Ids matter: models occasionally merge or drop items, and without ids you can't tell which.\n\nThe prompt carries:\n\n`{placeholders}`, keep HTML tags and attributes exactly, keep leading emoji, never translate the brand or product names.\nEvery translation is checked before it enters the dictionary:\n\n``` js\nfunction problems(src, out) {\n  const p = [];\n  if (!out?.trim()) p.push('empty');\n  if (placeholders(src) !== placeholders(out)) p.push('placeholders');\n  if (tags(src).join('') !== tags(out).join('')) p.push('html tags');\n  if (leadingEmoji(src) && !out.startsWith(leadingEmoji(src))) p.push('emoji');\n  if (src.includes('LiveToka') && !out.includes('LiveToka')) p.push('brand');\n  return p;\n}\n```\n\nFailures 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.\n\nThe 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.\n\nTwo fixes:\n\n`--retranslate <regex>` flag that drops matching keys (`[Tt]ranscri`) so they're redone with the glossary.\nLesson: after the first run, grep your dictionary for your product's core nouns and count the variants.\n\nMy 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.\n\nLocalized pages are only half the job if the flows bounce people back to English. Three places did that:\n\n`/`. Login links now pass the current path (` returnTo`), validated server-side to be a same-site path.\nFor 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.\n\n`canonical`, `og:url`, and a generated `hreflang` block (all languages + `x-default`) — on the English pages too.\nThe 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.", "url": "https://wpnews.pro/news/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every", "canonical_source": "https://dev.to/anders_lee_28dbcc2fd8e034/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-validating-every-string-24g3", "published_at": "2026-11-06 23:07:54+00:00", "updated_at": "2026-11-06 23:22:35.298329+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "developer-tools", "generative-ai"], "entities": ["LiveToka"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every", "markdown": "https://wpnews.pro/news/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every.md", "text": "https://wpnews.pro/news/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every.txt", "jsonld": "https://wpnews.pro/news/localizing-a-static-site-into-6-languages-at-build-time-with-an-llm-and-every.jsonld"}}