Skip to content

Tutorial 5 Localization

James Morris edited this page Jul 29, 2026 · 1 revision

Tutorial 5 · Localization (i18n)

Goal: understand how LockedIn CLI speaks 33 languages — and practice directing an agent to add another one. Localization is a fantastic agent task: it's mechanical enough to delegate, but it has real constraints (a test gate, layout rules, grammar review) that teach you to review.

← Prev: Tutorial 4 Prompting & Reviewing · Back to Home


What "localized" means here

Run the CLI in Spanish, Hindi, Japanese, Simplified Chinese, or any shipped language and everything changes — the splash, the help table, every command's output, the chat session, even the legal fine print. Not just the jokes: the whole visible surface.

lockedin --lang es post
LOCKEDIN_LANG=hi lockedin
lockedin --lang zh aura

The language is autodetected on startup, resolved in priority order:

  1. the --lang flag (--lang es, --lang=fr, -l ja)
  2. the LOCKEDIN_LANG environment variable
  3. your locale (LC_ALL / LC_MESSAGES / LANG, then the OS/runtime locale)
  4. English, as the fallback

normalizeLang() normally uses the locale's primary subtag. That means de-DE selects de, but tlh does not accidentally become tl; the real aliases fil and tgl intentionally map to Tagalog (tl), Norwegian nb and nn map to no, legacy Indonesian in maps to id, and legacy Hebrew iw maps to he. Two regional codes are preserved verbatim rather than folded to their primary subtag: pt-BR/pt_BR select the canonical regional code while generic pt stays the backward-compatible Brazilian Portuguese bundle (both are Brazilian Portuguese and share the same pools/UI), and en-SG/en_SG keep Singlish (generic en stays English). Hong Kong Traditional Chinese is the same kind of exception: zh-HK, zh_HK.UTF-8, and zh-Hant-HK select zh-HK, while generic zh and mainland tags select Simplified Chinese (zh).

Switching mid-flight — the /language panel. The CLI could always start in another language (--lang, LOCKEDIN_LANG); now you can switch mid-session. Type /language (aliases /lang and /languages) to list all 33 languages by code, each in its own script; /language el switches for the rest of the session. The point is the escape hatch: after a switch it redraws in the new language and then, in the language you just left, prints the exact way back — /language en now, lockedin --lang en next time — so accidentally landing in 日本語 or ಕನ್ನಡ never strands you. (Switch twice and the language your LOCKEDIN_LANG names is offered too.) --lang and LOCKEDIN_LANG are unchanged. Like /a11y, it's a sincere utility, not part of the satire.

The idea: language bundles

All translatable text lives in bundles, one per language, each shaped like:

{ meta: { lang: 'es', name: 'Español', dir: 'ltr' },
  pools: { HOOKS: [ /* ~25 */ ], LESSONS: [ /* ... */ ], /* ... */ },
  ui:    { buzzwordDensity: 'Densidad de palabrería: ', /* labels, headings */ } }
  • pools are the content arrays (the jokes) you met in Chapter 2.
  • ui are the chrome strings: labels, headings, and small templates.

English is the reference bundle inside src/lockedin.js; the other 32 bundle modules live in src/content/*.js: ar, bn, bo, de, el, en-SG, es, eu, fa, fi, fr, he, hi, id, is, it, ja, kn, ms, nl, no, pl, pt, pt-BR, ru, sv, tl, tr, uk, ur, zh, and zh-HK (pt-BR reuses pt's pools/UI but is registered separately). Each one is registered in BUNDLES; SUPPORTED_LANGS is generated from those keys, and renderHelp() prints that generated code list. No UI bundle hard-codes the list.

setLang('fr');       // point the active language at the French bundle
// L = active pools, U = active ui
pick(L.HOOKS)        // a French hook
U.buzzwordDensity    // "Densité de jargon : "

Because every renderer reads L and U (never a hardcoded string), setLang alone changes the entire experience. That's the whole trick.

The safety net: key parity

Here's the invariant that makes adding a language safe:

Every bundle must expose the exact same pools and ui keys as English.

A test enforces it across all 33 bundles. If you add a new UI string in English and forget to translate it in Ukrainian, npm test goes red and tells you which key is missing. You cannot silently ship a half-translated language.

The hard part: terminal layout

Languages stress terminal layout in different ways:

  • Japanese, Simplified Chinese, and Hong Kong Traditional Chinese use East Asian Wide / Fullwidth characters. vw() counts those as two columns, and wrap() hard-breaks long spaceless tokens so CJK text stays inside cards and boxes.
  • Hindi and Kannada use nonspacing/enclosing combining marks (Mn / Me), such as matras and viramas. vw() counts those as zero columns so they do not inflate measured width.
  • box() wraps each body line before padding it, so a long translated banner can no longer punch through the border.
  • Each bundle sets sentenceEnd and listSep (for example . / , , / ) so generator-composed sentences read naturally.

When you add a language, card header strings (cardSubtitle, cardMeta, cardFooter) must still fit ≤ 60 visible columns. Arabic, Persian, Hebrew, and Urdu set meta.dir: 'rtl'. Output contains no bidi controls by default, because some terminals render them as boxed labels. LOCKEDIN_BIDI=on explicitly enables balanced isolates after wrapping for terminals known to support them, preserving ANSI, ASCII commands, and logical copy/paste order. Accessible output always removes those controls. Without the opt-in, mixed RTL/LTR ordering may be less sophisticated; never probe or infer support.

The sneaky part: grammar around raw user input

Some UI templates splice in raw user clauses with placeholders like {cap}. Do not translate those slot-for-slot. The sentence must remain grammatical when the placeholder is a phrase the user typed, not a neat noun.

A real cautionary bug: Japanese templates that put directly after {cap} can sound wrong when {cap} is a full clause. The fix is not "translate harder"; it is to restructure the template (for example, add a nominalizer or move the placeholder) so arbitrary user input still fits.

✅ Try it with your agent — add a language

This exercise still works exactly the same way. Pick a language you can sanity-check (or ask the agent to), and drive it end-to-end. Write the spec first:

Add Danish (da). Create src/content/da.js as a { meta, pools, ui } bundle with the same keys as English, translating every entry (content pools at ~25 each, all UI strings). Register da in BUNDLES in src/lockedin.js. --lang da and a da-* locale must select it. Keep card header strings within the width limit. npm test must stay green, and add Danish invariants + detection tests mirroring the existing localized ones.

Then run the loop from Chapters 3–4:

  1. Plan first. "Before writing code, tell me the files you'll change and how you'll keep key parity with English."
  2. Tests first. "Add failing tests: da detection, key parity for da, and a Danish reflect/connect invariant. Don't create the bundle yet."
  3. Implement. "Now create src/content/da.js by translating an existing bundle key for key, register it, and make the tests pass. Only pick/shuffle for randomness."
  4. Gate + review. npm test, then lockedin --lang da post — and read the diff: did every key get translated? Do the card borders still line up? Do templates with {cap} survive raw user clauses?

Smaller warm-up exercises if a whole language is too much:

  • "Add one more TAGLINE to all 33 language bundles, keeping counts equal."
  • "Check whether the Kannada cardFooter is ≤ 60 visible columns and explain how combining marks were measured."
  • "Show me the test that would fail if I deleted a ui key from ja.js."

Where to go next

  • Skim src/content/es.js — it is still a friendly template for a new bundle.
  • Re-read docs/HANDOFF.md → "Adding a language".
  • Enjoy the multilingual jokes in the Command Reference.

That's the full tutorial. You can now direct an AI agent to build features and localize them behind a test gate — in 33 languages and ready for more. Agree? 👇

📘 LockedIn CLI wiki

Tutorial

Reference


Satire · Sátira · 風刺. Not affiliated with LinkedIn. GPL-3.0-or-later.

Clone this wiki locally