Skip to main content

Add a custom Markdown component

Register a global Vue component, map its MDC tag, declare a narrow content policy, and optionally serialize it for agents.

A custom MDC tag crosses three explicit boundaries: Vue renders it, the Markdown tag map resolves it, and the component policy decides which authored input is safe. Add an agent serializer only when the visual component needs a different text representation.

This example adds ::api-playground with a required HTTP method and path.

Create the Vue component

app/components/mdc/MdcApiPlayground.vue
<script setup lang="ts">
defineProps<{
  method: string;
  path: string;
}>();
</script>

<template>
  <section class="not-prose rounded-lg border border-border p-5">
    <p class="font-mono text-sm font-semibold">{{ method }} {{ path }}</p>
    <div class="mt-3 text-sm text-muted-foreground">
      <slot />
    </div>
  </section>
</template>

MDC resolves tag targets dynamically. Register the component globally in a small Nuxt plugin:

app/plugins/mdc-components.ts
import MdcApiPlayground from "~/components/mdc/MdcApiPlayground.vue";

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.component("MdcApiPlayground", MdcApiPlayground);
});

A .global.vue component is an alternative. Do not add a second Docs-specific component registry.

Extend the tag map and policy

Keep the layer's built-in mappings, then add the authored tag. Declare only the props and slots this component needs:

nuxt.config.ts
import { ginkoDocsComponentTags } from "@lupinum/ginko-docs/components";

export default defineNuxtConfig({
  extends: ["@lupinum/ginko-docs"],
  content: {
    componentPolicy: {
      components: {
        "api-playground": {
          kind: "block",
          props: {
            method: { type: "string", required: true },
            path: { type: "string", required: true },
          },
          slots: ["default"],
          media: null,
        },
      },
    },
    markdown: {
      tags: {
        ...ginkoDocsComponentTags,
        "api-playground": "MdcApiPlayground",
      },
    },
  },
});

The policy is the public authoring boundary, not a convenience schema. Dynamic Vue bindings remain rejected in public Markdown. Avoid broad prop bags, arbitrary slots, or media access unless the component has a concrete use for them.

Authors can now use the tag:

content/docs/api.md
::api-playground{method="GET" path="/v1/projects"}
Returns the projects visible to the current token.
::

Add agent Markdown when needed

The default agent renderer keeps unknown component structure explicit. Register a serializer only when a compact text form is more useful. The serializer runs on the server and must return deterministic public content.

server/plugins/api-playground-markdown.ts
import {
  registerAgentMarkdownSerializers,
  type AgentMarkdownSerializer,
} from "@lupinum/ginko-content/agent-registry";
import { defineNitroPlugin } from "nitropack/runtime";

const apiPlaygroundMarkdown: AgentMarkdownSerializer = (node, context) => {
  const props = context.cleanProps(node);
  const method = String(props.method || "GET");
  const path = String(props.path || "/");
  const details = context.renderChildren(node).trim();

  return [`**${method} ${path}**`, details].filter(Boolean).join("\n\n");
};

export default defineNitroPlugin(() => {
  registerAgentMarkdownSerializers({
    "api-playground": apiPlaygroundMarkdown,
    MdcApiPlayground: apiPlaygroundMarkdown,
  });
});

Register both the authored tag and component alias because the parsed tree can contain either name. Test the rendered component and its /raw/**.md output before publishing. The content configuration reference documents the policy shape.