<?php
/**
* snippet.php — inline-CSS email export, end to end.
*
* Drop alongside `dist/` + `themes/default/` + `backend/`, run a PHP
* server, open this page. Click "Inline CSS" → the rendered HTML is
* POSTed to /backend/export-inline.php, every <head><style> rule is
* promoted onto each element's inline style="", and the email-ready
* file downloads as email-inlined.html.
*
* The single moving part is /backend/export-inline.php +
* /backend/_lib/CssInliner.php (zero dependencies). In a buyer app the
* four THEME_* globals come from the same ThemeRegistry::resolveBundle()
* round-trip the demo uses (or wherever your app stores the saved page).
*
* Matches the live demo on /examples/flows/4-inline-css-email/ (IDs
* inlineBtn / inlineStatus) and the topnav Export ▸ Inline CSS action.
*/
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>Inline CSS for email</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 { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: #4338ca; border: 1px solid #4338ca; color: #fff; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; font-weight: 600; }
.my-btn:hover { filter: brightness(1.08); }
.my-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#inlineStatus { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #f4f4f5; color: #71717a; font-weight: 600; max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#inlineStatus[data-tone="busy"] { background: #e0e7ff; color: #4338ca; }
#inlineStatus[data-tone="success"] { background: #dcfce7; color: #15803d; }
#inlineStatus[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">Inline CSS — email-ready export</span>
<button type="button" id="inlineBtn" class="my-btn">
<span class="material-symbols-rounded" aria-hidden="true" style="font-size:16px;">mail</span>
Inline CSS
</button>
<span id="inlineStatus" 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) ?>;
</script>
<script src="/dist/builder.js"></script>
<script>
const builder = new Builder({
mainContainer: '#MyCanvas',
widgetsContainer: '#MyWidgets',
settingsContainer: '#MySettings',
});
const btn = document.getElementById('inlineBtn');
const pill = document.getElementById('inlineStatus');
function setStatus(text, tone) {
pill.textContent = text;
pill.dataset.tone = tone || '';
}
btn.addEventListener('click', async () => {
setStatus('Inlining…', 'busy');
btn.disabled = true;
try {
const body = new FormData();
// Absolute media URLs — email clients fetch images from wherever
// they're hosted, so the plain getHtml() flavour is what you want.
body.append('html', builder.getHtml());
body.append('format', 'json'); // {html, stats} so we can show numbers + download
const res = await fetch('/backend/export-inline.php', { method: 'POST', body });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
setStatus(
`✓ ${data.inlinedRules} rules inlined · ` +
`${data.inlinedDeclarations} decls · ${data.keptRules} kept (media/hover)`,
'success'
);
// Download the inlined HTML from the same response.
const url = URL.createObjectURL(new Blob([data.html], { type: 'text/html' }));
const a = document.createElement('a');
a.href = url;
a.download = 'email-inlined.html';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
} catch (err) {
setStatus('Failed: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
});
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>
</body>
</html>
Inline CSS for email — ship HTML that survives Gmail & Outlook
Email clients strip `<head><style>`. One server call promotes every `<style>` rule onto each element's inline `style=""`, so your rendered page lands in the inbox looking exactly like the editor — no broken buttons, no naked text. Click once in the demo below; the whole transform is ~300 lines of dependency-free PHP you own.
Walkthrough
-
The problem — `getHtml()` ships two style layers, and some targets keep only one
BuilderJS's
getHtml()already emits two layers of styling:(1) Element-level
style=""— the Formatter writes an inline style attribute for every visual property the author set (padding, colour, border, font). These are already inline and survive everywhere.(2) A
<head><style>block — the theme/template CSS: class rules, container-width constraints ([builder-element="BlockElement"]), positional rules (:first-child,:not(:last-child)),:hover, and@mediaresponsiveness. This layer lives only inside the<style>tag.Layer 2 is the problem whenever the
<head>doesn't travel with the markup — a CMS rich-text field, a third-party embed, an archived snippet, and most famously email (Gmail, Outlook desktop, Yahoo and most corporate webmail strip the entire<head>before rendering). A rule that exists only as.btn{background:#0d6efd}vanishes — the button renders unstyled. The fix is inlining: copy every matched declaration onto the element itself, producing one self-contained file that renders the same anywhere.HTML<!-- What getHtml() emits today (abridged) --> <head> <style> .btn { background:#0d6efd; color:#fff; border-radius:6px; } /* layer 2 — DROPPED by Gmail */ [builder-element="BlockElement"] { max-width:600px; } /* layer 2 — DROPPED */ .btn:hover { background:#0a58ca; } /* can't inline — stays */ @media (max-width:600px){ .btn{ width:100% } } /* can't inline — stays */ </style> </head> <body> <a class="btn" style="padding:8px 16px;">Buy now</a> <!-- layer 1 — survives, but loses bg+color --> </body> <!-- After Inline CSS export --> <body> <a class="btn" style="background:#0d6efd; color:#fff; border-radius:6px; padding:8px 16px;">Buy now</a> <!-- @media + :hover kept in a slimmed <style>; everything else baked inline --> </body> -
The fix — one server endpoint, one helper class
The transform is pure string-in → string-out: hand it the rendered HTML, get back HTML with the
<style>rules promoted onto each element.demo/backend/export-inline.phpvalidates the payload, calls the inliner, and either streams an.inlined.htmldownload or returns{html, stats}JSON for a live preview.The work lives in
demo/backend/_lib/CssInliner.php— zero dependencies, built on PHP's nativeDOMDocument. It parses each<style>block, matches every inlinable selector (type ·.class·#id·[attr="val"]· descendant · child>), merges declarations onto the element in specificity order, and — critically — lets a pre-existing inlinestyle=""always win (true inline specificity, so the Formatter's per-element styling is never clobbered).PHP// demo/backend/export-inline.php — the whole handler. require_once __DIR__ . '/_lib/Validator.php'; require_once __DIR__ . '/_lib/JsonResponse.php'; require_once __DIR__ . '/_lib/CssInliner.php'; use DemoBuilder\Validator; use DemoBuilder\CssInliner; $validated = Validator::make($_POST) ->required('html')->maxLength(10 * 1024 * 1024) ->optional('format')->regex('/^(download|json)$/') ->validateOrFail(); $result = CssInliner::inline((string) $validated['html']); // $result = [ // 'html' => '…inlined HTML…', // 'inlinedDeclarations' => 47, // props promoted onto elements // 'inlinedRules' => 12, // <style> rules that matched something // 'keptRules' => 3, // @media / :hover / unsupported — kept // 'styleBlocks' => 1, // ]; if (($validated['format'] ?? 'download') === 'json') { header('Content-Type: application/json; charset=utf-8'); echo json_encode(['ok' => true] + $result); exit; } header('Content-Type: text/html; charset=utf-8'); header('Content-Disposition: attachment; filename="builder-export.inlined.html"'); echo $result['html']; -
Wire the button — fetch → stats → download
The host owns the trigger. The pattern mirrors C.15: build a
FormDatabody, POST it, and act on the response. Here we ask forformat=jsonso we can show a stats pill ("12 rules inlined · 47 declarations · 3 kept") AND download the result from the same round-trip — instant, honest feedback that the transform actually did something.Try it in the live demo above: click Inline CSS. Watch the pill report exactly how many rules were promoted vs. kept, then check your Downloads folder for
email-inlined.html— open it and view-source to see the<style>block shrink to just the@media+:hoverrules.JavaScriptconst btn = document.getElementById('inlineBtn'); const pill = document.getElementById('inlineStatus'); btn.addEventListener('click', async () => { pill.textContent = 'Inlining…'; pill.dataset.tone = 'busy'; btn.disabled = true; try { const body = new FormData(); body.append('html', builder.getHtml()); // absolute media URLs — email-safe body.append('format', 'json'); // {html, stats} instead of a raw download const res = await fetch('/backend/export-inline.php', { method: 'POST', body }); if (!res.ok) throw new Error('HTTP ' + res.status); const { html, inlinedRules, inlinedDeclarations, keptRules } = await res.json(); pill.textContent = `\u2713 ${inlinedRules} rules inlined \u00b7 ${inlinedDeclarations} decls \u00b7 ${keptRules} kept`; pill.dataset.tone = 'success'; // Download the inlined HTML from the same response. const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); const a = Object.assign(document.createElement('a'), { href: url, download: 'email-inlined.html' }); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); } catch (err) { pill.textContent = 'Failed: ' + err.message; pill.dataset.tone = 'error'; } finally { btn.disabled = false; } }); -
What gets inlined, what stays — and where to call it in production
Inlined (promoted onto
style=""): ordinary rules whose selector is any mix of type,.class,#id,[attr]/[attr="val"], descendant (space), child (>) combinators, AND structural pseudo-classes —:first-child,:last-child,:only-child,:nth-child(n),:not(…)— which are resolved against the static DOM exactly likejuice/emogrifierdo. Specificity decides who wins when two rules touch the same property.Kept verbatim in a slimmed
<style>block (never dropped):@media,@font-face,@supports,@keyframes; any selector with a dynamic pseudo (:hover,:focus,::before— these have no static answer, so they cannot be expressed as an inline attribute); and any selector the inliner doesn't fully model (substring attribute operators^= $= *=, sibling combinators+ ~). The rule is honest: if we can't inline it correctly, we keep it rather than guess and corrupt it. So a fully-inlined export typically leaves a<style>containing only@media+:hover— everything statically resolvable is already on the elements.Where to call it. Inline server-side, right before you hand the HTML to your mail transport — that's the single point every send path crosses, regardless of whether the HTML came from a live builder, a saved snapshot, or an API call. Don't inline at save time: you want to store the compact
<style>form (smaller, re-editable) and inline only at send time.Swapping the engine. The inliner is tuned for the selectors BuilderJS themes emit and is deliberately dependency-free so it ships in the buyer ZIP with nothing to
composer install. For arbitrary third-party CSS with exotic selectors, drop inpelago/emogrifierortijsverkoyen/css-to-inline-styles— the contract is identical (HTML string in → HTML string out), so only the onerequire+ call line inexport-inline.phpchanges.PHP// Drop-in swap for a production-grade inliner — same I/O contract, // so export-inline.php is the ONLY file that changes. // // composer require pelago/emogrifier // use Pelago\Emogrifier\CssInliner as Emogrifier; function inline_for_email(string $html): string { return Emogrifier::fromHtml($html) ->inlineCss() // promote <style> → style="" ->render(); } // In your real send path: // // $html = $builderSnapshot->html; // stored compact (with <style>) // $emailReady = inline_for_email($html); // inline ONLY at send time // $mailer->send($to, $subject, $emailReady); // // Keep save-time HTML and send-time HTML decoupled: store the small // re-editable form, inline the email-safe form on the way out.
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.
Notes
Is inlining already "researched" in BuilderJS? Yes — for email artefacts, client-side. When a sample declares artefact_type: "email", getHtml() runs it through src/includes/EmailExportPipeline.js, which inlines every <style> rule via the browser's native CSSOM (the same approach as juice) as ONE step of a larger email transform — it also converts flex grids to <table>, swaps SVG social icons for PNGs, injects a mobile-stack @media query, and wraps the body in Outlook MSO conditional comments. So the email-specific path already ships a battle-tested inliner.
Why this server-side endpoint then? Three reasons the client pipeline doesn't cover: (1) it's artefact-agnostic — it inlines a page, form, or ad export too, not just email; (2) it runs without a browser — cron jobs, queue workers, and API callers that never mount a Builder can still get inlined HTML; (3) it's the one chokepoint every send path crosses. Use the client pipeline when you're exporting an email from a live editor; use this endpoint when you inline at send time on the server. They share the same contract (existing inline style="" always wins).
Pre-existing inline styles always win. The Formatter's per-element style="" is treated as the highest-specificity layer (true inline specificity), so promoting a low-specificity class rule never overrides what the author set directly on an element. This is the single most important correctness property — get it wrong and every author override silently breaks.
What it deliberately does NOT do. No <table> conversion, no MSO comments, no image-fluid defense — those are email-layout concerns owned by EmailExportPipeline. This endpoint does exactly one job (promote <style> → inline) and does it for any artefact. Compose the two if you need both.
Topnav parity. The same transform is one click away in the live builder: Export ▸ Inline CSS (the top, accent-tinted item, flagged email-ready). It POSTs builder.getHtml() to this exact endpoint with format=download. Wiring lives in demo/assets/js/builder-export-menu.js; the menu item in demo/_partials/builder/topnav.php.
Flows tier closes here at 4. C.15 export modes → C.16 import bundle → C.17 history shortcuts → C.18 inline CSS. Together they cover the full round-trip: design → export (3 artefact shapes + inline) → re-import → keep editing.