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:
- Write a
.mdocfile (or fill the CMS form) - Commit — or hit Publish in the CMS
- 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.
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:
| Tag | Renders | Key attributes |
|---|---|---|
{% image %} | Figure with caption + credit | src, alt (required); caption, credit, creditUrl |
{% youtube %} | Privacy-friendly video embed | url or id |
{% tweet %} | X post as a build-time static card | url or id |
{% instagram %} | Instagram post (client-side) | url or id |
{% embed %} | Generic escape hatch of the above | provider (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 configyoutube: { 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 */}# The whole pipeline, end to endpnpm build # astro build renders the Markdoc, then…pnpm exec pagefind --site dist # …the static site gets a search indexA 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.
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:

just setting up my twttr
Instagram embeds hydrate client-side, lazily, when the post scrolls near the viewport:
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:
documentrenders 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.headingdrops level 1 — one<h1>per page, rendered by the post header.fencerenders 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.