Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/html-block-editor.md
Original file line number Diff line number Diff line change
@@ -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 and edit raw HTML in a textarea. 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.
57 changes: 57 additions & 0 deletions docs/src/content/docs/guides/working-with-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.

<Aside type="caution">
HTML blocks are sanitized before rendering on the frontend to prevent XSS attacks. By default, iframes are only allowed from `www.youtube.com` and `player.vimeo.com`. Iframes from other providers (Cloudflare Stream, Loom, Wistia, Google Maps, etc.) will be stripped during sanitization.
</Aside>

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
],
});
---

<div class="html-block" set:html={sanitized} />
```

Then pass it to `<PortableText>`:

```astro
---
import { PortableText } from "emdash/ui";
import MyHtmlBlock from "../components/MyHtmlBlock.astro";
---

<PortableText
value={post.data.content}
components={{ type: { htmlBlock: MyHtmlBlock } }}
/>
```

## Editing Content

1. Navigate to the collection containing the content
Expand Down
42 changes: 42 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
Minus,
LinkBreak,
ArrowSquareOut,
BracketsAngle,
CodeBlock,
Stack,
Eye,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -149,10 +151,17 @@ interface PortableTextCodeBlock {
language?: string;
}

interface PortableTextHtmlBlock {
_type: "htmlBlock";
_key: string;
html: string;
}

type PortableTextBlock =
| PortableTextTextBlock
| PortableTextImageBlock
| PortableTextCodeBlock
| PortableTextHtmlBlock
| { _type: string; _key: string; [key: string]: unknown };

// Generate unique key
Expand Down Expand Up @@ -277,6 +286,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);
Expand Down Expand Up @@ -640,6 +658,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";
Expand Down Expand Up @@ -922,6 +948,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`,
Expand Down Expand Up @@ -2088,6 +2129,7 @@ export function PortableTextEditor({
underline: {},
}),
CodeBlockExtension,
HtmlBlockExtension,
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
Expand Down
Loading
Loading