Custom Editor Components
A custom editor component allows you to create reusable, complex block-level or inline components available in the rich text editor.
By default, registered components appear under the Insert button on the editor toolbar, though they can also be placed directly on the toolbar using the trigger option. When clicked, they insert a predefined template into the editor at the current cursor position.
Overview
To register a custom editor component, use the registerEditorComponent method on the CMS object:
CMS.registerEditorComponent(definition);Registering a component with the same id again replaces the previous one. The component definition object includes the following properties:
Required Properties
id(string): A unique identifier for the component. This is the name you will use to reference this component in theeditor_componentsoption for a RichText or Markdown field. It should be unique and not conflict with built-in component IDs (code-block,image).fields(array of field definitions): An array defining the fields to be displayed in the component.pattern(RegExp): A regular expression used to identify existing instances of the component in the Markdown content.- It’s recommended to use named capture groups corresponding to the field names so that
fromBlockcan be omitted if no additional processing is needed. - Matching could be either block (multiline) or inline, depending on the component. To match block content, use the
s(dotAll) orm(multiline) flag, or include[\s\S]in the pattern. Otherwise, the component is treated as an inline component that matches text within a paragraph. - The
g(global) flag is ignored.
- It’s recommended to use named capture groups corresponding to the field names so that
fromBlock(function): A function that takes a regex match array and returns an object mapping field names to their values.- This property can be omitted if the
patternregular expression contains named capture groups corresponding to the field names, and no additional processing like type conversion is needed. - Otherwise, this property is required. You must provide a function to extract field values from the regex match.
- This property can be omitted if the
toBlock(function): A function that takes an object mapping field names to their values and returns a string representing the Markdown content to be inserted. It’s also called once with an empty object while the editor is being initialized, so it must handle missing values.
Optional Properties
label(string): The text label displayed on the toolbar button. Defaults to theidvalue.icon(string): A Material Symbols icon name to display on the toolbar button.trigger(string): The trigger UI of the component, eithermenuitem(default) orbutton. A menu item is placed under the Insert menu, while a button is placed directly on the toolbar.toPreview(function): A function that takes an object mapping field names to their values and returns the preview of the component to be displayed in the editor. It can return a string, a DOM element or a React element. See Preview Output below. If omitted, or if it returns another type of value, no preview is shown. The function also receives agetAssetfunction and the component’sfieldsas the second and third arguments, like Netlify/Decap CMS; see Displaying Assets.mode(string): Editing mode for the component.block(default) renders the component within the rich text editor as an expandable field list.dialogrenders a compact placeholder that opens a dialog when clicked.summary(string): Template for the placeholder text whenmodeisdialog, e.g.{{title}} - {{videoId}}. Like the Object field’ssummaryoption, it supports nested field names and transformations. Text without placeholders is shown as is. If the summary is empty, the placeholder falls back to the first String or Text field value, then to the component label.thumbnail(string): The name of an Image or File field whose image is displayed as a small thumbnail in the placeholder whenmodeisdialog, e.g.icon. A nested field can be named with a key path likemedia.src. The thumbnail is displayed next to the summary. If there is no text to show, only the thumbnail is displayed, without the component label. The label is shown instead if the field is empty, the file is not an image, or the image fails to load. See Icon with Thumbnail below.collapsed(boolean): If true, the component's fields panel is collapsed by default when inserted (blockmode only).htmlSelector(string),fromBlockHTML(function) andtoBlockHTML(function): The HTML counterparts ofpattern,fromBlockandtoBlock, which make the component available in a RichText field with thehtmlformat. All three are required to support HTML. See Supporting HTML below.
Preview Output
The optional toPreview function can return any of the following:
- A string: Parsed as Markdown and HTML, then sanitized with DOMPurify unless the field’s
sanitize_previewoption is disabled. Most of the examples below use this. - A DOM element: Inserted as is, which allows you to mount a component built with Svelte, Vue or any other framework. See Using a Framework Component for Preview.
- A React element: Rendered with React as is. See Using React for Preview.
“As is” means that neither Markdown parsing nor sanitization is applied, so the value of a nested RichText or Markdown field is displayed verbatim, such as **bold**, unless you render it yourself. The CMS provides the renderRichText method for exactly that purpose, which renders the value into an element of your choice just like the preview pane, nested components included — see Rendering Markdown.
Like toBlock, the function is also called once with an empty object while the editor is being initialized, so make sure that it works without any field values, as the examples below do by using default values. A preview is reused as long as the component’s Markdown is unchanged.
Security Risk
The sanitize_preview option applies to string previews only, so any HTML you write into a DOM element or React element, for example with innerHTML or dangerouslySetInnerHTML, is rendered as is. This can expose your CMS to cross-site scripting (XSS) attacks if untrusted users have access to the CMS, especially when using Open Authoring, because entries can be written by anybody. Insert field values as text, or sanitize them yourself, unless you’re the sole user of your CMS.
Displaying Assets
A file path stored in a field value, such as /images/photo.jpg, may not point to the file in the preview: the file may not have been published yet, or not even saved, as a file the user has just picked is only uploaded when the entry is saved. The CMS takes care of images, videos and audio: the src and srcset of any <img> and <source> element, the src of any <video> and <audio> element and the poster of a <video> element in the preview, including one in a DOM element or React element preview, are replaced with URLs that work, as in the Image with Caption example.
For anything else, such as a path your component transforms, or a CSS background image in a DOM element or React element preview, use the getAsset function that toPreview receives as the second argument. It takes a file path and returns an asset object, or undefined if the file is not found. Its url property is the URL to display, and the object also turns into the URL when used as a string. The path is resolved just like an image in the preview, so a file in the entry folder, a field-level media folder of the component, or one the user has just picked is found as well. A complete URL, such as https://example.com/photo.jpg, is returned as is. See the getAsset prop of a custom preview template for the other properties of the asset object.
CMS.registerEditorComponent({
id: 'cover',
label: 'Cover',
icon: 'wallpaper',
fields: [{ name: 'src', label: 'Image', widget: 'image' }],
pattern: /{{< cover src="(?<src>.*?)" >}}/,
toBlock: ({ src = '' }) => `{{< cover src="${src}" >}}`,
toPreview: ({ src = '' }, getAsset) => {
const element = document.createElement('div');
element.className = 'cover';
element.style.backgroundImage = `url("${getAsset(src)?.url ?? src}")`;
return element;
},
});The third argument is the component’s fields as an Immutable.js List, which Netlify/Decap CMS passes along with getAsset so that a component can find the field a path comes from and pass its configuration as the second argument of getAsset. Sveltia CMS accepts that argument for compatibility but doesn’t need it, as it searches the media folders of all fields. Immutable.js is loaded on demand when a component whose toPreview function takes three parameters is registered, and fields is undefined until it’s ready, so code ported from Netlify/Decap CMS should read it with optional chaining (fields?.find(…)), as the built-in image component of Decap CMS does.
getAsset returns the asset object right away, so the url property is the file’s public path while the file is being retrieved from the repository. Once it has been retrieved, toPreview is called again, and the new preview replaces the previous one, which receives the Unmount event if it’s a DOM element.
Supporting HTML
A RichText field with the format option set to html saves its content as HTML, so the Markdown syntax defined with pattern, fromBlock and toBlock doesn’t apply there. A component is only available in such a field if it also defines its HTML syntax with the following properties:
htmlSelector(string): A CSS selector to identify existing instances of the component in the HTML content, such asaside.note. The outermost matching element is the component, including its content.- Each selector in a selector list has to name the element type it matches, such as
figureora:has(> img), img, because the editor finds the component by those types. A selector like.noteis invalid. - An element of those types that isn’t an instance of the component, such as an
<aside>without the class foraside.note, is handled as if there was no component: the editor imports it if it can, such as a link fora, or the field can only be edited inrawmode otherwise.
- Each selector in a selector list has to name the element type it matches, such as
fromBlockHTML(function): A function that takes a matching element and returns an object mapping field names to their values, for example by reading attributes withgetAttribute(), which returns decoded values, or text withtextContent. It can returnundefinedif the element is not an instance of the component after all, which a selector cannot always tell, like a link that has text besides an image.toBlockHTML(function): A function that takes an object mapping field names to their values and returns a single element matchinghtmlSelector. It can return either an HTML string or anHTMLElementcreated withdocument.createElement(). We recommend the latter, because values set withsetAttribute()ortextContentdon’t have to be escaped, while you must escape the values yourself in a string. The output is also used when the component is copied to the clipboard in the editor, in a Markdown field as well.
The toPreview function works the same way in an HTML field. If it’s omitted, the component’s HTML itself is shown in the preview.
Security Risk
The element passed to fromBlockHTML comes from content edited by users, so treat it as data: read values from it, but don’t insert the element itself or its HTML into the page, for example in a DOM element preview. Doing so would bypass the preview sanitization and could expose your CMS to cross-site scripting (XSS) attacks.
Whether a component is a block or inline one is still determined by pattern, so make sure it matches the element: a block element like <figure> needs a block component, or the editor puts it in a paragraph. Here is the Image with Caption example below with the HTML syntax added, as well as the m flag and anchors in pattern to make it a block component:
/**
* Create a figure element. The values are set as text and attributes, so they don’t have to be
* escaped.
*/
const createFigure = ({ src = '', caption = '' }) => {
const figure = document.createElement('figure');
const img = document.createElement('img');
const figcaption = document.createElement('figcaption');
img.setAttribute('src', src);
img.setAttribute('alt', '');
figcaption.textContent = caption;
figure.append(img, figcaption);
return figure;
};
CMS.registerEditorComponent({
id: 'figure',
label: 'Image with Caption',
icon: 'photo',
fields: [
{ name: 'src', label: 'Image', widget: 'image' },
{ name: 'caption', label: 'Caption' },
],
// Markdown syntax
pattern: /^{{< image src="(?<src>.*?)" caption="(?<caption>.*?)" >}}$/m,
toBlock: ({ src = '', caption = '' }) => `{{< image src="${src}" caption="${caption}" >}}`,
// HTML syntax
htmlSelector: 'figure',
fromBlockHTML: (element) => ({
src: element.querySelector('img')?.getAttribute('src') ?? '',
caption: element.querySelector('figcaption')?.textContent ?? '',
}),
toBlockHTML: createFigure,
// The same element works as the preview, which is displayed as is
toPreview: createFigure,
});In an HTML field, the component is saved as <figure><img src="/images/photo.jpg" alt=""><figcaption>A photo</figcaption></figure>, with any special characters in the caption escaped by the browser. Since the element is built with text and attributes only, it’s also safe to display as the preview, which isn’t sanitized as a DOM element, and the image source is replaced with a URL that works as described in Displaying Assets. The <img> element within the <figure> is part of the component, so the built-in image component doesn’t take it.
Using Components
Once registered, custom editor components can be used in any RichText or Markdown field, while a RichText field with the html format only offers the components that support HTML. By default, all built-in and custom components are included. You can restrict which components are available by adding their id to the field’s editor_components array in the collection configuration.
For example, to allow only the built-in image component and custom callout and youtube components:
fields:
- name: content
label: Content
widget: richtext
editor_components: [image, callout, youtube][[fields]]
name = "content"
label = "Content"
widget = "richtext"
editor_components = ["image", "callout", "youtube"]{
"fields": [
{
"name": "content",
"label": "Content",
"widget": "richtext",
"editor_components": ["image", "callout", "youtube"]
}
]
}{
fields: [
{
name: "content",
label: "Content",
widget: "richtext",
editor_components: ["image", "callout", "youtube"],
},
],
}Examples
Callout
The following example demonstrates how to register a custom editor component for a “Callout” block:
CMS.registerEditorComponent({
id: 'callout',
label: 'Callout',
icon: 'campaign',
fields: [
{ name: 'type', label: 'Type', widget: 'select', options: ['info', 'warning', 'error'] },
{ name: 'message', label: 'Message' },
],
pattern: /^:::callout (\w+)\n([\s\S]+?)\n:::/m,
fromBlock: (match) => ({
type: match[1],
message: match[2].trim(),
}),
toBlock: (data) => `:::callout ${data.type}\n${data.message}\n:::`,
toPreview: (data) => `:::callout ${data.type}\n${data.message}\n:::`,
});In this example, the “Callout” component allows users to insert a callout block with a specified type (info, warning, or error) and a message. The pattern regular expression is used to identify existing callout blocks in the Markdown content, while the fromBlock and toBlock functions handle the conversion between the component's data and its Markdown representation.
File Link
This example demonstrates how to create a custom editor component for inserting a file link using the built-in file field type:
CMS.registerEditorComponent({
id: 'file-link',
label: 'File Link',
icon: 'attach_file',
fields: [
{ name: 'file', label: 'File', widget: 'file' },
{ name: 'text', label: 'Text to Display', default: '{{file}}' },
],
pattern: /<a href="([^"]+?)" data-file-link>([^\n]+?)<\/a>/,
fromBlock: (match) => ({
file: decodeURI(match[1]),
text: match[2],
}),
toBlock: (data) => `<a href="${data.file}" data-file-link>${data.text}</a>`,
toPreview: (data) => `<a href="${data.file}" data-file-link>${data.text}</a>`,
});Collapsible Note
Here’s an example of a collapsible “Note” component:
CMS.registerEditorComponent({
id: 'note',
label: 'Note',
icon: 'note_alt',
fields: [
{ name: 'summary', label: 'Summary' },
{ name: 'content', label: 'Content', widget: 'richtext' },
],
pattern: /^<details>\s*<summary>(?<summary>.+?)<\/summary>\s*(?<content>[\s\S]+?)\s*<\/details>/m,
toBlock: ({ summary, content }) =>
`<details>\n<summary>${summary}</summary>\n${content}\n</details>`,
toPreview: ({ summary, content }) =>
`<details>\n<summary>${summary}</summary>\n<p>${content}</p>\n</details>`,
});In this example, the “Note” component creates a collapsible section using HTML <details> and <summary> tags. The fromBlock function is omitted because the pattern regular expression uses named capture groups that correspond to the field names. The toBlock function generates the appropriate HTML structure for the note component based on the provided summary and content.
Image with Caption
Here’s an example of a custom editor component for inserting an image with a caption using a Hugo shortcode:
CMS.registerEditorComponent({
id: 'figure',
label: 'Image with Caption',
icon: 'photo',
fields: [
{ name: 'src', label: 'Image', widget: 'image' },
{ name: 'caption', label: 'Caption' },
],
pattern: /{{< image src="(?<src>.*?)" caption="(?<caption>.*?)" >}}/,
toBlock: ({ src, caption }) => `{{< image src="${src}" caption="${caption}" >}}`,
toPreview: ({ src, caption }) =>
`<figure><img src="${src}" alt=""><figcaption>${caption}</figcaption></figure>`,
});The fromBlock function is omitted again because the pattern regular expression uses named capture groups that correspond to the field names. The toBlock function generates a Hugo shortcode for the image with caption, while the toPreview function creates an HTML figure element to display the image and its caption in the editor preview.
Note that the src attribute will be automatically replaced with a blob URL in the editor preview when an image is selected, while the actual file path will be stored in the Markdown content.
Multiple Images with Caption
This example, a variation of the previous one, demonstrates how to create a custom editor component for inserting multiple images with a single caption:
CMS.registerEditorComponent({
id: 'gallery',
label: 'Image Gallery',
icon: 'photo_library',
fields: [
{ name: 'images', label: 'Images', widget: 'image', multiple: true },
{ name: 'caption', label: 'Caption' },
],
pattern:
/<figure>(?<images>(?:<img src=".+?" alt="">)*)<figcaption>(?<caption>.*?)<\/figcaption><\/figure>/,
fromBlock: ({ groups: { images, caption } }) => ({
images:
images?.match(/<img src="(.+?)" alt="">/g)?.map((img) => img.match(/src="(.+?)"/)[1]) ?? [],
caption,
}),
toBlock: ({ images, caption }) =>
`<figure>${
images?.map((src) => `<img src="${src}" alt="">`).join('') ?? ''
}<figcaption>${caption}</figcaption></figure>`,
toPreview: ({ images, caption }) =>
`<figure>${
images?.map((src) => `<img src="${src}" alt="">`).join('') ?? ''
}<figcaption>${caption}</figcaption></figure>`,
});The fromBlock function extracts the image sources from the matched HTML and returns them as an array, along with the caption. The toBlock and toPreview functions, which are identical for demo purposes, generate the appropriate HTML structure for the gallery component based on the provided images and caption.
Code Sample (Object Value)
Most field types hold a primitive value, but some hold an object. The Code field is one of them: unless the output_code_only option is enabled, its value is an object with code and lang keys. The KeyValue and Object fields behave the same way, as does any field with multiple: true — like the gallery above, which holds an array.
The rule is the same for all of them: fromBlock must return the value nested under the field name, and toBlock and toPreview receive it nested.
CMS.registerEditorComponent({
id: 'code-sample',
label: 'Code Sample',
icon: 'code_blocks',
fields: [
{ name: 'title', label: 'Title' },
{ name: 'snippet', label: 'Snippet', widget: 'code' },
],
pattern:
/{{< code-sample title="(?<title>.*?)" lang="(?<lang>.*?)" >}}\n(?<code>[\s\S]*?)\n{{< \/code-sample >}}/,
fromBlock: ({ groups: { title, lang, code } = {} }) => ({
title,
snippet: { code, lang },
}),
toBlock: ({ title = '', snippet: { code = '', lang = 'plain' } = {} }) =>
`{{< code-sample title="${title}" lang="${lang}" >}}\n${code}\n{{< /code-sample >}}`,
toPreview: ({ title = '', snippet: { code = '', lang = 'plain' } = {} }) =>
`**${title}**\n\n\`\`\`${lang}\n${code}\n\`\`\``,
});The shortcode is flat — lang and code are separate attributes — so fromBlock reassembles them into the snippet object the Code field expects, and toBlock takes them apart again.
Destructuring with a = {} default matters here. As noted in Preview Output above, these functions are called with an empty object while the editor is being initialized, and destructuring snippet from it would otherwise throw.
The toPreview function returns a fenced code block rather than <pre><code> markup. Because a string preview is parsed as Markdown, the fence gives you syntax highlighting for free and the code is escaped for you, so a snippet containing < or & is displayed rather than interpreted.
If you customize the Code field’s keys option, use those key names in place of code and lang.
Styled Separator
This is an Eleventy shortcode example for a styled separator component:
CMS.registerEditorComponent({
id: 'separator',
label: 'Styled Separator',
icon: 'horizontal_rule',
fields: [
{
name: 'variant',
label: 'Variant',
widget: 'select',
options: [
{ value: 1, label: 'Standard' },
{ value: 2, label: 'Alternate' },
],
default: 1,
},
],
pattern: /\{\% separator (?<variant>\d+)?\s?\%\}/,
fromBlock: ({ groups: { variant } }) => ({
variant: Number(variant),
}),
toBlock: ({ variant }) => `\{\% separator ${variant || 1} \%\}`,
toPreview: ({ variant }) => renderSeparatorSvg(variant),
});We need fromBlock here because the variant field is a number, and we need to convert the string captured by the regex into a number. The toPreview function uses a helper function to render an SVG representation of the separator based on the selected variant.
YouTube Embed
CMS.registerEditorComponent({
id: 'youtube',
label: 'YouTube',
icon: 'youtube_activity',
fields: [
{ name: 'id', label: 'ID' },
{ name: 'width', label: 'Width', widget: 'number', valueType: 'int', default: 560 },
{ name: 'height', label: 'Height', widget: 'number', valueType: 'int', default: 315 },
],
pattern: /{{< youtube id="(?<id>.*?)"(?: width="(?<width>.*?)" height="(?<height>.*?)")? >}}/m,
fromBlock: ({ groups: { id, width, height } = {} }) => ({
id,
width: width ? Number(width) : 560,
height: height ? Number(height) : 315,
}),
toBlock: ({ id, width = 560, height = 315 }) =>
`{{< youtube id="${id}" width="${width}" height="${height}" >}}`,
toPreview: ({ id, width = 560, height = 315 }) =>
id
? `<iframe src="https://www.youtube-nocookie.com/embed/${id}"
width="${width}" height="${height}" allowfullscreen
allow="autoplay; encrypted-media; picture-in-picture"></iframe>`
: '',
});In this example, the “YouTube” component allows users to embed YouTube videos using a Hugo shortcode. The pattern regular expression captures the video ID, width, and height from the shortcode. The fromBlock function processes the captured values, casting width and height to numbers. The toBlock function generates the shortcode string, while the toPreview function creates an iframe preview of the embedded video.
The pattern uses the m (multiline) flag to make the component block-level, though it’s not multiline in this case.
Inline Link (Dialog Mode)
The dialog mode is ideal for inline elements that would be too disruptive to display as a block within the editor. This example creates a custom link shortcode that appears as a compact inline placeholder and opens a dialog when clicked:
CMS.registerEditorComponent({
id: 'custom-link',
label: 'Custom Link',
icon: 'link',
mode: 'dialog',
summary: '{{text}} — {{url}}',
fields: [
{ name: 'text', label: 'Link Text' },
{ name: 'url', label: 'URL' },
],
pattern: /\[link text="(?<text>.*?)" url="(?<url>.*?)"\]/,
toBlock: ({ text, url }) => `[link text="${text}" url="${url}"]`,
toPreview: ({ text, url }) => `<a href="${url}">${text}</a>`,
});In this example, the “Custom Link” component renders as a small inline chip in the editor showing the link text and URL. Clicking it opens a dialog where the user can fill in or update the fields. The summary template controls what text is shown in the placeholder — here it shows the link text and URL separated by an em dash. When neither the summary nor any string field value is available (e.g. for a freshly inserted component), the component label is shown as a fallback.
Icon with Thumbnail (Dialog Mode)
The thumbnail option displays an image in the placeholder of a dialog mode component, which helps identify an inline element like an icon at a glance. This example creates a Hugo shortcode for an SVG icon:
CMS.registerEditorComponent({
id: 'icon',
label: 'Icon',
icon: 'star',
mode: 'dialog',
thumbnail: 'icon',
fields: [{ name: 'icon', label: 'Icon', widget: 'image', accept: 'image/svg+xml' }],
pattern: /{{< symbol icon="(?<icon>.*?)" >}}/,
toBlock: ({ icon = '' }) => `{{< symbol icon="${icon}" >}}`,
toPreview: ({ icon = '' }) => `<img class="icon" width="24" height="24" src="${icon}" alt="">`,
});In this example, the “Icon” component renders as a small inline chip in the editor showing the selected icon. As the component has no summary and no String or Text field, the chip shows only the image rather than the component label. Add a summary, such as summary: 'Icon' or summary: '{{icon}}', to display text next to the image.
Using React for Preview
You can use React components to create rich, interactive previews for your custom editor components. The toPreview function can return a React element instead of a string, allowing you to leverage React's capabilities for rendering complex previews.
You can write the markup with HTM, JSX or h() calls — see the Writing React Components section for more details.
CMS.registerEditorComponent({
id: 'callout',
label: 'Callout',
fields: [
{
name: 'type',
label: 'Type',
widget: 'select',
options: ['info', 'warning', 'tip'],
default: 'info',
},
{ name: 'content', label: 'Content', widget: 'text' },
],
pattern: /\[(?<type>info|warning|tip)\]\s*(?<content>.*)/gs,
fromBlock: (match) => ({ type: match.groups?.type, content: match.groups?.content }),
toBlock: ({ type = 'info', content = '' }) => `[${type}] ${content}`,
toPreview: ({ type = 'info', content = '' }) => {
const colors = { info: '#0ea5e9', warning: '#f59e0b', tip: '#22c55e' };
const borderColor = colors[type] ?? colors.info;
return html`
<div
style=${{
padding: '0.75em 1em',
borderLeft: `4px solid ${borderColor}`,
background: '#f8fafc',
borderRadius: '0 4px 4px 0',
}}
>
<strong style="text-transform: capitalize">${type}</strong>
<p style="margin: 0.25em 0 0">${content}</p>
</div>
`;
},
});CMS.registerEditorComponent({
id: 'callout',
label: 'Callout',
fields: [
{
name: 'type',
label: 'Type',
widget: 'select',
options: ['info', 'warning', 'tip'],
default: 'info',
},
{ name: 'content', label: 'Content', widget: 'text' },
],
pattern: /\[(?<type>info|warning|tip)\]\s*(?<content>.*)/gs,
fromBlock: (match) => ({ type: match.groups?.type, content: match.groups?.content }),
toBlock: ({ type = 'info', content = '' }) => `[${type}] ${content}`,
toPreview: ({ type = 'info', content = '' }) => {
const colors = { info: '#0ea5e9', warning: '#f59e0b', tip: '#22c55e' };
const borderColor = colors[type] ?? colors.info;
return (
<div
style={{
padding: '0.75em 1em',
borderLeft: `4px solid ${borderColor}`,
background: '#f8fafc',
borderRadius: '0 4px 4px 0',
}}
>
<strong style={{ textTransform: 'capitalize' }}>{type}</strong>
<p style={{ margin: '0.25em 0 0' }}>{content}</p>
</div>
);
},
});CMS.registerEditorComponent({
id: 'callout',
label: 'Callout',
fields: [
{
name: 'type',
label: 'Type',
widget: 'select',
options: ['info', 'warning', 'tip'],
default: 'info',
},
{ name: 'content', label: 'Content', widget: 'text' },
],
pattern: /\[(?<type>info|warning|tip)\]\s*(?<content>.*)/gs,
fromBlock: (match) => ({ type: match.groups?.type, content: match.groups?.content }),
toBlock: ({ type = 'info', content = '' }) => `[${type}] ${content}`,
toPreview: ({ type = 'info', content = '' }) => {
const colors = { info: '#0ea5e9', warning: '#f59e0b', tip: '#22c55e' };
const borderColor = colors[type] ?? colors.info;
return h(
'div',
{
style: {
padding: '0.75em 1em',
borderLeft: `4px solid ${borderColor}`,
background: '#f8fafc',
borderRadius: '0 4px 4px 0',
},
},
h('strong', { style: { textTransform: 'capitalize' } }, type),
h('p', { style: { margin: '0.25em 0 0' } }, content),
);
},
});Using a Framework Component for Preview
The toPreview function can also return a DOM element, which is inserted into the preview as is. This allows you to reuse a component written with Svelte, Vue or any other framework that can be mounted on an element, so the preview matches what your site actually renders.
Because the CMS cannot destroy a component that it didn’t create, it dispatches a custom Unmount event on the returned element once the preview is replaced or removed, the preview pane is closed, or the entry is closed. Listen for that event to tear down your component and avoid memory leaks.
The following example renders a “Warning” component that wraps some body text. Because the body is a nested RichText field, its value arrives as a Markdown string, and the element you return is inserted as is — so **bold** would show up with the asterisks intact unless you render it. The example passes the value to the component, which renders it with the renderRichText method once its element is available — with an attachment in Svelte, or in the onMounted hook in Vue:
import { registerEditorComponent } from '@sveltia/cms';
import { mount, unmount } from 'svelte';
import Warning from '$lib/components/Warning.svelte';
registerEditorComponent({
id: 'warning',
label: 'Warning',
icon: 'warning',
fields: [{ name: 'body', label: 'Body', widget: 'richtext' }],
pattern: /<Warning>\s*(?<body>[\s\S]*?)\s*<\/Warning>/,
toBlock: ({ body = '' }) => `<Warning>\n\n${body}\n\n</Warning>`,
toPreview: ({ body = '' }) => {
const element = document.createElement('div');
const component = mount(Warning, { target: element, props: { body } });
element.addEventListener('Unmount', () => unmount(component), { once: true });
return element;
},
});import { registerEditorComponent } from '@sveltia/cms';
import { createApp } from 'vue';
import Warning from './components/Warning.vue';
registerEditorComponent({
id: 'warning',
label: 'Warning',
icon: 'warning',
fields: [{ name: 'body', label: 'Body', widget: 'richtext' }],
pattern: /<Warning>\s*(?<body>[\s\S]*?)\s*<\/Warning>/,
toBlock: ({ body = '' }) => `<Warning>\n\n${body}\n\n</Warning>`,
toPreview: ({ body = '' }) => {
const element = document.createElement('div');
const app = createApp(Warning, { body });
app.mount(element);
element.addEventListener('Unmount', () => app.unmount(), { once: true });
return element;
},
});<script>
import { renderRichText } from '@sveltia/cms';
let { body } = $props();
</script>
<div class="bg-red-100" {@attach (element) => renderRichText(element, body)}></div><script setup>
import { renderRichText } from '@sveltia/cms';
import { onMounted, onUnmounted, ref } from 'vue';
const props = defineProps(['body']);
const element = ref(null);
let destroy;
onMounted(() => {
destroy = renderRichText(element.value, props.body);
});
onUnmounted(() => {
destroy?.();
});
</script>
<template>
<div class="bg-red-100" ref="element"></div>
</template>Note that this approach requires a build step, so the CMS has to be installed as an npm package and imported into your admin page, rather than loaded from a CDN.
renderRichText returns a function that destroys the rendered content, which the examples call when the component is unmounted: automatically in Svelte, as an attachment’s return value is its cleanup function, and in the onUnmounted hook in Vue. This matters because the nested value may contain other editor components, which are rendered with their own previews and need to be destroyed along with yours.
The output is sanitized regardless of the field’s sanitize_preview option, so the nested value is safe to render even when the CMS has untrusted users. Any image in the nested value keeps working, too, as the method replaces internal image paths with blob URLs just like the preview pane does. If you need an HTML string instead, for example to insert with Svelte’s {@html} tag or Vue’s v-html directive, see Rendering Markdown for the lower-level marked and DOMPurify libraries and the sanitization caveats that apply.
Rendering Comark Components
Comark and MDC, used by Nuxt Content, extend Markdown with a component syntax: ::name{props} opens a block component that ends with ::, and :name[text]{props} is an inline component. Sveltia CMS doesn’t parse this syntax itself, but you can register a custom editor component for each component your site provides, so editors can fill in a form instead of writing the syntax by hand, and let Comark render the preview with the same components as your site.
This example registers an alert block component, whose body can contain other components, and an inline badge component. Their toPreview functions pass the component’s own Markdown to a renderComark function, defined in the next code block; put both in the same file, with renderComark first:
// Comark lets a parent component use more colons than its children, so the closing `::` of a
// nested component doesn’t close the parent
const getFence = (body) =>
':'.repeat(Math.max(1, ...(body.match(/^:{2,}(?=\w)/gm) ?? []).map((c) => c.length)) + 1);
const toAlertBlock = ({ type = 'info', body = '' }) => {
const fence = getFence(body);
return `${fence}alert{type="${type}"}\n${body}\n${fence}`;
};
const toBadgeBlock = ({ text = '', color = 'blue' }) => `:badge[${text}]{color="${color}"}`;
registerEditorComponent({
id: 'alert',
label: 'Alert',
icon: 'info',
fields: [
{
name: 'type',
label: 'Type',
widget: 'select',
options: ['info', 'warning', 'danger'],
default: 'info',
},
{ name: 'body', label: 'Body', widget: 'richtext' },
],
pattern: /^(?<fence>:{2,})alert(?:\{type="(?<type>\w+)"\})?\n(?<body>[\s\S]*?)\n\k<fence>$/m,
fromBlock: ({ groups: { type = 'info', body = '' } = {} }) => ({ type, body }),
toBlock: toAlertBlock,
toPreview: (data) => renderComark(toAlertBlock(data)),
});
registerEditorComponent({
id: 'badge',
label: 'Badge',
icon: 'label',
fields: [
{ name: 'text', label: 'Text' },
{
name: 'color',
label: 'Color',
widget: 'select',
options: ['blue', 'green', 'red'],
default: 'blue',
},
],
pattern: /:badge\[(?<text>[^\]]*)\](?:\{color="(?<color>\w+)"\})?/,
fromBlock: ({ groups: { text = '', color = 'blue' } = {} }) => ({ text, color }),
toBlock: toBadgeBlock,
toPreview: (data) => renderComark(toBadgeBlock(data), { inline: true }),
});The renderComark function returns a DOM element, into which Comark renders the Markdown asynchronously. With Svelte or Vue, it uses Comark’s <Markdown> component with your site’s own components, so it requires a build step, as described in Using a Framework Component for Preview above. Without a build step, it can load @comark/html and render HTML strings, with the components written as functions. Comark doesn’t provide a browser build, so the example uses the ES module that jsDelivr’s +esm endpoint bundles from the npm package on demand. It’s not maintained by Comark, so pin the exact version you’ve tested:
import { registerEditorComponent } from '@sveltia/cms';
import { Markdown } from '@comark/svelte';
import { mount, unmount } from 'svelte';
import Alert from '$lib/components/comark/Alert.svelte';
import Badge from '$lib/components/comark/Badge.svelte';
const components = { alert: Alert, badge: Badge };
const renderComark = (value, { inline = false } = {}) => {
const element = document.createElement(inline ? 'span' : 'div');
const component = mount(Markdown, { target: element, props: { value, components } });
// Comark wraps the output in a `<div>`, which would break the line around an inline component
if (inline) {
element.style.display = 'inline-block';
}
element.addEventListener('Unmount', () => unmount(component), { once: true });
return element;
};import { registerEditorComponent } from '@sveltia/cms';
import { Markdown } from '@comark/vue';
import { createApp, h, Suspense } from 'vue';
import Alert from './components/comark/Alert.vue';
import Badge from './components/comark/Badge.vue';
const components = { alert: Alert, badge: Badge };
const renderComark = (value, { inline = false } = {}) => {
const element = document.createElement(inline ? 'span' : 'div');
// Comark’s `<Markdown>` is an async component, which has to be wrapped in `<Suspense>`
const app = createApp(() => h(Suspense, null, () => h(Markdown, { value, components })));
// Comark wraps the output in a `<div>`, which would break the line around an inline component
if (inline) {
element.style.display = 'inline-block';
}
app.mount(element);
element.addEventListener('Unmount', () => app.unmount(), { once: true });
return element;
};const { registerEditorComponent } = CMS;
// Render each child on its own, as `render()` mistakes a list of children that starts with text for
// a single element
const renderChildren = async (children, render) =>
(await Promise.all(children.map((child) => render([child])))).join('');
const comark = import('https://cdn.jsdelivr.net/npm/@comark/[email protected]/+esm').then(
({ createHtmlRenderer }) =>
createHtmlRenderer({
components: {
alert: async ([, { type = 'info' }, ...children], { render }) =>
`<div class="alert alert-${type}" role="alert">${await renderChildren(children, render)}</div>`,
badge: async ([, { color = 'blue' }, ...children], { render }) =>
`<span class="badge badge-${color}">${await renderChildren(children, render)}</span>`,
},
}),
);
const renderComark = (value, { inline = false } = {}) => {
const element = document.createElement(inline ? 'span' : 'div');
comark.then(async (renderHtml) => {
element.innerHTML = DOMPurify.sanitize(await renderHtml(value));
});
return element;
};With these components, the following Markdown is edited as a form, saved back as is, and rendered in the preview pane by Comark, nested components included:
Intro with a :badge[New]{color="green"} badge.
:::alert{type="warning"}
Outer **bold** text.
::alert{type="info"}
Inner :badge[Hot]{color="red"} text.
::
:::A few things to note:
- The
alertpattern captures the opening colons asfenceand matches the closing line with the\k<fence>backreference, so a block only ends at a line with the same number of colons.toBlockthen picks a fence with one more colon than any component in the body, which keeps the output valid however deeply components are nested.fromBlockis specified to leave thefencegroup out of the field values. - The
mflag makesalerta block component, whilebadge, which has no flag, is matched within a paragraph as an inline component. - Because Comark renders the whole Markdown of
alert, its body included, the nestedalertandbadgeare rendered by Comark as well, withoutrenderRichText. Comark doesn’t know about the CMS, but an image, video or audio it renders is still displayed, even if it hasn’t been published yet, as the CMS replaces the URLs of these elements in the preview. If one of your components displays a file in another way, such as a CSS background image, pass thegetAssetfunction fromtoPreviewtorenderComark, and on to the component, e.g. with Svelte’ssetContextor Vue’sprovide. - Each pattern matches the props in a fixed order, as
toBlockwrites them. If your content has props in a different order, or uses other prop syntax such as{.class}, slots or a YAML props block, capture the whole props string or block with the pattern and parse it infromBlockinstead. - The Svelte and Vue examples don’t sanitize the output, and Comark keeps any raw HTML in the Markdown. See the security risk of DOM element previews if untrusted users have access to your CMS. The vanilla JS example sanitizes the HTML with DOMPurify, which the CMS provides.
Showcase
Real-world examples of editor components can be found in our showcase.