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.
<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:
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.
# 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:
::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.
<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 inNote: 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.comor<https://a.com>, stays text. The serializer writeshttps\://a.com. - Text that site content would link, such as
a.com,README.md, ora@b.com, gets one backslash, such asa\.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"and . - 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
altattribute. 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.
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.