Skip to main content

Use Vue components in Markdown

Render Vue components, props, and slots from Markdown with MDC syntax.

Create a Vue component, then invoke it from Markdown with a ::tag block.

app/components/Callout.vue
<script setup lang="ts">
import { useSlots } from 'vue'

defineProps<{
  title: string
  tone?: 'info' | 'warning'
}>()

const slots = useSlots()
</script>

<template>
  <aside :data-tone="tone ?? 'info'">
    <strong>{{ title }}</strong>
    <div><slot /></div>
    <footer v-if="slots.actions"><slot name="actions" /></footer>
  </aside>
</template>

Allow the component, its props, and its slots in the runtime component policy:

nuxt.config.ts
export default defineNuxtConfig({
  content: {
    componentPolicy: {
      components: {
        callout: {
          kind: 'block',
          props: {
            title: { type: 'string', required: true },
            tone: { type: 'string', required: false }
          },
          slots: ['default', 'actions'],
          media: null
        }
      }
    }
  }
})

The policy is a closed allowlist used by rendering and portability checks. Undeclared components, props, and slots are rejected.

content/guide.md
# Guide

::callout{title="Before you deploy" tone="warning"}
Run the production build and test a direct nested route.
::

The kebab-case tag maps ::callout to Callout.vue. Declared attributes inside {} become props. The block content fills the default slot.

Fill named slots

Open each named slot with #slot-name:

content/features.md
::callout{title="Before you deploy" tone="warning"}

#default
Run the production build and test a direct nested route.

#actions
[Open the deployment guide](/docs/resources/deployment)
::

The slot name must also appear in componentPolicy. Nuxt must discover the component before MDC can render it; app/components/ works with Nuxt's default component discovery.

Keep the words inside each component meaningful on their own. The same Markdown then remains readable in a CMS, a text editor, or another renderer that does not load the Vue component.

Author angle components

The same declared component accepts angle syntax. Use PascalCase when a name also names an HTML element: <Callout> is a component, and lowercase native HTML keeps its native behavior.

content/guide.md
<Callout title="Before you deploy" tone="warning">
Run the **production build** before deployment.
<template #actions>
[Open the deployment guide](/docs/resources/deployment)
</template>
</Callout>

Quoted props are strings. A bare prop is true. A : binding accepts JSON literals, such as :count="3", :enabled="false", or :options='{"mode":"safe"}'. It does not evaluate JavaScript expressions. Declare every prop in the component policy and in the Vue component.

Use parseMdcDocument(source, { autoClose: false }) from ./cms-contract when saving angle-authored content. This rejects incomplete or mismatched angle tags, duplicate angle props and slots, and invalid angle bindings. Also validate the resulting body against the component policy before saving; parsing alone does not establish render safety.

The default parser completes unfinished delimiters for an editing preview. Completion changes only the parsed result, not the source. It ignores component markers inside fenced code, so a code sample that contains ::card does not receive a closing ::.

serializeMdcDocument() writes Markdown that parseMdcDocument(markdown, { autoClose: false }) reads back to the same public body:

  • Text that the parser would read as syntax gets a backslash, such as \:fire, \::card, a\{.b}, or \#slot. Ordinary colons, as in Note: 10:30, stay unchanged.
  • Inline code and fenced code stay verbatim.
  • A heading id is written as {#id} only when it differs from the id that the parser generates. createHeadingIdGenerator() returns the same ids.
  • Text that looks like a URL, such as https://a.com or <https://a.com>, stays text. The serializer writes https\://a.com.
  • Text that site content would link, such as a.com, README.md, or a@b.com, gets one backslash, such as a\.com. The editor and the site then both show it as text.
  • A component prop keeps its JSON type and its exact string value, including quotes and line breaks. A block component in colon syntax writes such values in a YAML block, and typed values always in the --- form. An inline component switches to angle syntax and encodes them as character references, such as &quot; and &#10;.
  • A line break inside a heading is written as <br>.
  • A thematic break is written as ***.
  • Image alt text that cannot be a label is written as an alt attribute. A link destination with a space or parenthesis is written in <...>.

A quoted colon attribute value that starts with [ or {, such as {items="[1, 2]"}, reads as JSON. A quoted YAML value, such as items: "[1, 2]", and a quoted angle value, such as items="[1, 2]", stay strings. The serializer writes such a string prop in one of these forms.

serializeMdcDocument() throws MdcSerializationError when no syntax can hold a value. Examples are a prop string such as "[1, 2]" in the attribute list of a native Markdown element, and a property name such as a b in angle syntax. The error path points to the prop in document.nodes. It is empty when the serializer finds the problem only while it writes the output, such as image alt text or an inline property name that has no written form.

Whitespace that Markdown cannot represent is not kept. Examples are spaces at the start or end of a paragraph and around a line break. Attributes on a native list, table, quote, or code block are written as a ::tag{...} wrapper, which the parser reads as a component with that name.

parseMdcDocument(), parseMdcBody(), and portable import link only URLs with an http:, https:, or mailto: scheme. Bare domains, email addresses, //host URLs, and ftp: URLs stay text. Site content in the filesystem provider keeps the default link recognition. Text written by serializeMdcDocument() is escaped, so it stays text there too.

A heading id contains only ASCII letters, digits, -, and _. A heading with no such character, such as ## 日本語, gets an empty id, or -1 when it repeats. Add an explicit id, such as ## 日本語 {#japanese}, when the heading needs an anchor.

Constrain component values and nesting

Use a V2 policy when a prop accepts multiple types, a fixed set of values, or when components have parent/child restrictions. V1 policies remain supported. Define the V2 policy at content.componentPolicy in Nuxt configuration.

nuxt.config.ts
import type { PortableComponentPolicyV2 } from '@lupinum/ginko-content/cms-contract'

const componentPolicy = {
  version: 2,
  components: {
    callout: {
      kind: 'block',
      props: {
        title: { types: ['string'], required: true, allowedValues: null },
        tone: { types: ['string'], required: false, allowedValues: ['info', 'warning'] }
      },
      slots: ['default', 'actions'],
      allowedParents: null,
      allowedChildren: null,
      media: null
    }
  }
} satisfies PortableComponentPolicyV2

export default defineNuxtConfig({ content: { componentPolicy } })

null leaves the corresponding value or nesting constraint open. allowedValues contains literal prop values. Non-null allowedParents and allowedChildren lists use declared canonical component names. types can contain string, number, boolean, json, or asset. A media policy names the declared prop that contains an asset reference. These rules apply to rendering and portable content validation. V2 resolved contracts use V2 portable manifests; individual portable document records retain their V1 format.