Extend Pluto

Author components for the editor

Make a Vue component show in the block palette of a docs page and in the post editor, with typed fields and help text.

The visual editor lets editors add blocks to a page. A block is a component. This guide shows how to make your own component a block. It works for docs pages, for other pages of a Nuxt Content site, and for posts.

This needs @plutocms/core 0.66 or newer. The block palette of a docs page also needs @plutocms/docs 0.23 or newer.

Where the file goes

Put the component in components/content/. This is the folder where Nuxt Content looks for the components that Markdown uses.

The folder can be in your app or in a layer. The editor reads the folder of the app and the folder of every layer. If two files have the same name, the app wins.

The editor skips two kinds of file. It skips Prose* files, because they replace the Markdown elements. It also skips .client and .server variants.

Add the macro

A component shows in the palette only when it calls definePlutoBlock. A component without the call still works on a page. The palette does not show it.

The macro is an auto-import. Call it in <script setup>. You do not need an import line.

This is PlutoChip, from this site:

app/components/content/PlutoChip.vue
<script setup lang="ts">
definePlutoBlock<{ icon?: string, center?: boolean }>({
  label: 'Chip',
  icon: 'i-lucide-tag',
  group: 'landing',
  description: 'A short label above a section title.',
  props: {
    center: { label: 'Center the label' }
  },
  slots: { default: { label: 'Text', content: 'inline' } },
  insert: '::pluto-chip{icon="i-lucide-heart"}\nA short label\n::'
})

withDefaults(defineProps<{
  /** The icon before the text, such as `i-lucide-heart`. */
  icon?: string
  /** Put the label in the middle of the line. */
  center?: boolean
}>(), {
  icon: undefined,
  center: false
})
</script>

All keys are optional. definePlutoBlock({}) is valid. The props and the slots still come from the component.

KeyWhat it does
labelThe name in the palette. Default: the component name in words.
descriptionThe short text under the name in the palette.
iconAn Iconify name, such as i-lucide-tag.
groupThe palette group: text, callouts, media, layout, code, landing, or site. Another value makes a new group.
propsChanges to single props. See below.
slotsThe slots, with a label, a content kind, and accepts.
insertThe Markdown that the palette adds.
previewfalse shows a card in the preview, not the component.
hiddentrue keeps the block out of the palette.

The generic <{ icon?: string, center?: boolean }> is optional. With it, TypeScript checks that each key of props is a prop of the component.

Props become fields

The editor reads the types of defineProps. Each prop with a known type gets a field in the Settings section.

TypeField
stringOne line of text.
string named icon, or ending in IconAn icon field with a preview.
string named to or hrefA link field.
numberA number field.
booleanA switch.
A union of string literals, such as 'slow' | 'fast'A list of choices, in the order of the type.
A union of theme color namesA list of the theme colors.
An object, an array, or a functionNo field.

A prop with no field is not in the panel. If a page sets it, the prop shows in the Advanced section.

To choose the field yourself, set type in the entry of the prop in props. The types are string, text (a box with more lines), boolean, number, select (add options), icon, color, link, and media (a file of the media library; add fills: { width, height, alt } to also set those props from the file).

To move a prop to Advanced, set advanced: true in its entry in props.

Help text from JSDoc

Write a JSDoc comment above a prop. The editor shows the text as help under the field.

defineProps<{
  /** How many layers to show. */
  limit?: number
}>()

Keep the text short. Use plain words.

Defaults

A simple default shows in the field. A number shows as the placeholder. A list of choices shows the value in the Default item. A switch shows its default state.

withDefaults(defineProps<{
  /** How many layers to show. */
  limit?: number
}>(), { limit: 6 })

The default never goes into the Markdown. A page keeps its text until an editor changes the field.

A default must be a plain value, such as 6, 'slow', or false.

Slots

The editor reads the slots of the template. Each slot gets a field in the Content section.

A slot that has the mdc-unwrap attribute gets a one-line field. The attribute removes the paragraph around the text. Use it for a title or a label.

<slot mdc-unwrap="p" />

A slot without mdc-unwrap gets a text box, or a list of blocks, based on the content of the page.

To set the field yourself, use slots in the macro. Use content: 'blocks' for a list of blocks. Add accepts to limit the components that can go in the list.

slots: {
  links: { label: 'Buttons', content: 'blocks', accepts: ['u-button'] }
}

PlutoHero on this site uses this for its buttons.

Change a block in app.config.ts

The macro is the main place for the settings of a block. The site can still override it. Put an entry in plutoBlocks in app.config.ts. It wins over the macro. Set a prop to false to remove its field.

app/app.config.ts
export default defineAppConfig({
  plutoBlocks: {
    'pluto-chip': {
      label: 'Tag',
      props: { center: false }
    }
  }
})

The entry changes only the keys that it names. The other keys keep the value from the component. An entry can also add a component that does not call the macro.

The order, from lowest to highest, is:

  1. The preset of the layer.
  2. The component: its types and its macro.
  3. plutoBlocks in app.config.ts.

Labels in more than one language

A label or a description can be plain text or an i18n key. If the key exists in your locale files, the editor shows the translation. If not, it shows the text.

The cache

The build reads the macro in each file. It keeps the result in .nuxt/cache/pluto-blocks.

In dev, a change to a component file updates the palette. A change to a file that the component imports does not. This is true for a shared type file. After such a change, delete the .nuxt/cache/pluto-blocks folder and restart the dev server.

Limits

  • The argument of definePlutoBlock must be an object literal. It can use constants that you import from a .ts file. It cannot use a variable from <script setup>.
  • If the build cannot read the call, it logs one warning with the file name. The component is not in the palette. The rest of the build goes on.

Components in posts

The same components can go in a post, on every backend. The post editor shows a component as a box with its name. A click on the box opens a form with the fields of its props. The site and the live preview show the component.

The post editor uses the same rules as the block palette:

  • The component is in components/content/ and calls definePlutoBlock.
  • label, icon and description name it in the Insert a component menu and in the box. hidden: true keeps it out of the menu.
  • The props become fields, with the types, the JSDoc text and the defaults above.

A post holds a component with props only, at the top level:

::promo-card{title="Spring sale" :count='3' featured}
::

A component with content, or in another component, shows as text on the site, and the post opens in Markdown mode.

Safe props

An author writes the props, so the site gives only safe props to the component. The site removes:

  • A prop with a name that starts with on, and the props style, key, ref, is, innerHTML, textContent, innerText, and outerHTML.
  • A javascript:, vbscript: or data: URL, also in a list or an object. An image data: URL stays.
  • A value that is not text, a number, true or false, or a list or an object of them.

A name that no component has shows nothing on the site. The editor shows Not found on its box.