Extensions · 2 of 5

Custom Element — extend BaseElement, register, save/load round-trip

Three engine touchpoints — `extends BaseElement` (with `static parse(data)` + `getData()` + `_doRender()`), `Builder.registerElement(name, klass)` (mutates the registry, exposes the class on `window.*`), and a one-line EJS template injection into `builder.templates`. After registration, JSON nodes carrying `{ "name": "CountdownElement", … }` parse + render through the SAME path stock elements use.

Walkthrough

  1. Subclass BaseElement — getData / static parse / _doRender

    BaseElement exposes three centralizing helpers your subclass extends instead of re-implementing: getData() baseline returns { name, template, formats } (you spread ...super.getData() + add your unique fields); static parseFormats(instance, data) hydrates the formatter and returns the instance for chaining; renderTemplate(vars) resolves this.host.getTemplate(this.template) + runs EJS with { formatter, text, ...vars }.

    requiredTemplateKeys declares which EJS keys your template MUST reference — the engine throws synchronously at render time if your template body forgets one of them, so a typo surfaces in dev not in production. The constructor sets this.template to the theme-template lookup key (Step 3 injects the template body matching that key).

    The countdown stores ISO datetime + label as plain string fields; the live tick is an external interval (Step 4) that reads data-target-iso from the rendered DOM. Element state stays serialisable — no DOM refs, no closures, no Date instances; getData() emits exactly what parse(data) reconstructs.

    JavaScript
    class CountdownElement extends BaseElement {
      constructor(template, targetIso, label) {
        super();  // sets this.id, this.host=null, this.formats baseline, this.formatter=null
        this.template = template || 'CountdownTimer';
        this.targetIso = targetIso || '2026-12-31T23:59:59Z';
        this.label     = label     || '';
        this.domNode   = null;
        // EJS template body MUST reference both keys; engine throws if either is unused.
        this.requiredTemplateKeys = ['target_iso', 'label'];
      }
    
      getName() { return 'Countdown'; }
    
      getData() {
        // name + template baseline merged from super (W0.2c).
        return { ...super.getData(), target_iso: this.targetIso, label: this.label };
      }
    
      static parse(data) {
        const inst = new CountdownElement(data.template, data.target_iso, data.label);
        return BaseElement.parseFormats(inst, data);  // returns inst for chaining
      }
    
      _doRender() {
        // formatter auto-merged by BaseElement.renderTemplate (W0.2c) — only the
        // element-specific vars need to be passed.
        this.domNode.innerHTML = this.renderTemplate({
          target_iso: this.targetIso,
          label:      this.label,
        });
      }
    }
  2. Register the class + inject the EJS template — both BEFORE the round-trip load()

    Builder.registerElement(name, klass) mutates ELEMENT_REGISTRY (the lookup Map in src/includes/ElementFactory.js) AND writes klass onto window under its constructor name — both required so ElementFactory.getElementClass(name) resolves the class via the SAME path stock elements use. klass.parse(data) MUST be a function or registration throws synchronously.

    The template body is just an EJS string. builder.templates is a plain object the engine reads via this.host.getTemplate(this.template) at render time — adding a key is a one-line mutation. Most theme bundles ship the templates inside themeTemplates JSON so the EJS source and the element class travel together; for buyer-side custom elements, drop the template into the bundle once at boot or maintain a separate customTemplates object you merge into themeTemplates at every builder.load() call site.

    Re-registering an existing name is allowed and replaces the prior entry — the responsibility for compatibility is the host's. Useful for hot-reload during development; risky in production unless you own both the prior and replacement classes.

    JavaScript
    // EJS template body. Required keys: target_iso + label (matches
    // requiredTemplateKeys in the constructor). Skip the formatter merge
    // since CountdownElement has no Formatter instance — referencing
    // `formatter` in the template would throw ReferenceError under EJS's
    // with(locals). Add a Formatter (like PElement does) if you want
    // padding / background-color controls in the settings panel.
    //
    // The leading <style> block travels INSIDE the template body — engine
    // renders each block into its own sandboxed iframe, so host page CSS
    // does NOT cross the boundary. Any visual rule the element needs MUST
    // live with the template body (or use inline style attributes).
    const COUNTDOWN_TEMPLATE = `
    <style>
      .my-countdown { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; color: #f8fafc; }
      .my-countdown__label { font-size: 14px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; opacity: .85; }
      .my-countdown__row { display: flex; gap: 12px; justify-content: center; }
      .my-countdown__cell { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 64px; padding: 12px 8px; background: rgba(255,255,255,.08); border-radius: 8px; }
      .my-countdown__cell > span:first-child { font-size: 28px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
      .my-countdown__cell > span:last-child { font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; opacity: .7; }
    </style>
    <div class="my-countdown" data-bjs-countdown
         data-target-iso="<%- target_iso %>">
      <% if (label) { %><div class="my-countdown__label"><%- label %></div><% } %>
      <div class="my-countdown__row">
        <div class="my-countdown__cell"><span data-cd-d>—</span><span>days</span></div>
        <div class="my-countdown__cell"><span data-cd-h>—</span><span>hrs</span></div>
        <div class="my-countdown__cell"><span data-cd-m>—</span><span>min</span></div>
        <div class="my-countdown__cell"><span data-cd-s>—</span><span>sec</span></div>
      </div>
    </div>`;
    
    // One-time, before any save/load round-trip:
    Builder.registerElement('CountdownElement', CountdownElement);  // mutates ELEMENT_REGISTRY + window
    builder.templates.CountdownTimer = COUNTDOWN_TEMPLATE;          // engine's getTemplate() lookup
    
    // On subsequent builder.load(json, themeTemplates, ...), make sure the
    // `themeTemplates` object passed in ALSO carries CountdownTimer — load()
    // reassigns this.templates = themeTemplates wholesale (Builder.js:796).
    themeTemplates.CountdownTimer = COUNTDOWN_TEMPLATE;
  3. Round-trip getData → load — proves parse() rehydrates the custom element

    The engine doesn't expose a stable public appendElement() on the page level. Buyer-canonical pattern (locked by api/5-clear-insert-elements/ in C.10): builder.getData() → mutate the JSON → builder.load(modifiedData, ...). For a CUSTOM element, the only difference is that data.name === 'CountdownElement' resolves through the registry instead of the stock seed list — Step 2's registration is what makes this branch take.

    The reload button captures builder.getData() + immediately hands it back via builder.load(captured, themeTemplates, ...). The engine wipes the tree, re-parses every node, and walks the new tree to reattach host back-refs (_adopt(), Builder.js:252). Your CountdownElement nodes flow through ElementFactory.createElement → CountdownElement.parse(data); target_iso + label + formats survive byte-identically. If parse round-trip is broken, the element either disappears (registry miss → "Unknown element name") or renders with stale data (parse forgot to read a field).

    The "Bump target +1 day" button is the same loop with one extra step in the middle: read JSON → walk into data.blocks → find every { name: 'CountdownElement' } → add 86,400,000 ms to its target_iso → load. Useful for quick contract validation during development; in production you'd expose target_iso as a Control in getControls() for inline editing.

    JavaScript
    const HERO_COUNTDOWN_BLOCK = {
      name: 'BlockElement', template: 'Block',
      formats: { background_color: '#0f172a', padding_top: 24, padding_bottom: 24 },
      elements: [{
        name:       'CountdownElement',  // resolved via ELEMENT_REGISTRY (Step 2)
        template:   'CountdownTimer',    // resolved via builder.templates (Step 2)
        target_iso: '2026-12-31T23:59:59Z',
        label:      '✨ Big launch in',
      }],
    };
    
    // Insert — uses C.10's getData → mutate → load round-trip.
    document.getElementById('c12InsertBtn').addEventListener('click', () => {
      const data = builder.getData();
      data.blocks.unshift(HERO_COUNTDOWN_BLOCK);
      builder.load(data, themeTemplates, themeConfigData, mediaUrl);
    });
    
    // Reload — proves CountdownElement.parse(data) rehydrates byte-identically.
    document.getElementById('c12ReloadBtn').addEventListener('click', () => {
      const captured = builder.getData();  // includes any CountdownElement nodes
      builder.load(captured, themeTemplates, themeConfigData, mediaUrl);
    });
    
    // Bump — proves target_iso survives the JSON layer end-to-end.
    document.getElementById('c12BumpBtn').addEventListener('click', () => {
      const data = builder.getData();
      const walk = (block) => (block.elements || []).forEach((el) => {
        if (el.name === 'CountdownElement') {
          el.target_iso = new Date(Date.parse(el.target_iso) + 86_400_000).toISOString();
        }
      });
      (data.blocks || []).forEach(walk);
      builder.load(data, themeTemplates, themeConfigData, mediaUrl);
    });

