Skip to content

feat: add CSS and JavaScript to HTML blocks - #3687

Merged
ascorbic merged 18 commits into
mainfrom
noah/editor-embeds/html-block
Oct 1, 2026
Merged

ascorbic merged 18 commits into
mainfrom
noah/editor-embeds/html-block

Conversation

@khoinguyenpham04

@khoinguyenpham04 khoinguyenpham04 commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • Site frame. The sandbox is allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation with <base target="_top">, never allow-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.
  • Who can add scripts. Anyone who can edit content (Contributor and up) can add JavaScript that runs for visitors once the entry is published. There is no site-wide switch; the changeset says so.
  • Admin preview. The preview frame is 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.
  • Escaping. srcdoc is escaped for &, ", < and >, so middleware that inserts HTML before </body> can't land inside it. In the block's JavaScript, only <script and </script followed by whitespace, / or > are escaped, so code such as i<scripts.length still runs.

Compatibility:

  • htmlBlock gains optional css, js and isolated fields. They're written only when set, so existing content is unchanged when it's opened and saved.
  • Existing HTML blocks, and blocks created through imports, REST or MCP, keep rendering inline unless they set isolated: true.
  • Sites that replace the htmlBlock renderer need to handle isolated blocks. The changeset explains how.
  • @emdash-cms/admin gains a server-safe ./html-block entry. Core uses it to build the frame document and to share the sanitizer's allowlist with the preview.
  • In dev, CodeMirror is pre-bundled for the admin, so opening the first code editor doesn't trigger a dependency re-optimization and reload.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5.5

Screenshots / test output

The HTML tab, with the CSS, JS and Preview tabs beside it:

An HTML block in the editor showing its HTML tab, with a signup form's markup in a code editor with line numbers

The Preview tab after Run preview, rendering the block's HTML and CSS:

The same HTML block on its Preview tab, showing a styled newsletter signup form

The published page. The block runs in its sandboxed frame, and its JavaScript has shown the confirmation after Subscribe:

The published post showing the signup form rendered in a frame, with the text "Thanks! Check your inbox to confirm." below the email field

In an Arabic interface, the block's controls follow the interface direction while the English content and the code stay left to right:

The HTML block in the Arabic admin: the tabs run right to left starting with HTML on the right, the options menu is on the left, and the code stays left to right

At phone width, the tabs show only their icons:

The HTML block at a 390-pixel-wide viewport, with icon-only tabs and wrapped code

Tests:

  • packages/core: the unit tests for the changed files pass (17), and so do the render tests in vitest.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 on main too.
  • Lint and types: pnpm lint:json reports 0 diagnostics, and pnpm typecheck passes.

Manual checks:

  • Admin content security policy. A page served with the production policy, including 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.
  • Dev server. With demos/simple running, opening the first code editor didn't trigger a dependency re-optimization.

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.
@khoinguyenpham04 khoinguyenpham04 mentioned this pull request Sep 30, 2026
11 of 19 tasks
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 2f74d23 Oct 01 2026, 07:51 AM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://noah-editor-embeds-html-block.try.emdashcms.com, https://noah-editor-embeds-html-block-emdash-playground.emdash-cms.workers.dev (commit 2f74d23)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://54d7f367.try.emdashcms.com, https://54d7f367-emdash-playground.emdash-cms.workers.dev 2f74d23 2026-10-01T07:51:10.042Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://c92ded29.try.emdashcms.com, https://c92ded29-emdash-playground.emdash-cms.workers.dev 2669f70 2026-10-01T06:30:00.958Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://ee806b2d.try.emdashcms.com, https://ee806b2d-emdash-playground.emdash-cms.workers.dev fde39db 2026-09-30T13:54:43.779Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://3a30c9c4.try.emdashcms.com, https://3a30c9c4-emdash-playground.emdash-cms.workers.dev 4f9605a 2026-09-30T12:58:39.315Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://0d2521aa.try.emdashcms.com, https://0d2521aa-emdash-playground.emdash-cms.workers.dev 784bdd5 2026-09-30T12:12:12.533Z Visit the dashboard ↗

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label Sep 30, 2026
…ml-block

# Conflicts:
#	packages/admin/src/components/PortableTextEditor.tsx

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/core/src/utils/sanitize.ts Resolved
Comment thread .changeset/editor-html-block.md Resolved
@github-actions github-actions Bot added the review/awaiting-author Reviewed; waiting on the author to respond label Sep 30, 2026
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.
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@changeset-bot

changeset-bot Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f74d23

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@emdash-cms/admin Minor
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond query-count changed PR diff modifies query-count snapshot files labels Sep 30, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3687

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3687

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3687

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3687

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3687

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3687

emdash

npm i https://pkg.pr.new/emdash@3687

create-emdash

npm i https://pkg.pr.new/create-emdash@3687

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3687

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3687

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@3687

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3687

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3687

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3687

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@3687

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3687

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3687

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3687

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3687

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3687

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3687

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3687

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3687

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3687

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3687

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3687

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3687

commit: 2f74d23

Comment thread packages/admin/src/html-block.ts Resolved
Comment thread packages/admin/src/html-block.ts Resolved
…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.
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond review/needs-rereview New commits since the last review and removed review/needs-rereview New commits since the last review review/awaiting-author Reviewed; waiting on the author to respond labels Sep 30, 2026
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.
@ascorbic
ascorbic merged commit 76b06d4 into main Oct 1, 2026
43 checks passed
@ascorbic
ascorbic deleted the noah/editor-embeds/html-block branch October 1, 2026 11:15
@emdashbot emdashbot Bot mentioned this pull request Oct 1, 2026
khoinguyenpham04 added a commit that referenced this pull request Oct 1, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants