API · 1 of 5

getHtml() + getHtmlWithRelativeLinks() — HTML export

Two flavours of HTML export from one engine surface. getHtml() returns the canvas with absolute media URLs (themeMediaUrl prefix included); getHtmlWithRelativeLinks() strips that prefix so the saved HTML is portable across domains. Side-by-side preview + Download .html link.

Walkthrough

  1. Two methods, two use cases — pick the one that matches your storage

    builder.getHtml() returns the rendered canvas HTML with all media URLs in absolute form (e.g. https://app.example.com/p/templates/000abc/assets/profile_image/avatar.png). Use this when the consumer of the HTML lives on the SAME domain as the editor — preview iframes, email-client renderers that resolve URLs at compose-time, server-side render pipelines.

    builder.getHtmlWithRelativeLinks() strips the themeMediaUrl prefix so the asset paths come out relative (e.g. assets/profile_image/avatar.png). Use this when the saved HTML will be served from a DIFFERENT domain or moved between deployments. Cheaper to migrate, no rewrite step.

    Both methods are pure reads — no side effects, no engine state mutation. Call them anywhere your code path makes sense.

    JavaScript
    // Absolute URLs — keep when consumer is same-domain.
    const absoluteHtml = builder.getHtml();
    // → '<html lang="en">…<img src="https://app.example.com/p/templates/000abc/assets/profile_image/avatar.png">…</html>'
    
    // Relative URLs — strip themeMediaUrl. Use when storing for portable serve.
    const relativeHtml = builder.getHtmlWithRelativeLinks();
    // → '<html lang="en">…<img src="assets/profile_image/avatar.png">…</html>'
    
    // Both methods are pure reads. Call as often as you like;
    // no debounce needed (typical full-page export is < 5 ms).
  2. Wire an external Export button + render the output in a side panel

    The host owns the export trigger + the output surface. The live demo above carries TWO buttons: #c6ExportBtn calls getHtml(); #c6ExportRelativeBtn calls getHtmlWithRelativeLinks(). Output renders in an inline <pre> below the canvas — same panel, different button. Clear button empties it.

    For an in-place buyer preview (see what the saved HTML actually looks like) wrap the output in an iframe[srcdoc] instead of a <pre>. Either pattern works; the <pre> form is easier to copy/paste and read line-by-line.

    JavaScript
    const outputPane = document.getElementById('c6OutputPane');
    
    document.getElementById('c6ExportBtn').addEventListener('click', () => {
      const html = builder.getHtml();
      outputPane.textContent = html;     // textContent — escapes HTML for read
      outputPane.dataset.flavour = 'absolute';
    });
    
    document.getElementById('c6ExportRelativeBtn').addEventListener('click', () => {
      const html = builder.getHtmlWithRelativeLinks();
      outputPane.textContent = html;
      outputPane.dataset.flavour = 'relative';
    });
    
    document.getElementById('c6ClearBtn').addEventListener('click', () => {
      outputPane.textContent = '';
      outputPane.dataset.flavour = '';
    });
  3. Add a "Download .html" link — Blob → object URL pattern

    The polished pattern: Export button updates the on-page preview AND a Download .html link that downloads the most recently exported HTML on click. Use the Blob + URL.createObjectURL() pattern — no server round-trip, the browser writes the file directly.

    Important: revoke the previous object URL each time you create a new one (URL.revokeObjectURL(prevUrl)). Without this, every export leaks ~50KB-2MB of memory until the page reloads. The cookbook 4-rich-3col-header Export button uses the same pattern internally.

    JavaScript
    let lastBlobUrl = null;
    const downloadLink = document.getElementById('c6DownloadLink');
    
    function refreshDownloadLink(html, suffix) {
      // Revoke the previous URL — Blob URLs leak memory until revoked.
      if (lastBlobUrl) URL.revokeObjectURL(lastBlobUrl);
      const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
      lastBlobUrl = URL.createObjectURL(blob);
      downloadLink.href = lastBlobUrl;
      downloadLink.download = `export-${suffix}.html`;   // browser uses this filename
      downloadLink.removeAttribute('hidden');
    }
    
    // Wire into both Export buttons (same pattern, different flavour suffix).
    document.getElementById('c6ExportBtn').addEventListener('click', () => {
      refreshDownloadLink(builder.getHtml(), 'absolute');
    });
    document.getElementById('c6ExportRelativeBtn').addEventListener('click', () => {
      refreshDownloadLink(builder.getHtmlWithRelativeLinks(), 'relative');
    });

Live demo

demo-mini-builder--rich-3col-header
getHtml export — absolute vs relative URLs

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 — `getHtml()` + `getHtmlWithRelativeLinks()` HTML export
 * with side preview pane + Download .html link.
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server.
 * Click "Export (absolute)" → preview pane shows HTML with absolute
 * media URLs. Click "Export (relative)" → same HTML but with the
 * themeMediaUrl prefix stripped. Click ↓ Download to save as .html.
 *
 * IDs `c6ExportBtn` / `c6ExportRelativeBtn` / `c6ClearBtn` /
 * `c6OutputPane` / `c6DownloadLink` match the live demo on
 * /examples/api/1-get-html-export/ AND Steps 1-3 of the walkthrough
 * (BUILDER.md RULE H, D13.B.parity).
 *
 * NOTE: the W6 plan called this `replaceImageURLs()` but no such
 * method exists on the engine. The actual sibling to `getHtml()`
 * is `getHtmlWithRelativeLinks()`. See D13.C.6 for the
 * plan-correction rationale.
 */
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>getHtml + getHtmlWithRelativeLinks — HTML export</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 240px; height: 100vh; }
        .my-header  { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
        .my-btn { padding: 6px 12px; background: #fff; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; }
        .my-btn:hover { background: #f3f4f6; }
        .my-divider { width: 1px; height: 16px; background: #e5e7eb; }

        #c6DownloadLink { font-size: 12px; color: #4338ca; text-decoration: none; padding: 6px 10px; border-radius: 6px; }
        #c6DownloadLink:hover { background: #e0e7ff; }
        #c6DownloadLink[hidden] { display: none; }

        .my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; }
        #MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
        #MyCanvas { overflow: auto; }

        .my-output { display: flex; flex-direction: column; border-top: 1px solid #e5e7eb; background: #0f172a; color: #e2e8f0; min-height: 0; }
        .my-output__header { display: flex; align-items: center; padding: 8px 16px; background: rgba(255,255,255,0.06); border-bottom: 1px solid rgba(255,255,255,0.08); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: #94a3b8; }
        .my-output__flavour { margin-left: auto; padding: 2px 8px; border-radius: 999px; font-size: 10px; }
        .my-output__flavour[data-flavour="absolute"] { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }
        .my-output__flavour[data-flavour="relative"] { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
        .my-output__flavour:empty { display: none; }
        #c6OutputPane { flex: 1; margin: 0; padding: 12px 16px; overflow: auto; font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; }
        #c6OutputPane:empty::before { content: 'Click an Export button above. Output renders here.'; color: #475569; font-style: italic; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">getHtml export — absolute vs relative URLs</span>
    <button type="button" id="c6ExportBtn"         class="my-btn">Export (absolute)</button>
    <button type="button" id="c6ExportRelativeBtn" class="my-btn">Export (relative)</button>
    <span class="my-divider"></span>
    <a id="c6DownloadLink" href="#" download="export.html" hidden>↓ Download</a>
    <button type="button" id="c6ClearBtn"          class="my-btn">Clear</button>
</header>

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

<aside class="my-output" aria-labelledby="my-output-label">
    <header class="my-output__header">
        <span id="my-output-label">Exported HTML preview</span>
        <span class="my-output__flavour" data-flavour=""></span>
    </header>
    <pre id="c6OutputPane" aria-live="polite"></pre>
</aside>

<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>
    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    const outputPane   = document.getElementById('c6OutputPane');
    const flavourPill  = document.querySelector('.my-output__flavour');
    const downloadLink = document.getElementById('c6DownloadLink');

    let lastBlobUrl = null;

    function refreshDownloadLink(html, suffix) {
        // Revoke the previous URL — Blob URLs leak memory until revoked.
        if (lastBlobUrl) URL.revokeObjectURL(lastBlobUrl);
        const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
        lastBlobUrl = URL.createObjectURL(blob);
        downloadLink.href = lastBlobUrl;
        downloadLink.download = `export-${suffix}.html`;
        downloadLink.removeAttribute('hidden');
    }

    function renderTo(html, flavour) {
        outputPane.textContent = html;
        outputPane.dataset.flavour = flavour;
        flavourPill.dataset.flavour = flavour;
        flavourPill.textContent = flavour ? flavour.toUpperCase() : '';
    }

    document.getElementById('c6ExportBtn').addEventListener('click', () => {
        const html = builder.getHtml();
        renderTo(html, 'absolute');
        refreshDownloadLink(html, 'absolute');
    });

    document.getElementById('c6ExportRelativeBtn').addEventListener('click', () => {
        const html = builder.getHtmlWithRelativeLinks();
        renderTo(html, 'relative');
        refreshDownloadLink(html, 'relative');
    });

    document.getElementById('c6ClearBtn').addEventListener('click', () => {
        outputPane.textContent = '';
        outputPane.dataset.flavour = '';
        flavourPill.dataset.flavour = '';
        flavourPill.textContent = '';
        downloadLink.setAttribute('hidden', '');
        if (lastBlobUrl) {
            URL.revokeObjectURL(lastBlobUrl);
            lastBlobUrl = null;
        }
    });

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>

</body>
</html>

Notes

Why two flavours and not one? The decision is about the URL space the saved HTML will live in. If you serve from the same domain as the editor (preview iframe, in-tab printing), absolute URLs are fine — the browser resolves them with no extra work. If you save to a database and serve later from a CDN with a different prefix, relative URLs are mandatory — otherwise every export carries a hardcoded URL that breaks on migration.

What's actually stripped? getHtmlWithRelativeLinks() does ONE substitution: html.replace(new RegExp(themeMediaUrl + '/', 'g'), ''). So the absolute prefix you passed to builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL) as MEDIA_URL gets removed wherever it appears as a URL prefix. Everything else (CSS, inline scripts, custom attributes) stays byte-identical.

Pair with the inverse on load. If you stored relative URLs via getHtmlWithRelativeLinks(), your next builder.load() call passes the new MEDIA_URL as the 4th argument — the engine's internal transferMediaAbsUrl() prepends it to every relative path in the saved JSON before painting. So load + save are symmetrical: relative on disk, absolute in the editor, relative again when exported.

Blob URL leak warning. Every URL.createObjectURL(blob) retains the Blob in memory until the document unloads OR you call URL.revokeObjectURL(url). Long-lived editor sessions that export 50× without revoking can leak 5-100 MB. The walkthrough's `refreshDownloadLink` helper revokes the previous URL on each new export — same pattern in the live demo above and in snippet.php.

Plan-correction note. The original W6 plan paired getHtml() with replaceImageURLs() — but replaceImageURLs() does NOT exist on the engine. The actual sibling is getHtmlWithRelativeLinks(). This is the 5th plan-vs-reality gap surfaced in Phase C (joining 4 in the events tier). Discipline locked in PIPELINE.md: every Phase C row author greps the engine API surface in src/ BEFORE writing the walkthrough.