Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Draft: this page sits outside the guide's focus.

Nuxt ContentLesson

MDC components, and when not to use them

Markdown covers prose, but now and then a page needs a block that prose can't express: a callout, an interactive dial, an embedded record. MDC lets a writer place a Vue component right in the Markdown file. It is easy to reach for, and a field in frontmatter often does the same job more safely.

Know MDC already? Skip to In Terrarium.

Outside the box · General

The concept

Nuxt Content parses the body of each Markdown file into a tree, stores it with the Document in its database, and <ContentRenderer> turns that tree into Vue when the page renders. MDC, short for Markdown Components, is the syntax that adds components to that tree.

A block component opens with ::name and closes with ::. What sits between is its default slot, written in ordinary Markdown, and a line starting with #, such as #description, opens a named slot. Props go in braces after the name, as in ::alert{type="warning"}, or as YAML between two --- lines at the top of the block. A component inside a sentence takes one colon, :name. Nested blocks are told apart by their number of colons.

The name is kebab-case and finds its component by name: ::phase-note renders PhaseNote. Components in a components/content/ folder are available to Markdown; any other component must be registered as global first.

A collection's schema describes frontmatter, the fields Nuxt Content stores as columns you can query. Props inside the body are part of the body tree. They get no column, so you can't query them, and no schema describes them.

Full referenceNuxt Content docs: Markdown

Looking inside

In Terrarium

Atlas and Midden, two , use MDC where a block's place in the prose is the point. An Atlas specimen's field note seats its almanac dial with ::almanac, wraps each phase of the creature's year in a :::phase-note, and cites dated sightings inside it:

layers/atlas/content/pool/pages/luciola-mersa.md ↗141–148as of
141:::phase-note{of="few-lights"}
⋯<!-- … -->
144::sighting{date="2026-04-26"}
145::
⋯<!-- … -->
148:::

Their components live in layers/atlas/app/components/content/. A Midden dig report embeds each catalogued find with ::midden-artifact{slug="…"}, which layers/midden/app/components/content/MiddenArtifact.vue looks up in its own 's Collection. The Journal's Routine chart blocks, layers/journal/app/components/content/RoutineChart.vue among them, are a third use: they read data their page provides.

scripts/gate/validate-content-schema.ts checks only a Markdown page's frontmatter against its schema. scripts/gate/validate-content-refs.ts reads these blocks from the body: a phase-note must name a phase the page declares, a sighting a real entry in the Space's ledger, and a midden-artifact a real in the same Space. On a specimen page, every block must also be closed.

When MDC isn't the answer, Terrarium uses frontmatter. docs/agents/mdc-when-to-use.md holds the checklist, and its worked example is the Journal's "New here?" cards. So each page a card opens declares its card in frontmatter, and the Journal's schema checks it:

layers/journal/tenant.config.ts ↗147–154as of
147        // Dashboard on-ramp opt-in (the "New here?" cards on the Space landing).
148        // A page surfaces itself as a card by setting `onramp` to its sort order
149        // (lowest first); `onrampLabel`/`onrampBlurb` carry the card's teaser copy,
150        // kept distinct from the page's own title/description. Ordinary pages omit
151        // all three. Consumed in app/pages/t/journal/[space]/index.vue.
152        onramp: z.number().int().positive().optional(),
153        onrampLabel: z.string().optional(),
154        onrampBlurb: z.string().optional(),

.claude/rules/mdc-when-to-use.md points an agent at the checklist whenever it edits a content component or a manifest.

Why it's built this way

When to reach for MDC, and when frontmatter fits better: docs/agents/mdc-when-to-use.md.

What to watch for

  • A typo in a prop passes the build. Nothing checks the body against a schema, so a misspelt prop renders nothing or the wrong thing. Terrarium's body check covers only the blocks it names; a new component gets none until someone adds it to scripts/gate/validate-content-refs.ts.
  • A missing closing :: raises no error outside specimen pages. The block can quietly turn into plain text, and the page still renders.
  • The block renders where it sits. A page layout can't move a body block into a sidebar or between sections it draws itself. If the layout decides the place, use a field the layout reads.

Try it in your project

Find HTML you repeat by hand in your Markdown, such as a warning box. Move it into a component with a slot, then write ::callout{type="warning"} and a closing :: around the text instead:

<!-- components/content/Callout.vue -->
<script setup lang="ts">
defineProps<{ type?: 'note' | 'warning' }>()
</script>

<template>
  <aside :class="['callout', type]"><slot /></aside>
</template>

Then ask whether the block's data would be safer as a frontmatter field that your schema checks.

Where next

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/drafts/nuxt-content/mdc-components