Flows · 1 of 3

Export HTML / ZIP / Bundle — three modes, one engine read

One pair of engine reads — `getHtml()` (absolute URLs) + `getHtmlWithRelativeLinks()` (relative URLs) — feeds three backend endpoints that ship three different artefacts. HTML for in-editor consumers, ZIP for cross-domain hosting, Bundle for cross-tenant migration. The buyer wires three buttons; the engine + backend do the rest.

Walkthrough

  1. Three artefacts, one engine read pair — pick the mode that matches your downstream

    HTML ships a single rendered page. The consumer keeps fetching images from the editor's themeMediaUrl (absolute URLs). Cheapest / fastest — no zip step, no asset bundling. Use when the file lives on the SAME domain that hosts the editor (preview iframes, same-origin email-client renderers).

    ZIP ships a self-contained portable email: ./index.html + only the asset files the HTML actually references (img src/srcset, background-image: url(), <link href>, …). The backend parses the HTML, walks every relative URL, and packs the matching file from the theme dir at the same relative path so the archive resolves correctly out of the box. Use when the consumer hosts the file from a different domain (CDN, S3 bucket, internal portal). Absolute URLs (data: / external http(s)://) are left untouched.

    Bundle is ZIP plus the sample's source files (./index.json + ./thumb.*) — re-importable into another tenant's theme registry. Use for cross-tenant migration ("export tenant A's page from staging, import into tenant B's production"), archival, or hand-off to another team that wants to extend the sample further.

    JavaScript
    // Same pair of engine reads — both methods are pure, no engine state
    // mutation. Call as often as you like; typical full-page export is <5ms.
    const absoluteHtml = builder.getHtml();              // C.6 — keep media URLs absolute
    const relativeHtml = builder.getHtmlWithRelativeLinks(); // C.6 — strip themeMediaUrl prefix
    
    // Mode → endpoint → payload shape:
    //
    //   HTML mode   → POST /backend/export-html.php
    //                 body = { html: absoluteHtml }
    //                 → response: text/html attachment, filename=builder-export.html
    //
    //   ZIP mode    → POST /backend/export-zip.php
    //                 body = { themeUrl, dir, sample, html: relativeHtml, type: 'zip' }
    //                 → response: application/zip attachment, filename=<dir>.zip
    //                 → archive: ./index.html + only assets the HTML references
    //
    //   Bundle mode → POST /backend/export-bundle.php  (zip with type=bundle)
    //                 body = { themeUrl, dir, sample, html: relativeHtml }
    //                 → response: application/zip attachment, filename=<dir>_bundle.zip
    //                 → archive: ZIP + ./index.json + ./thumb.*
    //
    // Payload fields:
    //   themeUrl  absolute prefix the editor used at load time (4th arg to
    //             builder.load). Backend strips it to write relative paths.
    //   dir       theme directory name (e.g. 'default') — regex-validated
    //             server-side as `[A-Za-z0-9_-]+`.
    //   sample    sample path inside the theme (e.g. 'master/sample/email/
    //             Minimal', no extension). Required for bundle to locate
    //             index.json + thumb.*; helpful in zip for scoping.
  2. Wire the buttons — fetch POST → Blob → click pattern

    The host owns the export trigger + the download surface. Three buttons, one helper. The helper builds a FormData body (matches the backend's application/x-www-form-urlencoded contract — both are wire-compatible because FormData serializes the same key/value pairs), POSTs it, reads the response as a Blob, creates an object URL, and triggers a download by clicking an off-screen <a download>.

    Why fetch + Blob and not a plain <form action="POST">? A real form POST navigates AWAY from the editor — the buyer loses unsaved state. fetch keeps the editor mounted, lets the host show a status pill ("Exporting…" → "Downloaded HTML 12 KB"), and supports custom headers (auth tokens, tenant scoping) without touching the form HTML.

    Why an off-screen anchor and not window.location = url? Anchor-click respects the response's Content-Disposition: attachment; filename=… header, so the file lands in the buyer's Downloads folder with the backend-supplied name. Setting location works in some browsers but races with the next render and sometimes navigates the page instead.

    JavaScript
    const statusPill = document.getElementById('c15StatusPill');
    
    function setStatus(text, tone) {
      // tone: '' | 'busy' | 'success' | 'error'
      statusPill.textContent = text;
      statusPill.dataset.tone = tone || '';
    }
    
    async function exportTo(endpoint, payload, suggestedFilename) {
      setStatus('Exporting…', 'busy');
    
      const body = new FormData();
      for (const [k, v] of Object.entries(payload)) body.append(k, v);
    
      let response;
      try {
        response = await fetch(endpoint, { method: 'POST', body });
      } catch (networkErr) {
        setStatus('Export failed (network)', 'error');
        return;
      }
    
      if (!response.ok) {
        setStatus('Export failed: ' + response.status, 'error');
        return;
      }
    
      // Honour the backend's filename if present; fall back to the caller hint.
      const cd = response.headers.get('Content-Disposition') || '';
      const match = /filename="?([^"]+)"?/.exec(cd);
      const filename = match ? match[1] : suggestedFilename;
    
      const blob = await response.blob();
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href     = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      // Free the URL after the click event has dispatched.
      setTimeout(() => URL.revokeObjectURL(url), 0);
    
      const sizeKb = (blob.size / 1024).toFixed(1);
      setStatus(`Downloaded ${filename} (${sizeKb} KB)`, 'success');
    }
    
    // HTML mode — absolute URLs (the consumer will fetch from the editor's media URL).
    document.getElementById('c15ExportHtmlBtn').addEventListener('click', () => {
      exportTo('/backend/export-html.php',
               { html: builder.getHtml() },
               'builder-export.html');
    });
    
    // ZIP mode — backend extracts asset refs from the HTML and packs only those.
    document.getElementById('c15ExportZipBtn').addEventListener('click', () => {
      exportTo('/backend/export-zip.php',
               {
                 html:     builder.getHtmlWithRelativeLinks(),
                 themeUrl: bundle.mediaUrl,
                 dir:      bundle.dir,
                 sample:   bundle.sample,    // scopes bundle's index.json/thumb lookup
                 type:     'zip',
               },
               bundle.dir + '.zip');
    });
    
    // Bundle mode — adds the sample's source files (index.json + thumb.*) for re-import.
    document.getElementById('c15ExportBundleBtn').addEventListener('click', () => {
      exportTo('/backend/export-bundle.php',
               {
                 html:     builder.getHtmlWithRelativeLinks(),
                 themeUrl: bundle.mediaUrl,
                 dir:      bundle.dir,
                 sample:   bundle.sample,
               },
               bundle.dir + '_bundle.zip');
    });
  3. Production wiring — auth, quotas, async hand-off, telemetry

    The shipped backend handlers (demo/backend/export-{html,zip,bundle}.php) are buyer-package-ready as stubs — they validate input via _lib/Validator.php, contain path traversal against the themes root, set Cache-Control: no-store, and stream the artefact back. Five wiring decisions are typically per-deployment:

    (1) Auth. Add a session check at the top of each handler — re-use whatever your app uses (session_start() + $_SESSION['user_id'], JWT bearer in Authorization header, etc.). The fetch helper above sends cookies by default; add credentials: 'same-origin' explicitly if you have any nginx rewrite that strips them.

    (2) Quotas. Bundle exports ship 1.5-3× the ZIP size (full theme source). For paid tiers wire the bundle endpoint to a distinct IAM role / rate-limit bucket — demo/backend/export-bundle.php is intentionally a separate file (not a query param) so reverse proxies can apply different rules without parsing the body.

    (3) Async hand-off. Multi-MB exports (image-heavy pages, multi-language packs) block the request. The drop-in async pattern: handler POSTs a job into Redis / a queue, returns 202 + a jobId; the host polls /backend/export-status.php?jobId=… with exponential backoff; the worker writes the artefact to S3 and the status endpoint hands back a presigned download URL. Same fetch pattern from the host — just an extra polling loop.

    (4) Server-side rewriting. If the saved HTML must reference a CDN (not the editor's media URL), insert a str_replace step on $html in the relevant handler before streaming. Keep editor-time and export-time URLs decoupled — the editor cares about loading speed (closest CDN POP), exports care about portability (absolute paths or fully-qualified CDN paths).

    (5) Telemetry. Log the (user_id, mode, dir, kb, ms) tuple on every successful export. This single dataset answers most product questions ("which themes export most often?", "do bundle users churn higher?", "is the slow-export complaint really slow on the server or just slow to download?").

    PHP
    // Auth + quota-bucket sketch for export-bundle.php.
    // Drop in alongside the existing handler body — both helpers stay
    // tiny so reviewers (and Envato) read the security path top-down.
    
    declare(strict_types=1);
    
    require_once __DIR__ . '/_lib/Validator.php';
    require_once __DIR__ . '/_lib/JsonResponse.php';
    
    use DemoBuilder\Validator;
    use DemoBuilder\JsonResponse;
    
    session_start();
    if (empty($_SESSION['user_id'])) {
        JsonResponse::error('Unauthenticated', 401);
    }
    
    // Per-tier quota — Pro keeps 50 bundle exports/hour; Free gets 5.
    $tier   = $_SESSION['plan'] ?? 'free';
    $limit  = $tier === 'pro' ? 50 : 5;
    $bucket = 'export-bundle:' . $_SESSION['user_id'];
    // quotaCheck() is your app's existing rate limiter (Redis / DynamoDB / etc.)
    if (!quotaCheck($bucket, $limit, 3600)) {
        JsonResponse::error('Bundle quota exceeded for ' . $tier . ' plan', 429);
    }
    
    // Telemetry — append-only, fire-and-forget. Keeps the request hot path lean.
    $start = microtime(true);
    register_shutdown_function(function () use ($start) {
        $ms = (int) ((microtime(true) - $start) * 1000);
        error_log("export-bundle ms=$ms tier=" . ($_SESSION['plan'] ?? 'free'));
    });
    
    // Defer to the canonical handler — payload validation + zip assembly
    // + streaming response stay in one file so the security review surface
    // for path traversal stays small.
    $_POST['type'] = 'bundle';
    require __DIR__ . '/export-zip.php';

Live demo

demo-mini-builder--rich-3col-header
Export — HTML / ZIP / BundleIdle

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 — three export modes (HTML / ZIP / Bundle) wired to
 * the demo backend handlers. One engine read pair, three buttons,
 * three artefacts.
 *
 * Drop alongside `dist/` + `themes/default/` + `backend/`, run a
 * PHP server. Click HTML → single .html download (absolute media
 * URLs). Click ZIP → .zip with HTML + theme assets (relative URLs).
 * Click Bundle → .bundle.zip with full theme source.
 *
 * IDs `c15ExportHtmlBtn` / `c15ExportZipBtn` / `c15ExportBundleBtn`
 * / `c15StatusPill` match the live demo on
 * /examples/flows/1-export-html-zip-bundle/ AND Steps 1-3 of the
 * walkthrough (BUILDER.md RULE H, D13.B.parity).
 *
 * In a buyer app the four `THEME_*` globals come from the same
 * `ThemeRegistry::resolveBundle()` round-trip the demo uses (or
 * whatever your app keeps the saved page in).
 */
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>Export HTML / ZIP / Bundle</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; }
        .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-btn:disabled { opacity: 0.5; cursor: not-allowed; }

        #c15StatusPill { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #f4f4f5; color: #71717a; font-weight: 600; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        #c15StatusPill[data-tone="busy"]    { background: #e0e7ff; color: #4338ca; }
        #c15StatusPill[data-tone="success"] { background: #dcfce7; color: #15803d; }
        #c15StatusPill[data-tone="error"]   { background: #fee2e2; color: #b91c1c; }

        .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; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Export — HTML / ZIP / Bundle</span>
    <button type="button" id="c15ExportHtmlBtn"   class="my-btn">HTML</button>
    <button type="button" id="c15ExportZipBtn"    class="my-btn">ZIP</button>
    <button type="button" id="c15ExportBundleBtn" class="my-btn">Bundle</button>
    <span id="c15StatusPill" data-tone="" aria-live="polite">Idle</span>
</header>

<div class="my-builder-host">
    <div id="MyWidgets"></div>
    <div id="MyCanvas"></div>
    <div id="MySettings"></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) ?>;
    window.THEME_DIR         = <?= json_encode($bundle->dir,            JSON_UNESCAPED_SLASHES) ?>;
    // `THEME_SAMPLE` (e.g. "master/sample/email/Minimal") tells the export
    // backend which sample's source files (index.json + thumb.*) to include
    // in `bundle` mode + scopes asset extraction in `zip` mode.
    window.THEME_SAMPLE      = <?= json_encode($bundle->sample,         JSON_UNESCAPED_SLASHES) ?>;
</script>

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

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

    const statusPill = document.getElementById('c15StatusPill');

    function setStatus(text, tone) {
        statusPill.textContent = text;
        statusPill.dataset.tone = tone || '';
    }

    function setBusy(busy) {
        document.getElementById('c15ExportHtmlBtn').disabled   = busy;
        document.getElementById('c15ExportZipBtn').disabled    = busy;
        document.getElementById('c15ExportBundleBtn').disabled = busy;
    }

    async function exportTo(endpoint, payload, suggestedFilename) {
        setStatus('Exporting…', 'busy');
        setBusy(true);

        try {
            const body = new FormData();
            for (const [k, v] of Object.entries(payload)) body.append(k, v);

            const response = await fetch(endpoint, { method: 'POST', body });
            if (!response.ok) throw new Error('HTTP ' + response.status);

            const cd = response.headers.get('Content-Disposition') || '';
            const match = /filename="?([^"]+)"?/.exec(cd);
            const filename = match ? match[1] : suggestedFilename;

            const blob = await response.blob();
            const url  = URL.createObjectURL(blob);
            const a    = document.createElement('a');
            a.href     = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            setTimeout(() => URL.revokeObjectURL(url), 0);

            const sizeKb = (blob.size / 1024).toFixed(1);
            setStatus(`Downloaded ${filename} (${sizeKb} KB)`, 'success');
        } catch (err) {
            setStatus('Export failed: ' + err.message, 'error');
        } finally {
            setBusy(false);
        }
    }

    // HTML mode — absolute URLs (consumer fetches images from the editor's media URL).
    document.getElementById('c15ExportHtmlBtn').addEventListener('click', () => {
        exportTo('/backend/export-html.php',
                 { html: builder.getHtml() },
                 'builder-export.html');
    });

    // ZIP mode — backend parses the rendered HTML for every relative asset
    // reference (img src + srcset, background-image url(...), link href,
    // etc.) and packs ONLY those files alongside index.html. Self-contained
    // portable email; unzip → open index.html → renders out of the box.
    document.getElementById('c15ExportZipBtn').addEventListener('click', () => {
        exportTo('/backend/export-zip.php',
                 {
                     html:     builder.getHtmlWithRelativeLinks(),
                     themeUrl: MEDIA_URL,
                     dir:      THEME_DIR,
                     sample:   THEME_SAMPLE,       // scopes asset lookup + bundle's index.json/thumb
                     type:     'zip',
                 },
                 THEME_DIR + '.zip');
    });

    // Bundle mode — same as ZIP plus sample's source files (./index.json +
    // ./thumb.*). Re-importable into another tenant's theme registry.
    document.getElementById('c15ExportBundleBtn').addEventListener('click', () => {
        exportTo('/backend/export-bundle.php',
                 {
                     html:     builder.getHtmlWithRelativeLinks(),
                     themeUrl: MEDIA_URL,
                     dir:      THEME_DIR,
                     sample:   THEME_SAMPLE,
                 },
                 THEME_DIR + '_bundle.zip');
    });

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

