Adding Korean/English Toggle to My Blog
I wanted to make this blog readable in English too. Part of it was for portfolio purposes, but honestly, the bigger reason was curiosity — would the context of my writing translate beyond Korean? Philosophy of technology essays, stories about my country house in Seocheon, dev logs. What would they read like in English?
The question was how to implement multilingual support on a Jekyll blog. There are several approaches, each with different trade-offs. Instead of puzzling over it alone, I decided to use Claude Code’s agent system.
Phase 0. The Planning Agent
I didn’t start with implementation. First, I ran the planning agent (blog-i18n-planner). I gave it one requirement — “I want to add Korean/English toggle to my Jekyll blog” — and let it design the approach.
The planning agent spawned four sub-agents in parallel. Translation API + build pipeline design, UI string and Liquid template analysis, SEO + badge design, and similar case research. Each investigated independently, then synthesized their findings into an implementation plan document (i18n_구현플랜.md).
Key decisions were made at this stage.
Runtime vs build-time translation. Runtime approaches like Google Translate widgets offer no quality control and no SEO. Manually translating 110 posts wasn’t feasible either. The conclusion was build-time automated translation. GitHub Actions calls the DeepL API to translate posts, builds them as static HTML, and deploys them under the /en/ path.
UI string switching method. Post body content lives in separate HTML files, while UI strings like navigation and page titles use CSS data-lang attribute switching for instant toggling. Same pattern as the dark mode toggle (data-theme).
Four-phase structure. Phase 1 is the translation pipeline, Phase 2 is UI i18n + toggle, Phase 3 is badges + SEO, Phase 4 is spread mode JS string handling. Independent tasks within each phase run in parallel; phases run sequentially.
The plan document was specific enough to move straight to implementation.
Phase 1. Translation Pipeline
I ran the implementation agent (blog-i18n-implement). This agent reads the plan document and orchestrates sub-agent teams for each phase.
In Phase 1, three agents worked in parallel. workflow-builder (GitHub Actions yml), translate-script (translation script), title-script (English title generation). They create different files, so no conflicts.
The core structure of the translation script:
def translate_post(filepath, api_key):
fm, body, raw = parse_post(filepath)
# Protect code blocks from translation
protected_body, code_blocks = protect_code_blocks(body)
translated_body = translate_text(protected_body, api_key)
translated_body = restore_code_blocks(translated_body, code_blocks)
# Generate English post front matter
new_fm = {
"layout": "post-en",
"title": title_en,
"permalink": f"/en/{date_str}/{slug}/",
"original_url": f"/{date_str}/{slug}/",
"lang": "en",
"translated_from": "ko"
}
Code blocks are replaced with placeholders before translation and restored afterward. If title_en already exists in the front matter, it’s used as-is; otherwise, the title gets translated too. Content hash-based caching ensures only changed posts are re-translated.
_posts/ ← Korean originals
_translated/en/ ← Posts translated via DeepL
_translation_cache.json ← MD5 hash cache
Phase 2. CSS-based i18n Toggle
Phase 2 also had three agents running in parallel. i18n-data (_data/i18n.yml creation), liquid-convert (applying i18n spans to Liquid templates), toggle-ui (toggle JS + SCSS).
The i18n span pattern is exactly the same as dark mode.
<span class="i18n" data-lang="ko">글 목록</span>
<span class="i18n" data-lang="en">Post List</span>
.i18n { display: none; }
[data-lang="ko"] .i18n[data-lang="ko"] { display: inline; }
[data-lang="en"] .i18n[data-lang="en"] { display: inline; }
Changing one data-lang attribute on the <html> tag instantly switches all UI strings across the page. Translation data lives in _data/i18n.yml.
pages:
writings:
title:
ko: "✍️ 이것저것 쓰고싶은 글을 씁니다"
en: "✍️ Writing about this and that"
tags:
공연:
en: "Performance"
기술철학:
en: "Philosophy of Technology"
Phases 3–4. Badges, SEO, JS Strings
Phase 3 added a “Translated from Korean” badge to the English post layout (post-en.html), plus hreflang tags and dynamic <html lang> setting. Phase 4 replaced JS-generated strings in spread mode (loading messages, ToC titles, etc.) with a __t() helper function.
That was the scope of what the agents built automatically. Build passed, done.
What Broke During Manual QA
The agent-generated code passed the build, but actual usage revealed issue after issue. This part required manual fixing.
DeepL API authentication failure. The agent’s script put auth_key in the POST body, but DeepL’s Free plan requires an Authorization: DeepL-Auth-Key header. Got a 403 error; fixed it manually.
68 posts returning 404. The most painful bug. After translating all 110 posts, 68 English pages returned 404.
The cause was timezones. Jekyll converts the front matter date to local timezone (KST, +9) when generating URLs. For example, date: 2026-02-10T22:52:00+00:00 becomes February 11 in KST. But the translation script was using the filename date (2026-02-10) directly in the permalink.
def get_jekyll_date(fm, filename):
date = fm.get("date")
if date:
if hasattr(date, 'tzinfo') and date.tzinfo is not None:
# UTC → KST conversion
local_date = date + timedelta(hours=9)
return local_date.strftime("%Y-%m-%d")
return date.strftime("%Y-%m-%d")
# fallback: extract from filename
name = Path(filename).stem
match = re.match(r"(\d{4}-\d{2}-\d{2})", name)
return match.group(0) if match else None
Fixed the script and batch-corrected the permalinks in all 68 files. No DeepL API calls needed — just front matter corrections.
Spread mode toggle escape. Toggling language in desktop spread mode broke out of the spread layout. history.replaceState had changed the URL to a post path, so the toggle JS thought “this is a post page” and navigated to the /en/ URL. Solved with a lang-changed custom event pattern.
// lang-toggle.js
if (isInSpreadMode()) {
document.dispatchEvent(new CustomEvent('lang-changed', { detail: { lang: next } }));
return;
}
// spread.js
document.addEventListener('lang-changed', function() {
if (!currentUrl) return;
cache = {};
var reloadUrl = currentUrl;
currentUrl = null;
loadPost(reloadUrl);
});
hreflang double prefix. English pages generated hreflang as /en/en/.... Fixed by branching on page.lang.
{% if page.lang == 'en' %}
<link rel="alternate" hreflang="ko" href="{{ page.original_url | absolute_url }}" />
<link rel="alternate" hreflang="en" href="{{ page.url | absolute_url }}" />
{% else %}
<link rel="alternate" hreflang="ko" href="{{ page.url | absolute_url }}" />
<link rel="alternate" hreflang="en" href="{{ '/en' | append: page.url | absolute_url }}" />
{% endif %}
Portfolio/newsletter not showing in English. In EN mode, spread tried to fetch /en/portfolio/ which doesn’t exist. Fixed by only adding the /en/ prefix for post URL patterns (/YYYY-MM-DD/slug/), and added i18n spans directly to portfolio and newsletter pages.
The QA Agent
Clicking around manually and finding bug after bug wasn’t scaling. At this point I ran a QA agent. It systematically checked behavior across 9 test categories — toggle actions, spread mode, URL structure, SEO tags, layout consistency, and more.
The QA agent found 5 issues. Spread mode toggle escape (Critical), hreflang double prefix (High), <html lang="ko"> hardcoding (High), untranslated tags on English posts (Medium), broken prev/next navigation on English posts (Low). It came back with priorities assigned.
What manual QA had been discovering one-by-one (“why doesn’t this work?”), the agent organized all at once. Some I’d already found, others I hadn’t. The <html lang="ko"> hardcoding was an SEO issue invisible to the naked eye — I would have missed it without the agent.
With this report, I fixed all 5 in one pass. Spread toggle via lang-changed event pattern, hreflang via page.lang conditional, <html lang> via Liquid variable, tag translation via i18n.yml mapping, and prev/next removed entirely since the translated post structure can’t support it.
DeepL API Free Plan
The DeepL Free plan has a 500,000 character monthly limit. Translating all 110 posts used about 357,000 characters. Running one full translation pass at the start of the month leaves plenty of headroom for incremental translation of new or edited posts.
Translation quality was better than expected. It handled Korean mixed with technical terminology particularly well. Code blocks stayed intact thanks to the placeholder substitution. That said, nuance sometimes gets lost in essay-style writing, so for important posts it’s better to manually set title_en in the front matter.
Deployment Troubles
After fixing everything, I split the commits into 5 by topic and pushed. GitHub Actions failed. The agent-generated workflow referenced secrets.DEEPL_API_KEY directly in a step’s if condition, which isn’t valid GitHub Actions syntax. It needs to go through a job-level env var and check env.HAS_DEEPL_KEY == 'true'.
Fixed and pushed again — this time the deployment itself didn’t happen. The blog had been deploying via GitHub’s default pages-build-deployment workflow, but the agent had put deploy-pages@v4 inside the custom workflow, which confused the deployment path. I separated the custom workflow into translation-only, then added a dedicated deploy.yml for Jekyll build + Pages deployment.
The final workflow structure ended up clean. translate-and-deploy.yml (should really rename this) only runs translation when _posts/ changes. deploy.yml handles Jekyll build + Pages deploy on every master push. Clean separation of concerns.
The overall flow looked like this. Planning agent designs the architecture, implementation agent writes 20 files of code, I do manual QA, hit a wall, run a QA agent, then fix the discovered issues. Agent → human → agent → human, this loop repeated.
Honestly, agent-generated code didn’t work perfectly on the first run. It got the DeepL authentication method wrong, missed timezone conversion, and broke the spread mode toggle. But with the agents handling design and boilerplate, my focus narrowed from “design everything + build everything” to “QA + fix edge cases.” The insight that the same pattern as dark mode could be reused — toggling data-lang just like data-theme — came from the planning stage.
Translation quality isn’t perfect. But the gap between “no English version at all” and “machine translation, at least” is significant. Important posts can be polished by hand later. For now, having English versions of all 110 posts is meaningful in itself.