Docs menu

Markdoc and MDX

How the body editor shows Markdoc tags and MDX components as blocks, what it keeps exactly as written, and what stops a save.

The body editor

An entry's body opens in a writing view: paragraphs, headings, lists, quotes, code, links and images are edited as formatted text. "Edit as Markdoc" (or MDX, or Markdown) switches to the whole body as text, exactly as it's saved, and "Back to the writing view" returns.

A save rewrites only the blocks you changed. Untouched blocks keep their bytes, and so does the space between them. A changed block is written in one house style. Vesper reads the body with the same parser and options your site builds with, so the studio and the build see the same document.

Markdoc tags as blocks

The studio reads the tags in markdoc.config.mjs, as @astrojs/markdoc does, and builds each tag's block from them. There's nothing to declare twice.

// markdoc.config.mjs
import { component, defineMarkdocConfig } from '@astrojs/markdoc/config';

export default defineMarkdocConfig({
  tags: {
    callout: {
      render: component('./src/components/Callout.astro'),
      children: ['paragraph', 'list'],
      attributes: {
        type: { type: String, default: 'note', matches: ['note', 'tip', 'warning'] },
        title: { type: String },
      },
    },
    youtube: {
      render: component('./src/components/YouTube.astro'),
      selfClosing: true,
      attributes: {
        id: { type: String, required: true },
        title: { type: String, required: true },
      },
    },
    abbr: {
      render: component('./src/components/Abbr.astro'),
      inline: true,
      attributes: { title: { type: String, required: true } },
    },
  },
});

How a tag sits in the body decides how it shows:

In the bodyIn the writing view
An opening line, content, then a closing lineA frame. Its content stays editable, and only what the tag's children lists fits inside.
Self-closing, or with its content on the opening lineA card: one piece, with its settings
Inside a line of text (inline: true)A chip in the text

Each frame and card is named after its tag, with a Settings button. Its settings are the tag's attributes, with the same controls as frontmatter fields:

AttributeControl
String with a list of matchesRadio buttons for four values or fewer, otherwise a select
String with a pattern in matchesA text field checked against the pattern
String, or a custom typeA text field
NumberA number field
BooleanA switch
Array, Object or anything elseA raw YAML field

Changes apply as you make them, so Ctrl+Z undoes them like typing. Changing a setting changes only that attribute's bytes in the tag. The rest of the tag, its content and its closing stay as written. A new tag is written with its attributes in schema order, leaving out empty values and defaults.

On the keyboard: arrow onto a block to select it, Enter opens its settings, Alt+↑ and Alt+↓ move it, and Delete removes it. Inside a frame, Escape selects the frame, and Escape again moves to the toolbar. Type "/" at the start of a line or after a space for a list of the blocks that fit where you are. The toolbar's Insert menu has the same list.

Hints in vesper.config.ts

Names and help text for the studio live in vesper.config.ts, never in markdoc.config.mjs:

// vesper.config.ts
markdoc: {
  tags: {
    callout: {
      label: 'Callout',
      summary: 'type',
      attributes: {
        type: { labels: { note: 'Note', tip: 'Tip', warning: 'Warning' } },
        title: { description: 'Shown above the callout. Leave it empty to use the type.' },
      },
    },
    youtube: {
      label: 'YouTube video',
      summary: 'title',
      attributes: {
        id: { label: 'Video ID', description: 'The part after “v=” in the video’s address.' },
        title: { description: 'Describes the video for screen reader users.' },
      },
    },
  },
},
  • label is the block's name. Without one, it's the tag's name in sentence case.
  • description is help text for the tag.
  • summary names the attribute whose value shows in the block's header, as in "Callout · Warning".
  • Each attribute can have a label, a description, labels for its matches values, and a widget to choose its control by name. A widget applies as it would to a schema field of the attribute's type: a String attribute can take textarea, url, color or a custom widget. Object and Array attributes are edited as YAML, so a widget such as geo doesn't apply to them; naming one warns in the terminal and keeps the YAML field.

Partials and conditionals

Markdoc's partial tag is a card with a file picker. It lists .mdoc files whose names start with _ or that sit in a partials folder, nearest the entry first, and stores the path relative to the entry, as Astro resolves it.

A conditional is a frame named "Conditional content". Its content is editable, and its settings hold the condition as Markdoc writes it, such as $product.beta or equals($tier, "free"). An else inside it shows as "Otherwise":

{% if $product.beta %}
This feature is in beta, and may change.
{% else /%}
This feature is ready to use.
{% /if %}

The "/" list doesn't offer conditionals. Write a new one in the source view.

Kept as written

Some things aren't safe to retype in a writing view, so the studio protects them. It never reformats them.

  • Variables, functions and annotations are chips in the text. Their settings are a small text field, edited as written.
  • Everything else the writing view can't show faithfully is a block named in words, such as "Table, kept as written": tags markdoc.config.mjs doesn't define, HTML, comments, tables, footnotes and reference-style links. You edit it as raw text, in its own dialog or in the source view.

What stops a save

The studio checks the body as you type. A problem stops the save only when your site's build would fail on it:

  • a required attribute that's missing
  • a value the attribute's type or matches doesn't allow
  • an attribute the tag doesn't have
  • a partial whose file doesn't exist
  • MDX props that fail their schema

Each message names the block, as in "YouTube video: Title is required."

Other problems are warnings. Markdoc's own warnings stay warnings, such as content a tag's children doesn't list. Undefined variables aren't reported at all, because pages can pass variables in.

Tags and functions the site doesn't define depend on the mode. In local mode, the dev server checks every save again with your real markdoc.config.mjs. Unknown tags and functions then stop the save, and custom attribute types and validate functions run too. On the deployed site, in GitHub mode and the cloud, the studio checks with what it knows of your config, so unknown tags only warn, and your site's build is the last check for custom validators.

Tags and declared components also get accessibility notes from their own schema. A required title, alt, label or aria-label left empty is a note, and a tag with a captions attribute asks you to confirm captions when it's absent or off. See Accessibility.

MDX components

MDX components don't declare their props anywhere the studio can read. So a component is a block only when you declare it in vesper.config.ts, with a Zod props schema from astro/zod:

// vesper.config.ts
import { defineConfig } from '@vesper/astro/config';
import { fields } from '@vesper/astro/fields';
import { z } from 'astro/zod';

export default defineConfig({
  mdx: {
    components: {
      Callout: {
        label: 'Callout',
        summary: 'type',
        children: true,
        props: z.object({
          type: fields.select({ note: 'Note', tip: 'Tip', warning: 'Warning' }).default('note'),
          title: z.string().optional(),
        }),
      },
      YouTube: {
        label: 'YouTube video',
        props: z.object({ id: z.string(), title: z.string() }),
      },
    },
  },
});
<Callout type="tip" title="Before you apply">
Check the deadline for your program.
</Callout>

<YouTube id="abc123" title="A walk across campus" />
  • children: true makes the component a frame whose content stays editable. Without it, the component is a card.
  • inline: true puts it inside a line of text instead.
  • label, description and summary work as they do for Markdoc tags. Props get the same controls as frontmatter fields, so fields.* helpers and .meta() hints work there too.
  • The studio checks props with your own schema, in every mode. The schema loads the first time an MDX body needs it.
  • A prop that holds code, such as a function, or a spread of props, is shown read only and kept byte for byte. A component with a spread isn't checked.
  • Undeclared components, expressions, imports and exports are kept as written.

This page as Markdown, for AI tools.