Astro Markdoc: Readable, Declarative MDX Alternative

Rodney Lab - Nov 15 '23 - - Dev Community

🚀 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.

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 Getting Started with Astro post provides a gentle introduction.

Markdoc Astro Integration

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

pnpm astro add markdoc
Enter fullscreen mode Exit fullscreen mode

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!

import { defineConfig } from 'astro/config';

import markdoc from '@astrojs/markdoc';

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

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:

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

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

I opted for Shiki syntax highlighting here, and Astro Markdoc also supports Prism. For the theme, I chose One Dark Pro. If that is not your cup of tea, see a full list of Shiki themes 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:

---
{
  '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!");
```
Enter fullscreen mode Exit fullscreen mode

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 Markdoc docs give you a good primer on the syntax. 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 custom tag, 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:

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,
};
Enter fullscreen mode Exit fullscreen mode

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:

---
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>
Enter fullscreen mode Exit fullscreen mode

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:

---
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>
Enter fullscreen mode Exit fullscreen mode

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 nodes which you can apply this technique to, in the Markdoc docs.

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

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,
                },
            },
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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:

---
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>
Enter fullscreen mode Exit fullscreen mode

See starry-night docs for more details on how it works. Some interesting points to note in the starry-night setup here are:

To add starry-night to your project, run:

pnpm add @wooorm/starry-night hast-util-to-html
Enter fullscreen mode Exit fullscreen mode

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. Emulate prefers-color-scheme in Chromium and Firefox.

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.

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.

🙌🏽 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 full code for this project in the Rodney Lab GitHub repo. 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 consider supporting me through Buy me a Coffee.

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 @askRodney on X, @rodney@toot.community on Mastodon and also the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Astro as well as SEO. Also, subscribe to the newsletter to keep up-to-date with our latest projects.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .