Astro Markdoc: Readable, Declarative MDX Alternative
Tags: astro, webdev
Published: 2023-11-15 16:59:16

---
title: "Astro Markdoc: Readable, Declarative MDX Alternative"
published: "true"
description: "Astro Markdoc 📚 trying Stripe’s customizable, readable, declarative Markdown extension designed for 🖋️ creating documentation content."
tags: "astro, webdev"
canonical_url: "https://rodneylab.com/astro-markdoc/"
cover_image: "https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3t9f772yngb80dudchhd.png"

---
## 🚀 Using Markdoc with Astro

Markdown is a popular choice for authoring on content-rich websites. MDX offers extensions, providing access to React components, for example, within the content source.  In this post, we see why you might reach for Astro Markdoc, instead of Markdown or MDX for your content site.  We see how you can integrate Markdoc into an Astro documentation or **content site**, making full use of your favourite Astro features, such as underlying framework flexibility and **Content Collections** with **front matter validation**.


### Why use Markdoc?

Markdoc, created at Stripe, is a **declarative** alternative, to MDX. Like MDX, it offers power and flexibility for customizing your content.  Where Markdoc excels is in defining component attributes in a schema. This simplifies the tooling and makes writing content, rather than writing code, the focus.  As well as **reducing template complexity**, Markdoc brings the benefits of **validation** and machine-readable markup.

### What does Astro bring to the Party?

The Astro Markdoc integration abstracts away parts of the Markdoc configuration, letting you focus on business logic.  Astro also offers **flexibility**.  We will see a code block syntax highlighting example, adding sample code to a documentation site.  Initially, we let Astro and Markdoc handle creating beautiful code samples for the site.  Then, we see how we can take more control, adding our own Astro component to render the coding blocks using an alternative code highlighting package.

![Astro Markdoc: Screen capture shows browser window, there are Java Script, Python, and Rust code blocks with syntax highlighted.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f2drc9vqojvb1idp47uv.png)

You can even take this a step further and **use Astro components as gateways** to your own existing code block components written in **Preact**, **React**, **Svelte** or **Vue**.  Although we focus on a fenced code block example here, you can apply the techniques to custom-render other nodes. A further Markdoc feature is **custom tags**, though we will save them for another time!

## 🧱 What are we Building?

We make use of Astro Content Collections, allowing Markdoc content files to drive the content of the site.  Content Collections, generate a list of site content for our home page.  Links from that list will take you to the actual content pages, again generated using Content Collections.  We create such a page for each Markdoc file in the project content directory, using our own template.

If you are already familiar with Content Collections, there is little to learn there, as the API works with Markdoc near identically to how it works with Markdown source.  However, you will hopefully  be impressed by how little code we need to go from rendering the code blocks using the built-in Shiki theme to our own custom Astro component.  Here we will use the **starry-night** package, which offers a number of accessible theme alternatives and automatic dark/light mode switching.

## ⚙️ Astro Markdoc Configuration

We won’t build the site from start-to-finish here, though there is a link to the full code repo further down.  Instead, we assume you have an Astro project already set up and are comfortable customizing it.  If you are new to Astro, you might find the <a href="https://rodneylab.com/getting-started-astro/">Getting Started with Astro post provides a gentle introduction</a>.

### Markdoc Astro Integration

To start, run this command from your project folder to fire up the Astro Markdoc integration:

```shell
pnpm astro add markdoc
```

This will add the necessary packages to your project, and also update the config in `astro.config.mjs`.   The updated Astro config file should look something like the example below.  Remember to create a work-in-progress git feature branch before starting out here!


```javascript
import { defineConfig } from 'astro/config';

import markdoc from '@astrojs/markdoc';

// https://astro.build/config
export default defineConfig({
	integrations: [markdoc()],
});
```

### Markdoc Config

Because we want to tap into code syntax highlighting in our generated content, we will add a `markdoc.config.mjs` file to the project root directory with the following content:

```javascript
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
import shiki from '@astrojs/markdoc/shiki';

export default defineMarkdocConfig({
	extends: [
		shiki({
			theme: 'one-dark-pro',
		}),
	],
});
```

I opted for **Shiki** syntax highlighting here, and Astro Markdoc also supports <a href="https://docs.astro.build/en/guides/integrations-guide/markdoc/#prism">Prism</a>.  For the theme, I chose **One Dark Pro**.  If that is not your cup of tea, see a <a href="https://github.com/shikijs/shiki/blob/main/docs/themes.md#all-themes">full list of Shiki themes</a> in the Shiki GitHub repo.  We will switch to starry-night highlighting later, so don’t invest too much time in finding the perfect theme yet!

## 🖋️ First Markdoc Post

Next, create a collection folder in your project.  This has to be within the `src/content` folder.  I went for a `code` collection, and added content for this collection to a new `src/content/code` directory.  Note that Markdoc files normally have a `.mdoc` extension.  Paste this markup into your new content source file (`src/content/code/hello-world.mdoc`, for example) to get started:

~~~markdown
---
{
  'title': 'Markdoc Introduction with 🚀 Astro and starry-night Code Highlighting',
  'description': 'Astro Markdoc 📚 trying Stripe’s customizable, readable, declarative Markdown extension designed for 🖋️ creating documentation content.',
  'date': '2023-11-02',
}
---

# {% $frontmatter.title %}

![Vincent Van Gogh’s Starry Night](/starry-night.jpg)

## Markdoc Features

Some principal Markdoc features are:

- syntax for nodes is similar to Markdown so nothing to learn for creating headings, bold or italic text, links, images or lists;
- templates accept tags, which you can map to Astro components when you need more control; and
- front matter metadata is accessible within your Markdoc templates as variables, this might be a document title.

Unlike Markdown, Markdoc **does not accept HTML** within template files by default. The Astro configuration _does_ let you override this, though.

## 🖥️ Fenced Code Block Examples

### CSS

```css
.box {
	border: solid 5px red;
}
```

### Elixir

```elixir
"hello" <> " world"
```

### JavaScript

```javascript
console_log('Hello world!');
```

### Python

```python
print('Hello world!')
```

### Rust

```rust
println!("Hello world!");
```
~~~

You will see this is not too different to Markdown, so I won’t go into too much detail here.  If you are not familiar with Markdown, then the <a aria-label="Mark doc docs give you a good primer on the syntax" href="https://markdoc.dev/docs/syntax">Markdoc docs give you a good primer on the syntax</a>.  That said, some points to note are:

- you can write front matter in GraphQL, JSON or YAML (I opted for JSON here);
- Markdoc variables let you place front matter metadata in the markup (example in line `9` where we use the front matter title field); and
- you can add  a <a href="https://markdoc.dev/docs/tags#create-a-custom-tag">custom `tag`</a>, which gives you more flexibility, using your Astro components in the markup, though we stick to the basics here.

### Astro Content Collection Schema

Content Collection schema, is a way to define which fields we want in the front matter for each markup file.  The schema is optional, though useful because Astro can let you know if you forget to add a field that is required by your schema.  For that reason, I added a schema file at `src/content/config.ts`:

```typescript
import { defineCollection, z } from 'astro:content';

const codeCollection = defineCollection({
	type: 'content',
	schema: () =>
		z.object({
			title: z.string(),
			description: z.string().optional(),
			date: z.string(),
		}),
});

export const collections = {
	code: codeCollection,
};
```

This also generates TypeScript types under the hood, for your content front matter metadata.  Add another Markdoc file to your collection folder, just so you can try listing all content in the next section.

## 📝 Getting a List of Markdoc Content

Typically, each Markdoc file will be a separate page on your generated site, and you will need a list of all content pages, often on the home page.  We create that list here.

To get all Markdoc files in the collection:
1. import `getCollection` from `astro:content`;
2. call `getCollection`, providing your collection name as an argument; and
3. render the array generated.

We follow these steps in `src/pages/index.astro`, below:

```astro
---
import { getCollection } from 'astro:content';
import BaseLayout from '~layouts/BaseLayout.astro';

const { href: url } = Astro.url;
const codeEntries = await getCollection('code');
---

<BaseLayout
	title="Markdoc Introduction Home Page"
	description="Markdoc Introduction: see code examples and some basic Markdoc usage"
	{url}
>
	<main>
		<h1>Markdoc Introduction Home Page</h1>
		<p>Pages that use the Markdoc template:</p>
		<ul>
			{
				codeEntries
					.sort(
						({ data: { date: dateA } }, { data: { date: dateB } }) =>
							new Date(dateB).valueOf() - new Date(dateA).valueOf(),
					)
					.map(({ data: { title }, slug }) => (
						<li>
							<a href={`/${slug}`}>{title}</a>
						</li>
					))
			}
		</ul>
	</main>
</BaseLayout>
```

Go to `http://localhost:4321` in your browser to check the list of pages gets created, as expected.

## 🍪 Creating an Astro Markdoc Template

Astro template files generate site pages from a content collection.  The path of the page is determined by the name you give your template and some metadata from the source.

Here, we want the generated page path to come from the slug of the Markdoc source (its path within the Content Collection).  To make this work, we:

1. name the template `src/pages/[...slug].astro`; and
2. call `getStaticPaths` in the template file, returning  `params.slug` to let Astro know how to map a Markdoc file to a page `slug`.

You can see this in action in `src/pages/[...slug].astro`, below:

```astro
---
import { getCollection } from 'astro:content';
import BaseLayout from '~layouts/BaseLayout.astro';

export async function getStaticPaths() {
	const codeEntries = await getCollection('code');
	return codeEntries.map((entry) => ({
		params: { slug: entry.slug },
		props: { entry },
	}));
}

const { entry } = Astro.props;
const { href: url } = Astro.url;
const { data } = entry;
const { description, title } = data;
const { Content } = await entry.render();
---

<BaseLayout {url} {title} {description}>
	<main>
		<Content frontmatter={data} />
	</main>
</BaseLayout>
```

Another interesting part is how we access Markdoc source front matter in the template.  The bottom part of the file renders the content from the Markdoc source.  You can see in line `22`, we have a `frontmatter` prop on the `Content` element.  This is important for making Markdoc `variables` within the source work.  Remember, we had a variable in the source, which referenced the front matter title.  We pass `data` into the `frontmatter` prop for that reason.

Moving up the file, you can see `data` comes from `entry` (line `15`), which, itself, comes from `Astro.props` (line `13`).  We create the `props` object in the `getStaticPaths` function call (line `9`).  You will see a similar pattern for sourcing `title` and `description`, passed to the layout template in line `20`.

Open up one of your content pages in a browser.  Hopefully you see the code blocks with highlighting provided by the One Dark Theme, with Shiki.  In the next section, we see how you can use starry-night to highlight the code blocks.  We will generate a similar look to GitHub and also, automatically switch between light and dark syntax highlighting; based on the user theme dark/light preference.

## ✨ starry-night Syntax Highlighting

Next, we will see how you do not have to accept defaults for rendering nodes, and can instead use your own Astro components.  You can see a full list of <a href="https://markdoc.dev/docs/nodes#built-in-nodes">nodes which you can apply this technique to, in the Markdoc docs</a>.

We will override the `fence` rendering with our own component by updating `markdoc.config.mjs`:

```javascript
import { defineMarkdocConfig, component } from '@astrojs/markdoc/config';

export default defineMarkdocConfig({
	nodes: {
		fence: {
			render: component('./src/components/Fence.astro'),
			attributes: {
				content: { type: String },
				language: {
					type: String,
				},
			},
		},
	},
});
```

Notice the `attributes` for `fence` are `content` and `language` (there is also `process`, but we will not use it, here).  If you are modifying other nodes, check the previous link to see the available attributes.

## 🧩 Astro Markdoc Fence Component

We just referenced a `Fence` component in the Markdoc config file, but we have not yet created that component! Let’s do that now.  Create a `src/components` directory in your project, then make a `Fence.astro` file in there, with this content:

```astro
---
import { common, createStarryNight } from '@wooorm/starry-night';
import sourceElixir from '@wooorm/starry-night/source.elixir';
import textElixir from '@wooorm/starry-night/text.elixir';
import { toHtml } from 'hast-util-to-html';

import '@wooorm/starry-night/style/both';

// const starryNight = await createStarryNight(common);
const starryNight = await createStarryNight([...common, sourceElixir, textElixir]);

interface Props {
	content: string;
	language: string;
}

const { content, language } = Astro.props as Props;
const scope = starryNight.flagToScope(language);
const codeHtml = toHtml(starryNight.highlight(content.trim(), scope!));
---

<pre>
<code set:html={codeHtml} />
</pre>
```

See <a href="https://github.com/wooorm/starry-night">starry-night docs</a> for more details on how it works.  Some interesting points to note in the starry-night setup here are:

- starry-night gives you a lot of control over adding accessible themes. In line `9`, we imported `@wooorm/starry-night/style/both` which provides the CSS needed to show a light or dark theme depending on the user’s browser preference.  Other options include light, dark, and then, more <a href="https://github.com/wooorm/starry-night#css">specialized styles such as colorblind-light and dark, high-contrast-light and dark and tritanopia</a>.
- The package supports highlighting many common languages using the `common` list, in line `2`.  For less common languages, though, you will need to extend the list, as we do for Elixir, in line `10`.  See a list of all supported languages and <a href="https://github.com/wooorm/starry-night#languages">which languages starry-night includes in the common language list</a> for more.
- Calling `starryNight.highlight` generates Hypertext <a aria-label="A S T" href="https://en.wikipedia.org/wiki/Abstract_syntax_tree">AST</a> (**HAST**), and to convert this to HTML we use `hast-util-to-html`.

To add starry-night to your project, run:

```shell
pnpm add @wooorm/starry-night hast-util-to-html
```

### Our Astro Component

Notice the Astro `Fence` component takes `content` and `language` props (lines `15`-`18`) and renders these itself (`pre` tag in lines `25`-`27`).  If you preferred to work in Preact, React, Svelte or Vue, for example, you would replace that `pre` tag with your own component for that framework, passing through the props.  This is a feature I like most about Markdoc: it is not tied to React, and gives you flexibility to add components from other frameworks.

Try toggling light and dark theme on your browser to check the starry-night syntax highlighting gets updated to match the preference.  <a href="https://developer.chrome.com/docs/devtools/rendering/emulate-css/">Emulate `prefers-color-scheme` in Chromium</a> and <a href="https://firefox-source-docs.mozilla.org/devtools-user/page_inspector/how_to/examine_and_edit_css/index.html#view-media-rules-for-prefers-color-scheme">Firefox</a>.

![Astro Markdoc: Screen capture shows Firefox developer tools open with dark mode selected.  Within the main browser window, there are CSS and Elixir code blocks with light text on a dark background.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ilxre00nwuj6sf5om2q.png)

![Astro Markdoc: Screen capture shows Firefox developer tools open with dark mode selected.  Within the main browser window, there are CSS and Elixir code blocks with dark text on a light background.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ff3lwk70mr6trcnkc7dl.png)
## 🙌🏽 Astro Markdoc: Wrapping Up

In this post, we saw how you can create an Astro Markdoc site.  In particular, we saw:

- why **choose Markdoc over Markdown or MDX**;
- how you **customize Markdoc nodes with custom Astro components**; and
- how to **set up starry-night GitHub-like syntax highlighting**.

You can see the <a href="https://github.com/rodneylab/astro/tree/main/demos/astro-markdoc">full code for this project in the Rodney Lab GitHub repo</a>.  There are quite a few extensions you might consider including:

- creating custom Astro components for rendering headings or other nodes;
- adding Markdoc tags for increased flexibility; and
- coding up your custom components in your favourite framework, using the Astro component as a gateway. Pass through the necessary props to the framework component.

I do hope you have found this post useful! Let me know how you use what you have learned here.  Also, let me know about any possible improvements to the content above.

## 🙏🏽 Astro Markdoc: Feedback

Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also, if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please <a aria-label="Support Rodney Lab via Buy me a Coffee" href="https://rodneylab.com/giving/">consider supporting me through Buy me a Coffee</a>.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via <a href="https://twitter.com/messages/compose?recipient_id=1323579817258831875">@askRodney</a> on X, <a href="https://toot.community/@rodney">@rodney@toot.community</a> on Mastodon and also the <a aria-label="Contact Rodney Lab via Telegram" href="https://matrix.to/#/%23rodney:matrix.org">#rodney</a> Element Matrix room. Also, see <a aria-label="Get in touch with Rodney Lab" href="https://rodneylab.com/contact/">further ways to get in touch with Rodney Lab</a>. I post regularly on <a aria-label="See posts on Astro" href="https://rodneylab.com/tags/astro/">Astro</a> as well as <a aria-label="See posts on svelte kit" href="https://rodneylab.com/tags/seo/">SEO</a>. Also, <a href="https://newsletter.rodneylab.com/issue/latest-issue">subscribe to the newsletter to keep up-to-date</a> with our latest projects.