Repository navigation
feat: add CSS and JavaScript to HTML blocks - #3687
Conversation
HTML blocks gain optional `css`, `js` and `isolated` fields. The admin editor, the core converters and the visual editor carry them, writing `css` and `js` only when non-empty and `isolated` only when true, so existing blocks round-trip to identical JSON. A new `@emdash-cms/admin/html-block` module builds the frame document. On the site, an isolated block renders as a sandboxed srcdoc iframe without `allow-same-origin`. The frame reports its height to the page, and the page asks each frame for it once its listener starts. Blocks without `isolated` still render as sanitized inline HTML.
An HTML block opens as a card with HTML, CSS and JS tabs, each a CodeMirror editor loaded on demand, and a menu that switches the block between an isolated frame and inline rendering or deletes it. New blocks render in an isolated frame and take focus in their HTML editor. HTML blocks belong to a new `topBlock` group that quotes, list items and table cells don't accept, so they can no longer be nested where saving dropped them. Inserting one places it at the top level. Edits are written after a short pause, and before any press outside the code editor. While a code editor has focus, the editor's selection sits beside the block, so toolbar inserts don't replace it. Escape leaves the code editor and Enter or Tab returns to the block. The block's own drag handle and Delete button are removed, along with the Backspace/Delete shortcut that swallowed keys at a gap cursor.
Astro leaves `<` and `>` unescaped in attribute values, so the frame document's `</body>` appeared in the page's HTML. For signed-in users, the visual editing toolbar is spliced in before the page's first `</body>`, which landed inside `srcdoc`, ended the attribute early and dropped the frame's `sandbox`. The block's scripts then ran with the site's origin. The frame tag is now written with every markup character in `srcdoc` escaped.
The HTML block's code editor loads CodeMirror lazily. When the admin runs from source, Vite discovered those packages the first time a code tab opened, re-optimized and reloaded the page, losing unsaved edits.
HTML blocks gain a Preview tab that shows the block in the same document the site renders, in a sandbox that can run scripts but can't reach the admin. Saved blocks open on Preview; a saved block whose code could run scripts waits for Run preview, so a broken script can't freeze the editor. Frames mount near the viewport and keep their last height. Inline blocks preview through the site's sanitizer rules, now shared by `sanitizeContent` and the admin, so the preview keeps and removes the same markup. A note appears when the admin's security policy blocks something the block loads. Documents HTML blocks for authors and site developers.
The editor's content takes its direction from the text, so in an Arabic interface an HTML block holding English content laid out its tabs left to right while their arrow keys moved right to left. The card now follows the interface direction, as other editor controls do.
…table Focusing a block's code editor moves the selection after the block so toolbar actions insert beside it. When a table followed, that put the selection in the table's first cell, where the toolbar disables its insert buttons. The selection now goes to a gap cursor before the table.
The frame escaped every `<` before `script` in the block's JavaScript, which turned code such as `i<scripts.length` into a syntax error. Only `<script` and `</script` followed by whitespace, `/` or `>` can end the element or keep it open, so only those are escaped now.
The site's sanitizer drops the `<noscript>` tag but keeps its content, such as the fallback image in lazy-loading markup. The inline preview dropped the content too, so it showed less than the site renders.
After a visitor clicks inside its frame, the block can open other pages in the visitor's tab or a new one, which matters when deciding who may add JavaScript. The admin's security policy also still loads images from other sites in previews.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 2f74d23 | Oct 01 2026, 07:51 AM |
Scope checkThis PR changes 2,732 lines across 33 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
…ml-block # Conflicts: # packages/admin/src/components/PortableTextEditor.tsx
There was a problem hiding this comment.
The feature is the right addition for EmDash: giving HTML blocks CSS and JS while isolating them in a sandboxed frame is the safest way to let content authors run rich embeds. The implementation generally fits the architecture: the new @emdash-cms/admin/html-block entry keeps the frame document identical across admin, core converters, and the site; the site frame omits allow-same-origin and escapes srcdoc to avoid middleware injection; and there is solid test coverage for round-tripping, isolation, and editor interactions.
The main issue is that the PR silently tightens the inline HTML sanitizer. sanitizeContent now uses a curated allowlist (SITE_HTML_ALLOWED_TAGS) that drops many tags the previous sanitize-html defaults allowed (video, audio, button, input, canvas, details, summary, etc.) and removes data: image URLs. The changeset and user docs still say existing inline blocks “keep rendering inline” and only scripts, styles, and untrusted iframes are removed. That is no longer accurate, and existing content with those tags or base64 images will be silently stripped. The allowlist needs to be reconciled with the documented compatibility promise, or the changeset and docs need to clearly spell out the stricter inline policy and the migration path (e.g., switching affected blocks to isolated mode).
A process note: the PR description checks the “Feature” box but leaves the approved Discussion URL blank. Per AGENTS.md/CONTRIBUTING.md, a feature PR must link an approved Discussion; please add the link or confirm a prior approved discussion exists.
I did not find SQL/locale/query-count issues, and the sandbox model and escaping look correct in the authored source.
An isolated block runs its HTML tab as written, so a snippet with its own <style> and <script> elements works without splitting it across the CSS and JS tabs, but nothing said so. The HTML tab's placeholder now says so for isolated blocks, as do the docs, and a frame test keeps that working.
The inline mode's description named only the scripts, styles and iframes it removes. It keeps text formatting, links, lists, tables, images and iframes, and removes other markup such as media and form controls, which is what an author needs to choose between the modes.
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
🦋 Changeset detectedLatest commit: 2f74d23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-test
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-loader
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
…ults The site's inline HTML blocks now take their allowlist from a list shared with the admin preview instead of from sanitize-html's defaults. A test now fails if that list, or an upgrade of sanitize-html, changes what existing inline blocks render, and the list says what it matches.
A code edit waits briefly before it's written to the block. If the editor turned read-only in that window, the edit was dropped. It now waits for the next write instead.
Clipboard HTML from another page could carry data-html-isolated and create a block that runs its JavaScript on the site. Isolation now round-trips only with a per-page token. Also document the viewport-height limit of isolated frames and scope the nesting note to the admin editor.
A srcdoc frame takes its base URL from the page, so a block's JavaScript could read the full page URL through document.baseURI, including preview tokens in the query. The frame's base is now the site root.
…ment-7307c4 Brings in CSS and JavaScript for HTML blocks (#3687) and the iframe block (#3688), fitted to the redesigned editor: - A selected HTML or iframe block gets the editor's selection frame instead of a second ring of its own, and the same spacing as the other framed blocks. - Escape from a block's code editor selects the block, and the arrow keys then move between blocks, as after Escape in text. - Iframe is listed under Embeds, and /embed finds it before HTML. - An HTML or iframe block inserted on an empty list or quote line replaces that line, as other inserted blocks do.
What does this PR do?
This is the first of two stacked PRs for embeds in the editor. It adds CSS and JavaScript to HTML blocks. The second PR adds an iframe block on top of it.
In the editor, an HTML block has HTML, CSS, JS and Preview tabs. Each code tab is a CodeMirror editor, where Tab indents and Escape returns to the surrounding text. Preview shows the block as the site renders it. A saved block that runs JavaScript waits for Run preview, so a broken script can't freeze the editor. HTML blocks now stay at the top level, because saving dropped them when they were nested in quotes, lists or table cells. HTML blocks pasted from another website or browser tab come in inline, so a page can't use the clipboard to put running scripts on the site.
On the site, new HTML blocks render in a sandboxed iframe that runs their HTML, CSS and JavaScript and grows with its content (
HtmlBlockFrame.astro). The block's menu switches between Isolated frame and Inline. Inline blocks render in the page, sanitized, as every HTML block did before.Security model:
allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activationwith<base target="_top">, neverallow-same-origin. The frame has an opaque origin, so it can't read the site's cookies, storage or pages. After a visitor clicks inside it, it can open other pages.sandbox="allow-scripts"only and inherits the admin's content security policy. It can't navigate the admin, submit forms, open popups or load scripts and styles from other sites.srcdocis escaped for&,",<and>, so middleware that inserts HTML before</body>can't land inside it. In the block's JavaScript, only<scriptand</scriptfollowed by whitespace,/or>are escaped, so code such asi<scripts.lengthstill runs.Compatibility:
htmlBlockgains optionalcss,jsandisolatedfields. They're written only when set, so existing content is unchanged when it's opened and saved.isolated: true.htmlBlockrenderer need to handleisolatedblocks. The changeset explains how.@emdash-cms/admingains a server-safe./html-blockentry. Core uses it to build the frame document and to share the sanitizer's allowlist with the preview.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
The HTML tab, with the CSS, JS and Preview tabs beside it:
The Preview tab after Run preview, rendering the block's HTML and CSS:
The published page. The block runs in its sandboxed frame, and its JavaScript has shown the confirmation after Subscribe:
In an Arabic interface, the block's controls follow the interface direction while the English content and the code stay left to right:
At phone width, the tabs show only their icons:
Tests:
packages/core: the unit tests for the changed files pass (17), and so do the render tests invitest.repro.config.ts(84).packages/admin: the HTML block editor, frame, conversion, node, slash menu and toolbar tests pass (175 of 177). The two that failed are toolbar Alt+F10 tests. They fail only in this environment's single-process Chromium, which fails them onmaintoo.pnpm lint:jsonreports 0 diagnostics, andpnpm typecheckpasses.Manual checks:
frame-ancestors 'none', still runs the preview frame's JavaScript, and the frame still reports its height. A nested iframe from another site is blocked, and the preview says so.demos/simplerunning, opening the first code editor didn't trigger a dependency re-optimization.