Skip to content

feat: first-class HTML block in the admin editor#1215

Merged
ascorbic merged 5 commits into
mainfrom
feat/html-block-editor
Jun 2, 2026
Merged

feat: first-class HTML block in the admin editor#1215
ascorbic merged 5 commits into
mainfrom
feat/html-block-editor

Conversation

@scottbuscemi
Copy link
Copy Markdown
Collaborator

@scottbuscemi scottbuscemi commented May 29, 2026

What does this PR do?

Add a first-class HtmlBlockNode to the admin editor so the existing htmlBlock Portable Text type (produced by the WordPress and Contentful importers) becomes a fully editable block. Previously, imported htmlBlock content fell through to an opaque pluginBlock placeholder with the html field becoming inaccessible. Now authors can create and edit HTML blocks natively in the rich-text editor.

Closes discussion #1185

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 a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: First-class HTML block in the admin editor #1185

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4 (via OpenCode)

Implementation details

New files

  • packages/admin/src/components/editor/HtmlBlockNode.tsx — TipTap atom node extension with React node view. Provides a textarea for editing raw HTML source, a Preview toggle that sanitizes via DOMPurify, drag handle, delete action, and selection ring. Modeled on PluginBlockNode.tsx.

  • packages/core/tests/unit/converters/html-block-round-trip.test.ts — Round-trip test verifying htmlBlock survives PT → PM → PT conversion in the core standalone converters.

Modified files

  • packages/admin/src/components/PortableTextEditor.tsx — Added htmlBlock cases to convertPMNode and convertPTBlock for round-trip conversion. Added /html slash command. Registered HtmlBlockExtension in the extensions array.

  • packages/core/src/components/InlinePortableTextEditor.tsx — Added htmlBlock cases to both converters, a read-only HtmlBlockNode TipTap extension (placeholder, like PluginBlockNode), /html slash command, and registered the extension.

  • packages/core/src/content/converters/types.ts — New PortableTextHtmlBlock interface, added to the PortableTextBlock union.

  • packages/core/src/content/converters/prosemirror-to-portable-text.ts — New convertHtmlBlock handler in the convertNode switch.

  • packages/core/src/content/converters/portable-text-to-prosemirror.ts — New htmlBlock case in convertBlock.

  • packages/core/src/index.ts — Exported PortableTextHtmlBlock type.

Key design decisions

  1. Atom node, not editable content — HTML blocks are atom nodes (like images and plugin blocks), not inline-editable text regions. The textarea is rendered via the React node view, not ProseMirror content editing.

  2. DOMPurify for preview — The admin package already depends on dompurify. The preview toggle runs through DOMPurify.sanitize() so authors see what will actually render, matching the server-side sanitizeContent in core.

  3. All user-facing strings wrapped with Linguit template literals for all button labels, descriptions, placeholder text, and aria attributes.

  4. RTL-safe — Uses logical properties (start/end) throughout.

  5. Inline editor as read-only placeholder — The visual-editing (inline) editor shows HTML blocks as a simple "HTML block (edit in admin)" placeholder, preserving the data losslessly on round-trip without needing to mount the full editing UI.


Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks feat/html-block-editor. Updated automatically when the playground redeploys.

Add HtmlBlockNode TipTap extension to the admin editor so the existing
htmlBlock Portable Text type (produced by WordPress and Contentful
importers) is a fully editable block rather than falling through to an
opaque pluginBlock placeholder.

- New HtmlBlockNode.tsx: atom node with textarea for source editing and
  a Preview toggle that sanitizes via DOMPurify
- Slash command entry (/html) and aliases (raw, markup)
- Round-trip conversion in all three converter locations:
  - PortableTextEditor.tsx (admin editor)
  - InlinePortableTextEditor.tsx (visual-editing editor)
  - Core standalone converters (prosemirror-to-portable-text.ts,
    portable-text-to-prosemirror.ts)
- New PortableTextHtmlBlock type in core converter types, exported from
  the emdash package
- Inline editor renders htmlBlock as a read-only placeholder to prevent
  data loss during visual editing
- Round-trip test for the core converters

Closes discussion #1185
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: 1a0b6a2

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

This PR includes changesets to release 14 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash 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

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

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 1a0b6a2 Jun 02 2026, 10:29 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

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
emdash-playground 1a0b6a2 Jun 02 2026, 10:30 AM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 29, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: 1a0b6a2

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 29, 2026

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
emdash-demo-cache 1a0b6a2 Jun 02 2026, 10:30 AM

Drop the Eye/Preview toggle (DOMPurify-based sanitized preview) and the
character count subtitle from the HTML block header. The block is now a
straightforward textarea with a delete button.

This also removes the dompurify import from the component.
@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.

Add HTML block documentation to the Working with Content guide:
- Add /html to the slash commands table
- Add HTML blocks to the editor feature list
- New 'HTML Blocks' section explaining the feature and its use cases
- Document the iframe sanitization allowlist limitation (youtube,
  vimeo only by default)
- Provide a full code example for overriding the htmlBlock component
  to allow additional iframe providers
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels May 30, 2026
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

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

Approach judgment: This is the right change for the right problem. Exposing htmlBlock as a first-class editable atom node fits EmDash's architecture and follows the established PluginBlockNode pattern. The read-only placeholder in the inline editor is a pragmatic choice that preserves data without bloating the visual-editing bundle.

Implementation: The converter updates in core and admin are consistent, round-trip tests confirm data fidelity, and all new UI strings in the admin node view are properly wrapped with Lingui. RTL-safe Tailwind is used throughout.

Issues found:

  1. HtmlBlockExtension's Backspace/Delete keyboard shortcuts run at the editor level and intercept keystrokes even when the user is typing inside the HTML <textarea>. This deletes the entire block instead of a single character — a concrete usability blocker that needs a guard on document.activeElement.

  2. The html attribute is serialized to the DOM as a bare html="..." attribute, leaking raw markup into HTML output and clipboard serialization. A custom renderHTML/parseHTML pair (like PluginBlockExtension does for data-plugin-data) would keep the markup semantic.

  3. The PR description mentions a DOMPurify-powered preview toggle that is not present in the code. The current textarea-only implementation is internally coherent, but the description should be updated if the preview was intentionally deferred.

Comment on lines +177 to +195
return ReactNodeViewRenderer(HtmlBlockNodeView);
},

addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
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.

[needs fixing] The Backspace and Delete keyboard shortcuts run at the editor level and intercept keydown events even when focus is inside the HTML <textarea>. When a user presses Backspace while editing source, the entire block is deleted instead of a single character.

Add a guard that returns false when focus is inside an input or textarea so the native editing behavior is preserved:

Suggested change
return ReactNodeViewRenderer(HtmlBlockNodeView);
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
addKeyboardShortcuts() {
return {
Backspace: () => {
const active = document.activeElement;
if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) {
return false;
}
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
Delete: () => {
const active = document.activeElement;
if (active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT")) {
return false;
}
const { selection } = this.editor.state;
const node = this.editor.state.doc.nodeAt(selection.from);
if (node?.type.name === "htmlBlock") {
this.editor.commands.deleteSelection();
return true;
}
return false;
},
};
},

Comment on lines +153 to +159
draggable: true,
selectable: true,

addAttributes() {
return {
html: {
default: "",
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.

[suggestion] The html attribute is rendered to the DOM as a bare html="..." attribute, which leaks arbitrary markup into the editor's HTML output and clipboard serialization. Consider adding a custom renderHTML/parseHTML pair to store it in a semantic data-html attribute — matching how PluginBlockExtension uses data-plugin-data for its payload:

Suggested change
draggable: true,
selectable: true,
addAttributes() {
return {
html: {
default: "",
addAttributes() {
return {
html: {
default: "",
parseHTML: (el: HTMLElement) => el.getAttribute("data-html") || "",
renderHTML: (attrs: Record<string, unknown>) =>
typeof attrs.html === "string" ? { "data-html": attrs.html } : {},
},
};
},

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels May 31, 2026
Add PT->PM->PT round-trip assertions for htmlBlock against the converter
seams the admin and inline (visual-editing) editors actually exercise,
mirroring the existing core converter round-trip test.

Correct the changeset: there is no sanitized-preview toggle; the editor
renders only a textarea.
@github-actions github-actions Bot added size/XL review/needs-rereview Author pushed changes since the last review and removed size/L review/awaiting-author Reviewed; waiting on the author to respond labels Jun 2, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 2, 2026
Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Looking good! Tested in the playground and it works well

Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

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

This is still the right change for the right problem: making htmlBlock a first-class editable atom node fits EmDash's architecture and follows the established PluginBlockNode pattern. The read-only placeholder in the inline editor remains a pragmatic choice.

What's been addressed since the last review:

  • The DOMPurify preview toggle discrepancy is resolved — it was removed in daf0bf2b and the changeset was corrected in fda0961c to reflect that the editor renders only a textarea.
  • Additional round-trip tests covering the admin and inline editor converter seams were added in fda0961c.
  • Documentation for HTML blocks and iframe sanitization was added in d698e89d.

What remains unaddressed:
Two issues flagged in the prior review are still present and should be fixed before merge:

  1. Backspace/Delete keyboard shortcuts hijack the <textarea>. The HtmlBlockExtension keymap intercepts Backspace and Delete whenever the ProseMirror selection is on an htmlBlock, even while the user is typing inside the nested <textarea>. This deletes the entire block instead of a single character. A guard on document.activeElement (or the equivalent) is needed.

  2. Raw html markup leaks into DOM attributes. renderHTML passes HTMLAttributes (which includes the bare html attribute) straight to the DOM, so the editor serializes potentially large raw markup as html="..." on every drag, copy, and clipboard operation. The attribute should use a custom parseHTML/renderHTML pair — exactly like PluginBlockExtension does for data-plugin-data — to store the content in a semantic data-html-content attribute instead.

New note (minor): The admin editor's local PortableTextBlock union type does not include the new htmlBlock shape, so TypeScript won't narrow correctly for consumers of the editor's onChange callback. It's a small type gap worth closing while the file is open.

Implementation is otherwise consistent across core, admin, and inline surfaces; tests cover the round-trip paths; all new UI strings are properly wrapped with Lingui; and RTL-safe Tailwind is used throughout.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jun 2, 2026
- Guard Backspace/Delete editor shortcuts so they no longer hijack
  keystrokes while editing the nested source textarea (deferring to
  native field editing when focus is in an input/textarea).
- Store raw markup in a semantic data-html-content attribute via
  custom parseHTML/renderHTML instead of leaking a bare html="..."
  attribute on DOM/clipboard serialization.
- Add a dedicated htmlBlock shape to the editor's PortableTextBlock
  union so consumers narrow correctly.
- Cover the serialization round-trip with editor-level tests.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/approved Approved; no new commits since labels Jun 2, 2026
@ascorbic ascorbic merged commit 590b2f9 into main Jun 2, 2026
37 checks passed
@ascorbic ascorbic deleted the feat/html-block-editor branch June 2, 2026 11:57
@emdashbot emdashbot Bot mentioned this pull request Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core area/docs bot:review Trigger an emdashbot code review on this PR cla: signed overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants