A Field Guide to Markdoc

Every post on this blog is a .mdoc file rendered by Markdoc — a Markdown superset with a real parser, a validated schema, and extensible tags. Plain Markdown is valid Markdoc, which is what makes it a good fit here: bodies drafted in the CMS come out as ordinary Markdown and render without ceremony, while the parts that need more than Markdown get it.

This post is the living reference for what that setup can do. Every element below is rendered by the same pipeline a normal post goes through — nothing is special-cased — so it doubles as a smoke test: if this page renders well, the content pipeline works.

Prose, the everyday stuff

Ninety percent of writing is just prose, and Markdoc handles it with the Markdown you already know: bold, italic, inline code, and links. Inline code that would normally wreck a line layout — like src/content/blog/markdoc-field-guide.mdoc — word-wraps instead of overflowing the column.

The syntax never fights you, even when the subject matter is the syntax: mentioning a tag like {% youtube %} in backticks renders it as inert text.

Headings

Body sections start at ##. The post title is rendered once by the page header, and a transform in markdoc.config.ts drops any body-level h1, so every page keeps exactly one <h1>. Deeper levels nest normally:

A third-level heading

Headings are the only structural elements with restrictions here — everything below flows like any Markdown document.

Lists

Unordered, ordered, and nested — markers restored by the prose styles, since the CSS reset strips them by default:

  • Static output, so nothing runs at request time
  • Authored in Git or through the CMS
    • Editorial workflow drafts open a PR
    • Publishing merges it, which triggers the build
  • Deployed to Cloudflare Pages

And ordered, for things with a sequence:

  1. Write a .mdoc file (or fill the CMS form)
  2. Commit — or hit Publish in the CMS
  3. The build renders Markdoc to static HTML

Quotes

Blockquotes get the full editorial treatment — centered, italic, with an oversized quotation mark sitting behind the text:

Markdoc is a lightweight, text-based markup language and a tool for generating web pages and documentation. It combines the simplicity and readability of Markdown with flexibility and extensibility.

— The Markdoc docs

Tables

Pipe tables render with borders and a tinted header row. The handiest table for this blog is the reference for its five custom Markdoc tags, each defined in markdoc.config.ts and rendered by an Astro component:

TagRendersKey attributes
{% image %}Figure with caption + creditsrc, alt (required); caption, credit, creditUrl
{% youtube %}Privacy-friendly video embedurl or id
{% tweet %}X post as a build-time static cardurl or id
{% instagram %}Instagram post (client-side)url or id
{% embed %}Generic escape hatch of the aboveprovider (required), url or id

Code

Fenced blocks are picked up by a Markdoc fence transform that drives the Expressive Code engine directly — syntax highlighting in a light and a dark theme keyed to the site's theme toggle, line numbers in a non-selectable gutter, word wrap for long lines, and a capped height so a tall block scrolls instead of stretching the page. Code is also excluded from site search, so copy-pasted fragments never surface as search results.

// markdoc.config.ts — every custom tag is three lines of config
youtube: {
render: component('./src/components/YouTubeEmbed.astro'),
attributes: { id: { type: String }, url: { type: String } },
selfClosing: true,
},

All languages share one unified look — no terminal-style special casing:

/* The theme toggle is a data attribute, so code themes key off it too */
html[data-theme='dark'] .expressive-code {
/* monokai, selected to match the site palette */
}
Terminal window
# The whole pipeline, end to end
pnpm build # astro build renders the Markdoc, then…
pnpm exec pagefind --site dist # …the static site gets a search index

A fence with no language at all renders as plain text — which is how this post shows raw tag syntax without it executing:

{% image src="https://example.com/photo.jpg" alt="describe it" caption="optional" credit="Photographer" /%}
{% youtube url="https://www.youtube.com/watch?v=…" /%}
{% tweet url="https://x.com/user/status/…" /%}
{% instagram url="https://www.instagram.com/p/…/" /%}
{% embed provider="youtube" id="…" /%}

Images

The {% image %} tag wraps a remote image in a <figure> with an optional caption and an attribution line. alt is required — it is a caption for screen readers and image search, not decoration. For Unsplash imagery, a credit renders "Photo by {name} on Unsplash", with creditUrl linking the photographer.

A laptop on a wooden desk showing lines of code in a dark editor, photographed from above
An entire publishing stack, viewed from above: a `.mdoc` file, a build command, and nothing else to keep running.
Photo by Maik Jonietz on Unsplash

Embeds

The social tags keep rich content in a plain-text file. A YouTube URL (or bare video ID) becomes a youtube-nocookie.com embed:

An X post is baked at build time into a static, content-sized card — the syndication endpoint is fetched during the build, so readers load no third-party script for it. If the fetch fails, the tag falls back to X's own iframe embed, and the site always builds:

jack@jack

just setting up my twttr

18,061 replies310,940 likes

Instagram embeds hydrate client-side, lazily, when the post scrolls near the viewport:

View this post on Instagram

And {% embed %} is the generic form of all three — same components, spelled out with an explicit provider, for when the specific tag would be redundant or the provider is easier to express as data:


Under the hood

Three node transforms and five tags, all in markdoc.config.ts at the project root, account for everything on this page:

  • document renders as a <div>, not <article> — the post layout already wraps the body in a single <article class="h-entry">, and nested articles confuse reader modes.
  • heading drops level 1 — one <h1> per page, rendered by the post header.
  • fence renders through Expressive Code — Markdoc's renderer bypasses Astro's rehype pipeline, so the highlighting is driven from the transform instead.

Frontmatter is validated by a Zod schema (title, description, pubDate, tags, and a few optional fields), so a malformed post fails the build rather than shipping half-rendered. Draft posts are filtered out before anything renders — they never reach the sitemap, the search index, or the feed.

That is the whole trick: Markdown where Markdown is enough, tags where it isn't, and a schema keeping both honest.