Live demo

demo-mini-builder--rich-3col-header
Custom Element — register CountdownElement, save/load round-tripRegistered ✓

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 — Custom Element by subclassing `BaseElement` + registering
 * via the public `Builder.registerElement(name, klass)` API (CD-1).
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server. Three
 * toolbar buttons drive the contract end-to-end:
 *   • Insert countdown   → getData → unshift block w/ CountdownElement → load
 *   • Reload from JSON   → getData → load (proves parse round-trip)
 *   • Bump target +1 day → getData → mutate target_iso → load
 *
 * Class name `CountdownElement` + button IDs `c12InsertBtn` /
 * `c12ReloadBtn` / `c12BumpBtn` / `c12StatusPill` match the live demo on
 * /examples/extensions/2-custom-element/ 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>Custom Element — extend BaseElement, register, save/load round-trip</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; height: 28px; padding: 0 12px; font: inherit; font-size: 12px; font-weight: 600; border: 1px solid #d4d4d8; background: #fff; color: #18181b; border-radius: 6px; cursor: pointer; }
        .my-btn--primary { background: #4338ca; color: #fff; border-color: #4338ca; }
        .my-btn:hover { border-color: #4338ca; color: #4338ca; }
        .my-btn--primary: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; }
        /* .my-countdown styles travel INSIDE the EJS template body (see
         * COUNTDOWN_TEMPLATE below) — engine renders each block into its
         * own sandboxed iframe, so host page CSS doesn't cross. */
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Custom Element — CountdownElement</span>
    <button type="button" class="my-btn my-btn--primary" id="c12InsertBtn">Insert countdown</button>
    <button type="button" class="my-btn" id="c12ReloadBtn">Reload from JSON</button>
    <button type="button" class="my-btn" id="c12BumpBtn">Bump target +1 day</button>
    <span class="my-pill" id="c12StatusPill">Registered ✓</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) Subclass BaseElement — three required overrides (getData, static
    //    parse, _doRender) + a constructor that wires template + state.
    class CountdownElement extends BaseElement {
        constructor(template, targetIso, label) {
            super();
            this.template  = template  || 'CountdownTimer';
            this.targetIso = targetIso || '2026-12-31T23:59:59Z';
            this.label     = label     || '';
            this.domNode   = null;
            this.requiredTemplateKeys = ['target_iso', 'label'];
        }
        getName() { return 'Countdown'; }
        getData() {
            const base = super.getData();
            // Always emit a formats object — engine validator warns when
            // an element node lacks it; harmless but noisy in console logs.
            if (!base.formats) base.formats = {};
            return { ...base, target_iso: this.targetIso, label: this.label };
        }
        static parse(data) {
            const inst = new CountdownElement(data.template, data.target_iso, data.label);
            return BaseElement.parseFormats(inst, data);
        }
        _doRender() {
            this.domNode.innerHTML = this.renderTemplate({
                target_iso: this.targetIso,
                label:      this.label,
            });
        }
    }

    // 2) Register the class + inject the EJS template body. BOTH must happen
    //    BEFORE any builder.load() call that contains a CountdownElement node.
    // EJS template body. Required keys: target_iso + label. We deliberately
    // skip the formatter merge — CountdownElement has no Formatter, so
    // EJS's `with(locals)` would throw ReferenceError if the template
    // referenced `formatter`. Add a Formatter (mirror PElement.js) if you
    // want padding / background / border controls in the settings panel.
    //
    // The leading <style> block travels INSIDE the template body — engine
    // renders each block into its own sandboxed iframe, so host page CSS
    // does NOT cross the boundary. Visual rules live with the markup.
    const COUNTDOWN_TEMPLATE = `
<style>
  .my-countdown { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; color: #f8fafc; font-family: system-ui, -apple-system, sans-serif; }
  .my-countdown__label { font-size: 14px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; opacity: .85; }
  .my-countdown__row { display: flex; gap: 12px; justify-content: center; }
  .my-countdown__cell { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 64px; padding: 12px 8px; background: rgba(255,255,255,.08); border-radius: 8px; }
  .my-countdown__cell > span:first-child { font-size: 28px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
  .my-countdown__cell > span:last-child { font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; opacity: .7; }
</style>
<div class="my-countdown" data-bjs-countdown
     data-target-iso="<%- target_iso %>">
  <% if (label) { %><div class="my-countdown__label"><%- label %></div><% } %>
  <div class="my-countdown__row">
    <div class="my-countdown__cell"><span data-cd-d>—</span><span>days</span></div>
    <div class="my-countdown__cell"><span data-cd-h>—</span><span>hrs</span></div>
    <div class="my-countdown__cell"><span data-cd-m>—</span><span>min</span></div>
    <div class="my-countdown__cell"><span data-cd-s>—</span><span>sec</span></div>
  </div>
</div>`;

    Builder.registerElement('CountdownElement', CountdownElement);
    THEME_TEMPLATES.CountdownTimer = COUNTDOWN_TEMPLATE;  // survives every load()

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

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
        // builder.templates was reassigned by load() — it now points at the
        // SAME object as THEME_TEMPLATES so the CountdownTimer key we added
        // above is already there. No re-mutation needed.
    });

    // 3) Toolbar — buttons drive the contract end-to-end. Same getData →
    //    mutate → load round-trip from C.10; only the inserted node's name
    //    differs ('CountdownElement' instead of 'HeadingElement').
    const HERO_COUNTDOWN_BLOCK = {
        name: 'BlockElement', template: 'Block',
        formats: { background_color: '#0f172a', padding_top: 24, padding_bottom: 24 },
        elements: [{
            name:       'CountdownElement',
            template:   'CountdownTimer',
            formats:    {},  // engine validator wants every element node to carry formats
            target_iso: new Date(Date.now() + 7 * 86400000).toISOString(),
            label:      '✨ Big launch in',
        }],
    };

    const reload = (data) => builder.load(data, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);

    document.getElementById('c12InsertBtn').addEventListener('click', () => {
        const data = builder.getData();
        data.blocks = data.blocks || [];
        data.blocks.unshift(JSON.parse(JSON.stringify(HERO_COUNTDOWN_BLOCK)));
        reload(data);
        updateStatus();
    });
    document.getElementById('c12ReloadBtn').addEventListener('click', () => {
        reload(builder.getData());
        updateStatus();
    });
    document.getElementById('c12BumpBtn').addEventListener('click', () => {
        const data = builder.getData();
        (data.blocks || []).forEach((b) => {
            (b.elements || []).forEach((el) => {
                if (el && el.name === 'CountdownElement') {
                    const ms = Date.parse(el.target_iso) || Date.now();
                    el.target_iso = new Date(ms + 86400000).toISOString();
                }
            });
        });
        reload(data);
        updateStatus();
    });

    // walkCountdowns — yields every [data-bjs-countdown] node across the
    // top-level document AND every same-origin iframe canvas. The engine
    // renders each block into its own sandboxed <iframe> for style
    // isolation; without the iframe walk, top-level document.querySelector
    // misses every rendered countdown.
    function walkCountdowns(cb) {
        document.querySelectorAll('[data-bjs-countdown]').forEach(cb);
        document.querySelectorAll('iframe').forEach((frame) => {
            try {
                const doc = frame.contentDocument;
                if (!doc) return;
                doc.querySelectorAll('[data-bjs-countdown]').forEach(cb);
            } catch (e) { /* cross-origin frame; ignore */ }
        });
    }

    function updateStatus() {
        let count = 0; walkCountdowns(() => { count++; });
        const pill = document.getElementById('c12StatusPill');
        if (!pill) return;
        pill.textContent = count > 0 ? `Registered ✓ · ${count} on canvas` : 'Registered ✓';
    }

    // 4) Tick loop — single page-level interval walks every countdown node
    //    across top-document + iframes. Survives every re-render because
    //    it runs OUTSIDE the engine cycle.
    setInterval(() => {
        walkCountdowns((root) => {
            const iso = root.getAttribute('data-target-iso');
            const ms  = Math.max(0, (Date.parse(iso) || Date.now()) - Date.now());
            const d   = Math.floor(ms / 86400000);
            const h   = Math.floor((ms % 86400000) / 3600000);
            const m   = Math.floor((ms % 3600000) / 60000);
            const s   = Math.floor((ms % 60000) / 1000);
            const put = (sel, n) => {
                const node = root.querySelector(sel);
                if (node) node.textContent = String(n).padStart(2, '0');
            };
            put('[data-cd-d]', d); put('[data-cd-h]', h); put('[data-cd-m]', m); put('[data-cd-s]', s);
        });
        updateStatus();
    }, 1000);
</script>

</body>
</html>

Notes

Why register BEFORE round-trip load()? Every load(themeJson, themeTemplates, ...) call routes through PageElement.parse(data) which walks the JSON and calls ElementFactory.createElement(node) per node. createElement looks up node.name in ELEMENT_REGISTRY — if your custom element isn\'t registered yet, the factory throws Unknown element name: CountdownElement synchronously. Register at boot, NEVER inside a per-element render path.

Why mutate builder.templates AND themeTemplates? The engine reads templates via this.host.getTemplate(name) which hits builder.templates[name]. Mutating builder.templates directly makes the template available NOW. But load() reassigns this.templates = themeTemplates wholesale (src/includes/Builder.js line 796) — so the next round-trip rebinds from the bundle. Mutate BOTH to survive every cycle, OR maintain a single source of truth (e.g. server-side bundle) and pass it through load() consistently.

What about custom controls in the settings panel? Override getControls() on your subclass and return an array of Control instances (TextControl, NumberControl, ColorPickerControl, AlignControl, etc.). The engine wires them automatically when the element is selected — buyer typing into a control fires its setValue callback, which mutates the element field + calls this.render() (or this.applyFormatStyles() for format-only updates). See src/includes/PElement.js for the canonical multi-control panel pattern, and extensions/3-custom-control/ (W6.C.13, ships next) for a SUBCLASS-an-existing-control walkthrough.

Inline editing? Add a marker attribute to your template (e.g. inline-edit="label") and call this.registerInlineEdit(\'label\') in the constructor. BaseElement.afterRender() auto-wires contenteditable + the input listener (TEXT_INLINE_PLAN W0.2b). Field name passed to registerInlineEdit MUST match a property on the instance — the listener writes node.innerHTML back to this.label on every keystroke.

Re-registering replaces. Builder.registerElement(name, NewKlass) overwrites a prior entry with the same name (stock or buyer). Useful for hot-reload during development; risky in production unless you own both classes — existing JSON nodes will rehydrate through the NEW class\'s parse() on the next load(), so any field shape change is a breaking change to saved documents.

Engine-globals contract. dist/builder.js exposes window.BaseElement + window.Builder + the full element catalogue (see src/builder.js lines 261-401). ESM consumers can import { Builder, BaseElement } from \'builderjs\' instead — same surface, no globals. Spec assertion (extensions spec, kind element-registration) verifies both globals exist after dist/builder.js evaluates.