Dev.to · 6 min read

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

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

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. The shape of it public/index.html (English master) public/*.js with t('…') │ extract strings │ extract t('…') keys └──────────────► dictionary/.json ◄───┘ ▲ missing keys only │ LLM, batched, validated build: translate HTML + inline the JS dictionary → public//index.html Two kinds of text need different handling: what's in the HTML, and what JavaScript creates at runtime. 1. Extracting text from HTML without a parser library 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: Skip , , , comments, and the language switcher. Translate text nodes that contain letters, keyed by their whitespace-normalised text. Translate a few attributes: placeholder, title, alt, aria-label, data-tooltip and data-name (my language picker reads names from data-name). Translate sentences with inline tags as one unit. "…see our Pricing 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 includes the tags. 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. 2. Strings created by JavaScript 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 window.LT_DICT = {…} with just the JS keys, loaded before the first script that shows text. 3. Translating with an LLM — in batches, with ids 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: what the product is (UI of a real-time speech translation app), per-language style ("Japanese: buttons as short noun phrases, sentences in です・ます"), a glossary per language, hard rules: keep {placeholders}, keep HTML tags and attributes exactly, keep leading emoji, never translate the brand or product names. 4. Validate every string 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. 5. The bug validation can't catch: inconsistent terms 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: A glossary line per language: "transcription/transcribe = 받아쓰기 (never 전사 or 음성 텍스트 변환)". A --retranslate 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. 6. Don't translate language names in language links 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. 7. Keep users in their language Localized pages are only half the job if the flows bounce people back to English. Three places did that: Login: OAuth always returned to /. Login links now pass the current path (returnTo), validated server-side to be a same-site path. Payments: Stripe's success and cancel URLs point to English pages. Bookmarks and redirects in general. For the last two, a tiny script in 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. 8. SEO bits Each localized page gets its own canonical, og:url, and a generated hreflang block (all languages + x-default) — on the English pages too. Titles and meta descriptions for the localized homepages are hand-written; the rest is translated. FAQ structured data is regenerated from the translated FAQ. A CJK gotcha in my site checker: "description shorter than 50 characters" flags perfectly good Japanese descriptions. Count CJK characters double. Was it worth it? 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News