</body>
</html>

Notes

Why three endpoints and not one with a ?mode=… query? Two reasons: (1) reverse proxies / WAFs apply rules per URL path, not per query string — separate endpoints let you scope auth + rate-limits + IAM roles cleanly without parsing the body; (2) bundle exports are typically larger + slower + more privileged than HTML exports, so they belong in a different bucket. Same pattern Stripe / GitHub / S3 use for download surfaces.

Why pass themeUrl from the client and not look it up server-side? The handler doesn't know which media URL the editor used — your app may host the same theme dir under multiple URLs (per-tenant, staging vs prod, CDN regions). Whatever URL was the 4th arg to builder.load(…, MEDIA_URL) is what the rendered HTML carries; that's what needs stripping. Sending it from the client keeps the handler stateless.

Path traversal containment. The dir parameter is the only filesystem-touching input. export-zip.php validates it against /^[A-Za-z0-9_-]+$/ THEN resolves it against themesRoot THEN checks realpath() stays below the root. Three layers; any one would catch the obvious attack but the stack is what the security review wants. The same pattern lives in theme-upload.php + asset-upload.php — see W2 for the full _lib/Validator audit.

The mediaUrl/dir lookup pattern in the live demo. The mini-builder partial emits cfg.bundle in scope inside the boot IIFE — that object carries bundle.mediaUrl + bundle.dir + bundle.themeJson etc. (see demo/_partials/example.php line 487). The initJs closure reads bundle.mediaUrl + bundle.dir directly. In a buyer's app the same values come from window.MEDIA_URL + window.THEME_CONFIG_DATA.dir after the snippet's PHP block emits the four named globals (BUILDER.md RULE I split-globals).

Status-pill semantics. Idle → Busy (data-tone="busy", accent-soft bg) → Success / Error (success-soft / error-soft bg). Buttons disable during the in-flight request so a triple-click doesn't queue three exports. aria-live="polite" on the pill so screen-readers announce state changes without interrupting current speech. Keep the pill copy short — long labels overflow on mobile (see the 280px max-width clamp).

Memory hygiene. Same Blob-URL revocation rule as C.6 — every URL.createObjectURL(blob) retains the Blob until the document unloads OR you call URL.revokeObjectURL(url). The helper revokes on a setTimeout(0) AFTER the click dispatches, which is the canonical timing — revoking BEFORE click sometimes cancels the download in older browsers.

Phase C flows tier opens here. Next: flows/2-import-bundle/ (W6.C.16) — drag-drop a .bundle.zip the buyer just exported and re-hydrate the canvas from it; flows/3-history-keyboard-shortcuts/ (W6.C.17) — chord ladder (Cmd-Z / Cmd-Shift-Z / etc.) + max-history stepper, closes Phase C. Then Phase D multi-tenant cookbook + Phase F.B story layers + F.C 6-round audit close the wave.