Extensions · 4 of 5

i18n Locale Pack — I18n.init() + locale switcher with builder re-render

One engine touchpoint — `I18n.init(messages)` replaces the active messages dictionary wholesale; `I18n.t(key)` falls back to compile-time `en.json` defaults for any key the active dict misses. The buyer owns the re-render call after locale switch (the engine doesn't auto-repaint when messages change).

Walkthrough

  1. Define your locale tables — full or partial

    Each locale is a flat { key: translation } dictionary. Keys mirror src/lang/en.json — element names live under elements.*, control labels under controls.*, color-picker fallbacks under color.*, etc. The full key list ships in src/lang/en.json (~1000+ entries); your locale pack only needs the keys you want to translate — every missing key falls back to the en.json default automatically (I18n.t looks up I18n._messages[key] || I18n._defaults[key] || key).

    Partial packs are first-class — translate the high-traffic palette + panel labels first, ship, then iterate. The same I18n.t call sites work for any locale shape; nothing in the engine cares whether 10 or 1000 keys are translated.

    I18n.t(key, params) also supports parametric interpolation via :varName placeholders (e.g. "errors.unused_keys": "Missing :count keys" + I18n.t('errors.unused_keys', {count: 3})). Translations preserve the placeholders byte-identically; the engine substitutes at call time.

    JavaScript
    // Compact locale pack — covers the most-visible palette + panel labels.
    // Real-world packs ship 100s of keys (mirror src/lang/en.json end-to-end).
    const LOCALES = {
      // EN: empty dict → I18n.t falls through to en.json defaults baked into
      // dist/builder.js at compile time. The Reset button below uses this.
      en: {},
    
      fr: {
        'elements.heading':    'Titre',
        'elements.paragraph':  'Paragraphe',
        'elements.button':     'Bouton',
        'elements.image':      'Image',
        'elements.divider':    'Séparateur',
        'elements.link':       'Lien',
        'elements.list':       'Liste',
        'controls.font_family': 'Police',
        'controls.font_size':   'Taille du texte',
        'controls.text_color':  'Couleur du texte',
        'controls.alignment':   'Alignement',
        'controls.color':       'Couleur',
        'controls.height':      'Hauteur',
        'color.not_set':        'non défini',
      },
    
      vi: {
        'elements.heading':    'Tiêu đề',
        'elements.paragraph':  'Đoạn văn',
        'elements.button':     'Nút',
        'elements.image':      'Hình ảnh',
        'elements.divider':    'Đường kẻ',
        'elements.link':       'Liên kết',
        'elements.list':       'Danh sách',
        'controls.font_family': 'Phông chữ',
        'controls.font_size':   'Cỡ chữ',
        'controls.text_color':  'Màu chữ',
        'controls.alignment':   'Căn lề',
        'controls.color':       'Màu sắc',
        'controls.height':      'Chiều cao',
        'color.not_set':        'chưa đặt',
      },
    };
  2. Wire the switcher — I18n.init then re-render the builder

    I18n.init(messages) at src/includes/I18n.js:7-9 reassigns the active dictionary wholesale. There's no observer / no event / no auto-repaint — every UI surface that called I18n.t at render time still shows the OLD strings until that surface re-renders.

    The canonical re-render call covers all three buyer-facing surfaces in one shot: builder.load(builder.getData(), ...) rebuilds the canvas tree (every element's getName() + getControls() labels run through I18n.t again on the next render); builder.widgetsBox.render() repaints the palette so palette-item labels follow; if the buyer had a control panel open at switch time, the engine's post-load re-selection clears it (load() calls this.unselect() internally), so the panel surfaces with the new strings on the NEXT canvas-click.

    The 3-button switcher in the toolbar is just three click handlers calling the same applyLocale(lang) helper. Status pill reflects the active locale (no engine API for that — the buyer tracks it in a closure / a host store / sessionStorage).

    JavaScript
    let activeLocale = 'en';
    
    function applyLocale(lang) {
      if (!LOCALES[lang]) return;
      activeLocale = lang;
    
      // 1) Swap the active messages dictionary. I18n._messages is now LOCALES[lang].
      I18n.init(LOCALES[lang]);
    
      // 2) Re-render the builder so every UI surface re-runs I18n.t with the
      //    new messages. load() reads JSON in / parses every node / fires render()
      //    on each — palette + panel labels follow naturally.
      const data = builder.getData();
      builder.load(data, themeTemplates, themeConfigData, mediaUrl, () => {
        if (builder.widgetsBox) builder.widgetsBox.render();
      });
    
      // 3) Update host-side UI that doesn't go through I18n (status pill, etc.).
      document.getElementById('c14StatusPill').textContent = `Locale: ${lang.toUpperCase()}`;
    }
    
    document.getElementById('c14EnBtn').addEventListener('click', () => applyLocale('en'));
    document.getElementById('c14FrBtn').addEventListener('click', () => applyLocale('fr'));
    document.getElementById('c14ViBtn').addEventListener('click', () => applyLocale('vi'));
  3. Try it — palette labels switch live across all 3 locales

    Click FR / VI in the toolbar. The widget palette on the left flips to French / Vietnamese labels — "Paragraph" → "Paragraphe" → "Đoạn văn"; "Button" → "Bouton" → "Nút"; etc. Click any element on the canvas to open the settings panel — control labels also pick up the new locale.

    Click EN to reset — the I18n.init({}) call clears the active dict; subsequent I18n.t calls fall through to the en.json defaults baked into dist/builder.js. No server round-trip; no rebuild; no flash. The whole locale switch happens in <100ms even on cold cache (the engine's render pass is fast + the dictionary swap is O(1)).

    For production deployments: store the buyer's preferred locale in localStorage + apply it once at boot (BEFORE the first builder.load()) so the canvas mounts in the right language from frame zero. For multi-tenant SaaS, scope the LOCALES dict per tenant + lazily fetch the active locale's pack as the buyer authenticates (don't ship every tenant's pack to every browser).

    JavaScript
    // Production pattern — restore the buyer's preferred locale at boot,
    // BEFORE the first load() call so the canvas mounts in the right language.
    const savedLocale = localStorage.getItem('builder.locale') || 'en';
    I18n.init(LOCALES[savedLocale]);
    
    const builder = new Builder({ /* ... */ });
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
    
    // Persist the user's choice on every switch.
    function applyLocale(lang) {
      if (!LOCALES[lang]) return;
      localStorage.setItem('builder.locale', lang);
      I18n.init(LOCALES[lang]);
      builder.load(builder.getData(), THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
        builder.widgetsBox && builder.widgetsBox.render();
      });
    }
    
    // Multi-tenant: lazy-fetch the active locale's pack on auth, scope per tenant.
    async function fetchTenantLocale(tenantId, lang) {
      const r = await fetch(`/api/tenants/${tenantId}/locales/${lang}.json`);
      return r.json();
    }

Live demo

demo-mini-builder--rich-3col-header
i18n Locale Pack — switch palette + panel labels liveLocale: EN

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

<?php
/**
 * snippet.php — i18n locale pack via `I18n.init(translations)` + a
 * 3-button locale switcher that re-renders the builder so palette +
 * panel + canvas pick up new strings.
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server. Click
 * EN / FR / VI to swap the active locale; the widget palette on the
 * left and any open settings panel pick up the new strings on next
 * render. The status pill reflects the active locale.
 *
 * Button IDs `c14EnBtn` / `c14FrBtn` / `c14ViBtn` / `c14StatusPill`
 * match the live demo on /examples/extensions/4-i18n-locale-pack/ AND
 * Steps 1-3 of the walkthrough (BUILDER.md RULE H + D13.B.parity
 * triple-surface gate).
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>i18n Locale Pack — I18n.init() + locale switcher</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
        .my-header { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; flex-wrap: wrap; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
        .my-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; height: 28px; padding: 0 12px; font: inherit; font-size: 12px; font-weight: 600; letter-spacing: .04em; border: 1px solid #d4d4d8; background: #fff; color: #18181b; border-radius: 6px; cursor: pointer; transition: all 120ms ease; }
        .my-btn:hover { border-color: #4338ca; color: #4338ca; }
        .my-btn[aria-pressed="true"] { background: #4338ca; color: #fff; border-color: #4338ca; }
        .my-btn[aria-pressed="true"]:hover { background: #3730a3; border-color: #3730a3; color: #fff; }
        .my-pill { font-size: 11px; padding: 3px 10px; border-radius: 999px; background: #e0e7ff; color: #4338ca; font-weight: 600; }
        .my-builder-host { display: grid; grid-template-columns: 200px 1fr 240px; min-height: 0; }
        .my-host-slot { background: #f8f9fa; padding: 8px; overflow: auto; font-size: 12px; }
        .my-canvas { overflow: auto; background: #fff; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">i18n Locale Pack</span>
    <button type="button" class="my-btn" id="c14EnBtn" aria-pressed="true" aria-label="English">EN</button>
    <button type="button" class="my-btn" id="c14FrBtn" aria-pressed="false" aria-label="Français">FR</button>
    <button type="button" class="my-btn" id="c14ViBtn" aria-pressed="false" aria-label="Tiếng Việt">VI</button>
    <span class="my-pill" id="c14StatusPill">Locale: EN</span>
</header>

<div class="my-builder-host">
    <div id="MyWidgets"  class="my-host-slot"></div>
    <div id="MyCanvas"   class="my-canvas"></div>
    <div id="MySettings" class="my-host-slot"></div>
</div>

<script>
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    // 1) Locale tables. Compact subset — covers the most-visible palette +
    //    panel labels. Real-world packs ship 100s of keys mirroring
    //    src/lang/en.json end-to-end. Missing keys fall back to en.json
    //    defaults via I18n.t's three-tier lookup (active → defaults → key).
    const LOCALES = {
        en: {},  // empty → falls through to compile-time en.json defaults
        fr: {
            // Palette item labels — widgets.* (Widget.getName uses this namespace)
            'widgets.heading':      'Titre',
            'widgets.paragraph':    'Paragraphe',
            'widgets.button':       'Bouton',
            'widgets.image':        'Image',
            'widgets.divider':      'Séparateur',
            'widgets.list':         'Liste',
            // Canvas + panel labels — elements.*
            'elements.heading':     'Titre',
            'elements.paragraph':   'Paragraphe',
            'elements.button':      'Bouton',
            'elements.image':       'Image',
            'elements.divider':     'Séparateur',
            'elements.link':        'Lien',
            'elements.list':        'Liste',
            // Control labels — controls.*
            'controls.font_family': 'Police',
            'controls.font_size':   'Taille du texte',
            'controls.text_color':  'Couleur du texte',
            'controls.alignment':   'Alignement',
            'controls.color':       'Couleur',
            'controls.height':      'Hauteur',
            'color.not_set':        'non défini',
        },
        vi: {
            'widgets.heading':      'Tiêu đề',
            'widgets.paragraph':    'Đoạn văn',
            'widgets.button':       'Nút',
            'widgets.image':        'Hình ảnh',
            'widgets.divider':      'Đường kẻ',
            'widgets.list':         'Danh sách',
            'elements.heading':     'Tiêu đề',
            'elements.paragraph':   'Đoạn văn',
            'elements.button':      'Nút',
            'elements.image':       'Hình ảnh',
            'elements.divider':     'Đường kẻ',
            'elements.link':        'Liên kết',
            'elements.list':        'Danh sách',
            'controls.font_family': 'Phông chữ',
            'controls.font_size':   'Cỡ chữ',
            'controls.text_color':  'Màu chữ',
            'controls.alignment':   'Căn lề',
            'controls.color':       'Màu sắc',
            'controls.height':      'Chiều cao',
            'color.not_set':        'chưa đặt',
        },
    };

    let activeLocale = 'en';

    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    // Populate the widget palette with stock widgets — the engine doesn't
    // auto-populate; widgets must be added explicitly via widgetsBox.addWidget.
    function registerStockWidgets() {
        if (!builder.widgetsBox) return;
        // builder.load() creates a NEW widgetsBox each call but doesn't clear
        // the prior `<div class="widgets-box">` wrapper out of the host container
        // — orphans accumulate across reloads. Clear the container DOM here,
        // before the new widgetsBox renders into it.
        const widgetsContainerEl = document.getElementById('MyWidgets');
        if (widgetsContainerEl) widgetsContainerEl.innerHTML = '';
        builder.widgetsBox.widgetGroups = {};
        ['HeadingWidget', 'ParagraphWidget', 'ImageWidget', 'ButtonWidget',
         'DividerWidget', 'ListWidget'].forEach((name) => {
            const Klass = window[name];
            if (typeof Klass === 'function') {
                builder.widgetsBox.addWidget(new Klass(), { group: 'Layout' });
            }
        });
        builder.widgetsBox.render();
    }
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, registerStockWidgets);

    // 2) Apply-locale helper — swap the active dict, re-render the builder.
    //    load(getData, ...) rebuilds the canvas (every Element's getName +
    //    getControls labels run through I18n.t again); registerStockWidgets()
    //    in the load() callback re-paints the palette with new locale's labels.
    function applyLocale(lang) {
        if (!LOCALES[lang]) return;
        activeLocale = lang;
        I18n.init(LOCALES[lang]);

        const data = builder.getData();
        builder.load(data, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, registerStockWidgets);

        document.getElementById('c14StatusPill').textContent = `Locale: ${lang.toUpperCase()}`;
        ['en', 'fr', 'vi'].forEach((l) => {
            const btn = document.getElementById(`c14${l[0].toUpperCase()}${l.slice(1)}Btn`);
            if (btn) btn.setAttribute('aria-pressed', String(l === lang));
        });
    }

    // 3) Wire the 3 toolbar buttons.
    document.getElementById('c14EnBtn').addEventListener('click', () => applyLocale('en'));
    document.getElementById('c14FrBtn').addEventListener('click', () => applyLocale('fr'));
    document.getElementById('c14ViBtn').addEventListener('click', () => applyLocale('vi'));
</script>

</body>
</html>

Notes

Why I18n.init replaces wholesale (no merge). The implementation at src/includes/I18n.js:7-9 reassigns I18n._messages = translations || {} — there\'s no per-key merge with prior state. This is intentional: locale switching means "use THIS locale" not "additively layer translations." Buyers wanting a base + override pattern (e.g. en-GB extends en) build the merged dict themselves before calling init: I18n.init({ ...baseEn, ...enGbOverrides }).

The three-tier lookup makes partial packs first-class. I18n.t(key) at line 11-13 reads I18n._messages[key] || I18n._defaults[key] || key. _defaults is the compile-time en.json baked into dist/builder.js; _messages is whatever was last passed to I18n.init. So a 10-key locale pack still renders a coherent UI — every untranslated string falls through to English. This means buyers can ship FR Day 1 with the top 50 keys translated + iterate over the next quarter.

Apply BEFORE the first load() for production. The init order matters because every Element\'s render() calls I18n.t on its labels. If you call I18n.init(LOCALES.fr) AFTER builder.load(), the canvas paints in EN first then needs an extra round-trip to repaint in FR. For first-paint perfectionism, restore the saved locale + init BEFORE constructing the Builder. The demo above takes the opposite tack (boot in EN, switch live) because the contract being taught IS the live switch — production can flip the order.

Re-rendering the panel on locale switch — when do you need it? The demo\'s load(getData, ...) implicitly clears the selected element (load() calls unselect() internally). If the buyer had a settings panel open during locale switch, the panel goes empty + reopens on next canvas-click with the new locale\'s control labels. To keep the panel open across the switch, capture builder.getSelectedElement() BEFORE the switch + call builder.selectElement(savedSelection) AFTER load completes (use the load() callback). For the simplest UX, just expect buyers to re-click after switching — that\'s the path the demo above teaches.

Material Symbols + RTL. Locale packs don\'t cover icon glyphs (Material Symbols are language-agnostic) or layout direction (RTL languages like Arabic / Hebrew need controls.text_direction wired through your locale + document.dir = \'rtl\' at the host level + custom CSS to mirror the chrome). For LTR locales like FR / VI, the demo above is sufficient.

Closes Phase C.extensions tier 4/4. For the next surface (`flows`), see W6.C.15 flows/1-export-html-zip-bundle/ (export modes side-by-side), W6.C.16 flows/2-import-bundle/ (drag-drop bundle.zip), W6.C.17 flows/3-history-keyboard-shortcuts/ (chord ladder + max-history stepper). For multi-tenant flows that COMBINE custom elements + tenant-scoped i18n, see W6.D.4 extensions/5-multi-tenant/.