Skip to content
Wire to production D2 5 min read Customisation Copy-along · 20 min

i18n locale packs

Multi-language SaaS — translate ~20 keys, ship a French build in 20 minutes.

The shipped builder is English-only out of the box. Multi-language SaaS builds layer locale packs on top — French / Vietnamese / Spanish / whichever audience you ship to. This doc shows the i18n API + the pattern for shipping a locale. Time budget: 20 minutes.

The contract

Every UI string in the engine routes through I18n.t('key'). The base dictionary is src/lang/en.json. To translate, you call I18n.init({...}) with a flat key/value object before builder.load() runs.

`I18n.init()` **replaces** the dictionary wholesale (W6.C.14 lesson — no merge). You ship the full pack you want active.

Two key namespaces

There are two top-level namespaces (W6.C.14 lesson):

  • *`widgets.** — palette item labels (widgets.heading,widgets.paragraph,widgets.image`, …).
  • *`elements.** — canvas + settings panel labels (elements.heading,elements.paragraph,elements.image`, …).
  • *`controls.** — control labels (controls.text_color,controls.font_size`, …).

A locale pack must translate all three namespaces it cares about — translating only widgets.* and you get a half-translated UI (palette in French, panel still in English).

Step 1 — Pre-flight

Open <http://localhost:8000/examples/extensions/4-i18n-locale-pack/>. The example ships three buttons (EN / FR / VI) — clicking flips the dictionary + repaints. Reproduce this pattern in your own host.

Step 2 — Author your locale pack

Start from src/lang/en.json (the canonical key list). Translate the keys you need:

Three rules to keep dictionary maintenance sane:
  1. Match key spelling exactly to en.json — a typo means the engine falls back to the key itself ("widgets.heading" appearing in the UI).
  2. Translate every key in the namespaces you ship — half-translation produces a half-translated UI.
  3. Keep the dict flat'widgets.heading': 'Titre', not widgets: { heading: 'Titre' }. The engine looks up by literal dot-key.

Step 3 — Wire the dictionary

Call I18n.init() BEFORE you call builder.load(). Otherwise the first paint uses English defaults, then flips to your locale on the next mutation:

The widgets palette renders with French labels. Click any heading → the settings panel labels are French too.

Step 4 — Locale switcher (optional)

To let buyers flip locale at runtime, swap the dict + repaint:

Empty dict (`{}`) routes through compile-time `en.json` defaults — that's the engine's fallback.

Common patterns

Locale from URL

For SaaS hosts where the active locale lives in the URL (/fr/builder, ?locale=fr), pick the dict server-side and inline it:

The dict ships in the same HTTP response as the page. No second fetch round-trip; first paint is correctly translated.

Custom widget keys

When you author a custom Widget (per Build a custom Widget), pick a key namespace under widgets.<your-namespace>.* so your translations don't collide with stock widget keys:

Then translate `widgets.acme.callout` in every locale pack you ship.

Auditing for missing keys

If a label still renders in English after I18n.init(), the key is missing from your dict. The engine falls back to the literal key string when no value is found — useful as a debug heuristic but ugly in production. Run a CI check that walks src/lang/en.json and asserts every key exists in each shipped locale pack:

(The shipped audit script is illustrative — most projects ship their own.)

Numbers, dates, currencies (pluralisation + formatting)

I18n.t() covers UI string lookup only. For number / date / currency formatting use the browser's Intl API — it's per-locale-correct without requiring any translation effort:

For pluralisation (`1 item` / `5 items`), use `Intl.PluralRules`:
Custom Element templates can call these directly inside the EJS `<%- %>` blocks. The shipped engine doesn't ship its own number / date helpers — `Intl` is good enough.

RTL languages (Arabic, Hebrew)

Set dir="rtl" on the canvas's <html> or container, and the shipped Page templates flip automatically (the engine uses logical CSS properties under the hood). Per-element overrides via formats.text_align: "right" work for cases where a particular element should buck the document direction.

Did you make it?

You should now be able to:

  • ✅ State the three namespaces (widgets.*, elements.*, controls.*) and what each one covers.
  • ✅ Author a flat dict with translated keys matching src/lang/en.json.
  • ✅ Call I18n.init(dict) before builder.load() to set the active locale.
  • ✅ Switch locales at runtime by re-calling init() + load() + re-rendering the palette.

If a step didn't work:

  • 🩹 Some labels still in English → that namespace isn't translated. Check controls.* if panel labels are English; check widgets.* if palette labels are.
  • 🩹 Switching locale leaves orphan widgets in palette → clear widgetsContainer.innerHTML = '' before widgetsBox.render() (W6.C.14 lesson — engine doesn't auto-clear).
  • 🩹 Custom widget label not translated → your custom widget's getName() must return I18n.t('widgets.<key>'), not a literal string.

What's next?

  • FAQ — common questions on version, email-client matrix, debug recipes.
  • 🔗 examples/extensions/4-i18n-locale-pack/ — runnable EN / FR / VI switcher.
  • 🔗 docs/core/LANGUAGE.md — the canonical i18n spec.
  • 🔗 src/lang/en.json — the canonical key list to translate from.