Files
captoys/potluck/.agents/skills/creating-plugins/references/portable-text-blocks.md
T
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

6.9 KiB

Portable Text Block Types

Plugin CLI and registry packages cannot define Portable Text block types. emdash-plugin build warns that portableTextBlocks require trusted mode and omits them from the sandboxed manifest. Site-side rendering also needs an Astro componentsEntry, which is loaded with the site at build time.

Core can forward declarative block metadata from a config-declared standard plugin descriptor, but that path does not make the definition portable through the registry and does not provide a site renderer. Treat custom Portable Text blocks as a native-plugin feature unless the site owns both the descriptor and the rendering components and has verified the complete editor-to-render path.

Plugins can add custom block types to the Portable Text editor. These appear in the slash command menu and can be inserted into any portableText field.

Declaring Block Types

In definePlugin(), declare blocks under admin.portableTextBlocks:

admin: {
	portableTextBlocks: [
		{
			type: "youtube",
			label: "YouTube Video",
			icon: "video",
			placeholder: "Paste YouTube URL...",
			fields: [
				{ type: "text_input", action_id: "id", label: "YouTube URL" },
				{ type: "text_input", action_id: "title", label: "Title" },
				{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
			],
		},
		{
			type: "codepen",
			label: "CodePen",
			icon: "code",
			placeholder: "Paste CodePen URL...",
		},
	],
}

Block Config Fields

Field Type Description
type string Block type name (used in PT _type). Required.
label string Display name in slash command menu. Required.
icon string Icon key. Optional.
description string Description in slash command menu. Optional.
placeholder string Input placeholder text. Optional.
fields array Block Kit form fields for editing UI. Optional.

Icons

Named icons: video, code, link, link-external. Unknown or missing falls back to a generic cube icon.

Fields

When fields is declared, the editor renders a Block Kit form for editing. When omitted, a simple URL input is shown.

Fields use Block Kit element syntax:

fields: [
	{
		type: "text_input",
		action_id: "id",
		label: "URL",
		placeholder: "https://...",
	},
	{ type: "text_input", action_id: "title", label: "Title" },
	{ type: "text_input", action_id: "poster", label: "Poster Image" },
	{ type: "number_input", action_id: "start", label: "Start Time (seconds)" },
	{ type: "toggle", action_id: "autoplay", label: "Autoplay" },
	{
		type: "select",
		action_id: "size",
		label: "Size",
		options: [
			{ label: "Small", value: "small" },
			{ label: "Medium", value: "medium" },
			{ label: "Large", value: "large" },
		],
	},
];

See Block Kit for element shapes. The Portable Text editor additionally renders repeater and media_picker authoring elements. Do not assume every element accepted by the shared union renders in every Block Kit surface.

The action_id of each field becomes a key in the Portable Text block data. The field with action_id: "id" is treated as the primary identifier (typically the URL).

Data Flow

  1. User types / in the editor and selects a block type
  2. Modal opens with Block Kit form (or simple URL input if no fields)
  3. User fills in fields and submits
  4. Block is inserted with _type set to the block type and field values as properties
  5. Editing an existing block re-opens the modal pre-populated

Portable Text output:

{
	"_type": "youtube",
	"_key": "abc123",
	"id": "https://youtube.com/watch?v=dQw4w9WgXcQ",
	"title": "Never Gonna Give You Up",
	"poster": "https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg"
}

Site-Side Rendering

To render block types on the site, export Astro components from a componentsEntry.

Component File

// src/astro/index.ts
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";

// This export name is required
export const blockComponents = {
	youtube: YouTube,
	codepen: CodePen,
};

Astro Component

---
// src/astro/YouTube.astro
const { id, title, poster } = Astro.props.node;

// Extract video ID from URL
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
---

<div class="youtube-embed">
	<iframe
		src={`https://www.youtube-nocookie.com/embed/${videoId}`}
		title={title || "YouTube Video"}
		allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
		allowfullscreen
	></iframe>
</div>

Component receives Astro.props.node with the full block data.

Plugin Descriptor

Set componentsEntry in the descriptor:

export function myPlugin(options = {}): PluginDescriptor {
	return {
		id: "my-plugin",
		entrypoint: "@my-org/my-plugin",
		componentsEntry: "@my-org/my-plugin/astro",
		version: "1.0.0",
		options,
	};
}

Package Exports

Add the ./astro export:

{
	"exports": {
		".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
		"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
		"./astro": {
			"types": "./dist/astro/index.d.ts",
			"import": "./dist/astro/index.js"
		}
	}
}

Auto-Wiring

Plugin block components are automatically merged into <PortableText> on the site. Merge order:

  1. EmDash defaults (lowest priority)
  2. Plugin block components
  3. User-provided components (highest priority)

Site authors don't need to import anything. User components take precedence over plugin defaults.

Complete Example

// src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";

export function embedsPlugin(options = {}): PluginDescriptor {
	return {
		id: "embeds",
		version: "1.0.0",
		entrypoint: "@my-org/plugin-embeds",
		componentsEntry: "@my-org/plugin-embeds/astro",
		options,
	};
}

export function createPlugin() {
	return definePlugin({
		id: "embeds",
		version: "1.0.0",

		admin: {
			portableTextBlocks: [
				{
					type: "youtube",
					label: "YouTube Video",
					icon: "video",
					placeholder: "Paste YouTube URL...",
					fields: [
						{ type: "text_input", action_id: "id", label: "YouTube URL" },
						{ type: "text_input", action_id: "title", label: "Title" },
						{
							type: "text_input",
							action_id: "poster",
							label: "Poster Image URL",
						},
					],
				},
				{
					type: "linkPreview",
					label: "Link Preview",
					icon: "link-external",
					placeholder: "Paste any URL...",
				},
			],
		},
	});
}

export default createPlugin;
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import LinkPreview from "./LinkPreview.astro";

export const blockComponents = {
	youtube: YouTube,
	linkPreview: LinkPreview,
};