diff --git a/.changeset/html-block-editor.md b/.changeset/html-block-editor.md
new file mode 100644
index 000000000..3aa0fc206
--- /dev/null
+++ b/.changeset/html-block-editor.md
@@ -0,0 +1,6 @@
+---
+"emdash": minor
+"@emdash-cms/admin": minor
+---
+
+First-class HTML block in the admin editor. The existing `htmlBlock` Portable Text type (produced by the WordPress and Contentful importers) is now a fully editable block in the rich-text editor. Authors can insert an HTML block via the `/html` slash command, edit raw HTML in a textarea, and toggle a sanitized preview. Imported `htmlBlock` content that previously fell through to an opaque `pluginBlock` placeholder is now rendered in the same editable UI. The inline (visual-editing) editor preserves HTML blocks as read-only placeholders to prevent data loss.
diff --git a/docs/src/content/docs/guides/working-with-content.mdx b/docs/src/content/docs/guides/working-with-content.mdx
index 3afe5b3be..f5719c5e0 100644
--- a/docs/src/content/docs/guides/working-with-content.mdx
+++ b/docs/src/content/docs/guides/working-with-content.mdx
@@ -63,6 +63,7 @@ EmDash's editor supports:
- **Links** - Internal and external
- **Images** - Insert from media library
- **Code blocks** - With syntax highlighting
+- **HTML blocks** - Raw HTML for custom embeds and widgets
- **Embeds** - YouTube, Vimeo, Twitter
- **Sections** - Reusable content blocks via `/section` command
@@ -75,6 +76,7 @@ Type `/` to access quick insert commands:
| `/section` | Insert a reusable section |
| `/image` | Insert an image from media library |
| `/code` | Insert a code block |
+| `/html` | Insert a raw HTML block |
### Keyboard Shortcuts
@@ -99,6 +101,61 @@ Type `/` to access quick insert commands:
5. Click **Insert**
+### HTML Blocks
+
+Use `/html` to insert a raw HTML block. This is useful for embedding third-party widgets, custom markup, or content that doesn't fit the standard block types. HTML blocks are also created automatically when importing content from WordPress or Contentful that contains markup EmDash can't convert to native Portable Text blocks.
+
+
+
+To allow iframes from additional providers, override the `htmlBlock` component in your Portable Text rendering:
+
+```astro
+---
+// src/components/MyHtmlBlock.astro
+import sanitizeHtml from "sanitize-html";
+
+const { node } = Astro.props;
+
+if (!node?.html) {
+ return null;
+}
+
+const sanitized = sanitizeHtml(node.html, {
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, "img", "span", "iframe"],
+ allowedAttributes: {
+ ...sanitizeHtml.defaults.allowedAttributes,
+ "*": ["class", "id", "data-*", "style"],
+ iframe: ["src", "width", "height", "frameborder", "allow", "allowfullscreen"],
+ img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
+ },
+ allowedIframeHostnames: [
+ "www.youtube.com",
+ "player.vimeo.com",
+ "iframe.videodelivery.net", // Cloudflare Stream
+ // Add your providers here
+ ],
+});
+---
+
+
+```
+
+Then pass it to ``:
+
+```astro
+---
+import { PortableText } from "emdash/ui";
+import MyHtmlBlock from "../components/MyHtmlBlock.astro";
+---
+
+
+```
+
## Editing Content
1. Navigate to the collection containing the content
diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx
index b7b3a0ddb..8e54a8874 100644
--- a/packages/admin/src/components/PortableTextEditor.tsx
+++ b/packages/admin/src/components/PortableTextEditor.tsx
@@ -56,6 +56,7 @@ import {
Minus,
LinkBreak,
ArrowSquareOut,
+ BracketsAngle,
CodeBlock,
Stack,
Eye,
@@ -93,6 +94,7 @@ import { CaretNext } from "./ArrowIcons.js";
import { BlockKitMediaPickerField } from "./BlockKitMediaPickerField";
import { CodeBlockExtension } from "./editor/CodeBlockNode";
import { DragHandleWrapper } from "./editor/DragHandleWrapper";
+import { HtmlBlockExtension } from "./editor/HtmlBlockNode";
import { ImageExtension } from "./editor/ImageNode";
import { MarkdownLinkExtension } from "./editor/MarkdownLinkExtension";
import {
@@ -277,6 +279,15 @@ function convertPMNode(node: {
};
}
+ case "htmlBlock": {
+ const rawHtml = node.attrs?.html;
+ return {
+ _type: "htmlBlock",
+ _key: generateKey(),
+ html: typeof rawHtml === "string" ? rawHtml : "",
+ };
+ }
+
case "image": {
const attrs = node.attrs ?? {};
const provider = attrStr(attrs.provider);
@@ -640,6 +651,14 @@ function convertPTBlock(block: PortableTextBlock): unknown {
case "break":
return { type: "horizontalRule" };
+ case "htmlBlock": {
+ const htmlBlock = block as { _type: "htmlBlock"; _key: string; html?: string };
+ return {
+ type: "htmlBlock",
+ attrs: { html: htmlBlock.html || "" },
+ };
+ }
+
case "table": {
const tableBlock = block as {
_type: "table";
@@ -922,6 +941,21 @@ const defaultSlashCommands: SlashCommandItem[] = [
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
+ {
+ id: "htmlBlock",
+ title: msg`HTML`,
+ description: msg`Insert raw HTML`,
+ icon: BracketsAngle,
+ aliases: ["html", "raw", "markup"],
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .insertContent({ type: "htmlBlock", attrs: { html: "" } })
+ .run();
+ },
+ },
{
id: "divider",
title: msg`Divider`,
@@ -2088,6 +2122,7 @@ export function PortableTextEditor({
underline: {},
}),
CodeBlockExtension,
+ HtmlBlockExtension,
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
diff --git a/packages/admin/src/components/editor/HtmlBlockNode.tsx b/packages/admin/src/components/editor/HtmlBlockNode.tsx
new file mode 100644
index 000000000..18cd06190
--- /dev/null
+++ b/packages/admin/src/components/editor/HtmlBlockNode.tsx
@@ -0,0 +1,202 @@
+/**
+ * HTML block node for the admin editor.
+ *
+ * Renders a first-class `htmlBlock` in the Portable Text editor with:
+ * - A textarea for editing raw HTML source
+ * - Selection ring, drag handle, and delete action
+ *
+ * Modeled on `PluginBlockNode.tsx` (atom node with React node view) and
+ * the existing `{ _type: "htmlBlock", _key, html }` Portable Text shape
+ * used by the WordPress and Contentful importers.
+ */
+
+import { Button } from "@cloudflare/kumo";
+import { useLingui } from "@lingui/react/macro";
+import { BracketsAngle, DotsSixVertical, Trash } from "@phosphor-icons/react";
+import { Node, mergeAttributes } from "@tiptap/core";
+import type { NodeViewProps } from "@tiptap/react";
+import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
+import * as React from "react";
+
+import { cn } from "../../lib/utils";
+
+// ---------------------------------------------------------------------------
+// Node View
+// ---------------------------------------------------------------------------
+
+function HtmlBlockNodeView({ node, updateAttributes, selected, deleteNode }: NodeViewProps) {
+ const { t } = useLingui();
+ const html = typeof node.attrs.html === "string" ? node.attrs.html : "";
+ const [draft, setDraft] = React.useState(html);
+ const textareaRef = React.useRef(null);
+
+ // Sync draft when the stored html changes from outside the node view.
+ React.useEffect(() => {
+ setDraft(html);
+ }, [html]);
+
+ // Auto-resize textarea to fit content.
+ React.useEffect(() => {
+ const el = textareaRef.current;
+ if (el) {
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
+ }
+ }, [draft]);
+
+ const commitHtml = React.useCallback(
+ (value: string) => {
+ updateAttributes({ html: value });
+ },
+ [updateAttributes],
+ );
+
+ const handleChange = React.useCallback(
+ (e: React.ChangeEvent) => {
+ setDraft(e.target.value);
+ commitHtml(e.target.value);
+ },
+ [commitHtml],
+ );
+
+ return (
+
+