Apr 10 2023

Adding Necessary Features in the Astro Markdoc Blog

Astro Markdoc doesn't have feature parity with Markdown yet. Here are my Astro Markdoc solutions for linked headings, table of contents, external links in the Markdoc body, Shiki code blocks with a custom theme, and more.

Astro logo.
Last updated: Monday, April 10th, 2023

Following the Astro Markdoc with Obsidian setup , I needed to replace features that aren't yet available in Astro's Markdoc integration, but before getting into all that, a sidebar on my Markdoc configuration.

NOTE: I have reverted my other posts to Markdown since I wrote this post because my writing tool (Obsidian) is not dealing well with .mdoc extensions. This article, however, is still Markdoc to show you the result of the code samples on this page. The code samples do not include the copy-link or copy-code functionality.

Organized Markdoc configs

All my custom node and tag configurations sit in the src/markdoc/config folder. I plan to create additional nodes and tags for Markdoc, so I want them well organized.

I export all my nodes:

./src/markdoc/config/nodes/index.ts
1
export { default as document } from "./document.markdoc";
2
export { default as fence } from "./fence.markdoc";
3
export { default as heading } from "./heading.markdoc";
4
export { default as link } from "./link.markdoc";

and tags:

./src/markdoc/config/tags/index.ts
1
export { default as code } from "./code.markdoc";
2
export { default as example } from "./example.markdoc";

for easy use in the markdoc.config.mjs file:

./markdoc.config.mjs
1
import { defineMarkdocConfig } from '@astro/markdoc/config'
2
import * as nodes from './src/markdoc/config/nodes'
3
import * as tags from './src/markdoc/config/tags'
4
const config = defineMarkdocConfig({
5
nodes,
6
tags,
7
})
8
export default config

I also keep dedicated components in the markdoc folder. If the component has uses outside of rendering nodes or tags, I move it to src/components/astro (as with the AppLink.astro component).

NOTE: All my examples use Tailwind CSS with some custom classes. If you get errors, remove any strange color-related and prose* classes.

Prevent double wrapping with the article tag

If you, like me, handle the display of frontmatter data outside the .mdoc file and only use the Astro <Content/> component for rendering the post body, you may notice that Markdoc wraps the body in an article tag.

At the same time, you may have already wrapped your post data in an article tag - as I did. So now you have a nested article tag that makes no sense.

To prevent this double wrapping:

  1. Create a document.markdoc.ts file with the following content to only render the child nodes:

    ./src/markdoc/config/nodes/document.markdoc.ts
    1
    import markdoc from '@markdoc/markdoc'
    2
    const { nodes } = markdoc;
    3
    4
    export default {
    5
    ...nodes.document,
    6
    render: null
    7
    }
  2. Add the custom node to your nodes configuration in markdoc.config.mjs.

Enable linked headings for the table of contents and copy-to-clipboard functionality

In Astro, you can add remark and rehype plugins to get linked headings in your markdown content and create a table of contents . But how about Markdoc?

In .mdoc files, the headings key returned by the Astro rendering function has an empty array value.

We can generate them with a structure similar to those generated from .md files, so a Table of Contents component would work for both file formats if you're incrementally migrating to Markdoc.

We need the correct heading output structure to navigate to the heading and copy the link with one click.

NOTE: This example does not show how to implement the copy-to-clipboard functionality you are experiencing on this blog.

To define the custom heading tag:

  1. Install the slugify package:

    Terminal window
    1
    yarn add slugify
  2. In your utility files, define the options for slugifying strings:

    ./src/lib/collect-headings.ts
    1
    import slugify from 'slugify'
    2
    3
    const slugifyOptions = {
    4
    replacement: '-', // replace spaces with replacement character, defaults to `-`
    5
    remove: undefined, // remove characters that match regex, defaults to `undefined`
    6
    lower: true, // convert to lower case, defaults to `false`
    7
    strict: false, // strip special characters except replacement, defaults to `false`
    8
    locale: 'en', // language code of the locale to use
    9
    trim: true // trim leading and trailing replacement chars, defaults to `true`
    10
    }
  3. Create a function to slugify the content of the heading:

    ./src/lib/collect-headings.ts
    1
    export function generateID(children, attributes) {
    2
    if (attributes.id && typeof attributes.id === 'string') {
    3
    return attributes.id
    4
    }
    5
    return slugify(children
    6
    .filter((child) => typeof child === 'string')
    7
    .join(' ')
    8
    .replace(/[?]/g, ''), { ...slugifyOptions })
    9
    }
  4. Create a function to grab the full heading text content:

    ./src/lib/collect-headings.ts
    1
    export function grabHeadingContent(children) {
    2
    return children
    3
    .filter((child) => typeof child === 'string')
    4
    .join(' ')
    5
    }
  5. Define the custom heading node (mine sits in /src/markdoc/config/nodes/heading.markdoc.ts):

    ./src/markdoc/config/nodes/heading.markdoc.ts
    1
    import markdoc from '@markdoc/markdoc'
    2
    import { generateID, grabHeadingContent } from '../../../lib/index'
    3
    const { Tag } = markdoc
    4
    5
    export default {
    6
    children: ['inline'],
    7
    attributes: {
    8
    id: { type: String },
    9
    level: { type: Number, required: true, default: 1 }
    10
    },
    11
    transform(node, config) {
    12
    const attributes = node.transformAttributes(config)
    13
    const children = node.transformChildren(config)
    14
    // Using our functions for generating the ID and grabbing the content
    15
    const id = generateID(children, attributes)
    16
    const content = grabHeadingContent(children)
    17
    // Create the inner tags
    18
    const link = new Tag(
    19
    'a', { href: '#' + id, title: content },
    20
    [
    21
    new Tag('span', { 'aria-hidden': true, class: 'icon icon-link' },
    22
    [
    23
    null,
    24
    ]),
    25
    ])
    26
    // Finally create and return the customized heading
    27
    return new Tag(
    28
    'h' + node.attributes.level,
    29
    { ...attributes, id, class: 'mt-0 mb-0' },
    30
    [
    31
    content,
    32
    link,
    33
    ]
    34
    )
    35
    }
    36
    }

    NOTE: Feel free to use interpolation for the heading id and the link href attributes. Markdown is complaining about this code block if I use interpolation.

  6. Add the custom node to your nodes configuration in markdoc.config.mjs.

The document should now contain headings with the following rendered markup:

1
<h2 level="2" id="recap" class="some-classes-you-added">
2
Recap
3
<a href="#recap" title="Recap">
4
<span aria-hidden="true" class="icon icon-link"></span>
5
</a>
6
</h2>

You can change the order of the link and content nodes if you prefer the link at the start of the heading.

You now have a span you can style and use for "click-to-copy" functionality.

Create the table of contents

With headings proudly displaying an id attribute, we can now link to them from a table of contents.

To create the table of contents:

  1. Stating from the recipe in the Markdoc documentation , define a function that can iterate over an abstract syntax tree (AST) matching our heading attributes:

    ./src/lib/collect-headings.ts
    1
    // here node is the result of calling Markdoc.parse(entry.body);
    2
    export function collectHeadings(node, sections = []) {
    3
    if (node) {
    4
    if (node.type === 'heading') {
    5
    const inline = node.children[0].children[0];
    6
    if (inline.type === 'text') {
    7
    const slug = slugify(inline.attributes.content.replace(/[?]/g, '')
    8
    .replace(/\s+/g, '-'), { ...slugifyOptions })
    9
    sections.push({
    10
    ...node.attributes,
    11
    slug,
    12
    depth: node.attributes.level,
    13
    text: inline.attributes.content
    14
    });
    15
    }
    16
    }
    17
    18
    if (node.children) {
    19
    for (const child of node.children) {
    20
    collectHeadings(child, sections);
    21
    }
    22
    }
    23
    }
    24
    return sections;
    25
    }
  2. In the Astro page, for example src/pages/posts/[slug].astro, prepare the headings:

    ./src/pages/posts/[slug].astro
    1
    let toc = null;
    2
    const { Content, headings } = await entry.render();
    3
    4
    if (entry.id.endsWith(".mdoc")) {
    5
    const doc = Markdoc.parse(entry.body);
    6
    toc = collectHeadings(doc);
    7
    } else {
    8
    toc = headings;
    9
    }
  3. In the src/component folder, create an AppLink.astro component to render links:

    ./src/components/astro/AppLink.astro
    1
    ---
    2
    const props = { ...Astro.props };
    3
    4
    const external = props.href.startsWith('http');
    5
    const hash = props.href.startsWith('#');
    6
    const email = props.href.startsWith('mailto:');
    7
    const phone = props.href.startsWith('tel:');
    8
    ---
    9
    <a
    10
    href={props.href}
    11
    title={props.title}
    12
    class:list={['link', props.class, {
    13
    'external': external,
    14
    'hash': hash,
    15
    'mail': email,
    16
    'tel': phone
    17
    },
    18
    props.class,
    19
    props['class:list'],
    20
    ]}
    21
    target={external ? '_blank' : null}
    22
    rel={external ? 'noopener nofollow' : null}>
    23
    <slot />
    24
    </a>

    NOTE: Having this component may seem overkill, but the bright side is that you can use it to render the links inside your .mdoc files too.

  4. Create a TableOfContents component you can use with the headings. For example:

    ./src/components/astro/TableOfContents.astro
    1
    ---
    2
    import AppLink from "@components/astro/AppLink.astro";
    3
    4
    const { toc } = Astro.props;
    5
    ---
    6
    7
    <div class='sticky self-start top-24 max-w-[200px]'>
    8
    {
    9
    toc && toc.length >= 1 ? (
    10
    <strong>In this article</strong>
    11
    ) : null
    12
    }
    13
    <nav>
    14
    <ul class='px-0 py-0 mx-0 my-0 list-none'>
    15
    {
    16
    toc.map((item: { slug: any; depth: number; text: unknown }) => {
    17
    const href = `#${item.slug}`;
    18
    const active =
    19
    typeof window !== "undefined" && window.location.hash === href;
    20
    return (
    21
    <li
    22
    class={[
    23
    "flex items-start py-2 ",
    24
    active ? "underline hover:underline flex items-center" : "",
    25
    item.depth === 3 ? "ml-4" : "",
    26
    item.depth === 4 ? "ml-6" : "",
    27
    ]
    28
    .filter(Boolean)
    29
    .join(" ")}>
    30
    <AppLink
    31
    class:list={["break-word"]}
    32
    href={`#${item.slug}`}>
    33
    <span>{item?.text}</span>
    34
    </AppLink>
    35
    </li>
    36
    );
    37
    })
    38
    }
    39
    </ul>
    40
    </nav>
    41
    </div>
  5. In the Astro page, for example src/pages/posts/[slug].astro, import the new component:

    ./src/pages/posts/[slug].astro
    1
    import TableOfContents from "@components/astro/TableOfContents.astro";
  6. Use the component, passing the toc constant as the toc prop to the component. For example:

    ./src/pages/posts/[slug].astro
    1
    <div
    2
    class='toc-container'>
    3
    {toc && toc?.length ? <TableOfContents toc={toc} /> : null}
    4
    </div>

Add the reading time to your post/page

Showing the reading time on your articles is a good idea and a good reader experience. For example, looking at this article's reading time, unless you just want a quick look, you must set aside some time to go through it.

To add reading time to Markdoc content:

  1. Define the function that computes the reading time:

    ./src/lib/reading-time.ts
    1
    export const readingTime = (entry) => {
    2
    const WORDS_PER_MINUTE = 150
    3
    let wordCount = 0
    4
    const regex = /\w+/g
    5
    // I add the document body + description.
    6
    // Add more props if you render them on the page
    7
    wordCount = entry.body ? entry.body.match(regex).length : 0
    8
    wordCount += entry.description ? entry.description.match(regex).length : 0
    9
    const time = wordCount ? Math.ceil(wordCount / WORDS_PER_MINUTE) : 0
    10
    return time ? `${time}-minute read` : ''
    11
    }
  2. Use the function where you want to display reading time. For example:

    ./src/pages/posts/[slug].astro
    1
    ---
    2
    // other imports
    3
    import {readingTime} from "@lib/reading-time";
    4
    // other code
    5
    const { entry } = Astro.props;
    6
    const minutesRead = readingTime(entry);
    7
    ---
    8
    <!-- other markup -->
    9
    <p>{minutesRead}</p>
    10
    <!-- other markup -->

Add Shiki syntax highlighting to code blocks

Shiki support is not available with the Astro Markdoc extension. In my few days using Astro with plain Markdown, I customized a Shiki theme to match my overall theme better, so I didn't want to lose that.

To add Shiki support to your Markdoc documents:

  1. Define a custom code tag to ensure things don't change with future updates. My custom code definition sits in src/markdoc/config/tags/code.markdoc.ts:

    ./src/markdoc/config/tags/code.markdoc.ts
    1
    import markdoc from '@markdoc/markdoc'
    2
    const { Tag } = markdoc
    3
    4
    export default {
    5
    render: 'code',
    6
    attributes: {
    7
    content: { type: String, render: false, required: true },
    8
    },
    9
    transform(node, config) {
    10
    const attributes = node.transformAttributes(config)
    11
    return new Tag('code', attributes, [node.attributes.content])
    12
    },
    13
    }

    Add the custom tag to your tags configuration in markdoc.config.mjs.

  2. Define a custom Code component. We will use this component to render a custom fence node in the following steps. The Code component uses the Astro Code component:

    ./src/markdoc/components/Code.astro
    1
    ---
    2
    import Code from 'astro/components/Code.astro'
    3
    import * as shiki from 'shiki'
    4
    import path from 'path'
    5
    const themePath = path.join(process.cwd(), '/src/lib/vitesse-dark-ancaio.json')
    6
    7
    const theme = await shiki.loadTheme(themePath)
    8
    9
    const {code, lang} = Astro.props
    10
    11
    ---
    12
    <Code code={code} lang={lang} wrap={true} theme={theme}></Code>
  3. Define a custom fence Markdoc node that renders the new Code component. My custom code definition sits in src/markdoc/config/nodes/fence.markdoc.ts:

    ./src/markdoc/config/nodes/fence.markdoc.ts
    1
    import Code from '../../components/Code.astro'
    2
    3
    export default {
    4
    render: Code,
    5
    attributes: {
    6
    content: { type: String, render: 'code', required: true },
    7
    language: { type: String, render: 'lang' },
    8
    },
    9
    }

    Add the custom node to your nodes configuration in markdoc.config.mjs.

You should be able to enjoy Shiki in your rendered code blocks now.

If you want to style links based on their type, you need to know what kind of a link it is.

If you want to open external links in a new tab, you have several options, but the fastest is making the link render with the necessary attributes.

When creating the table of contents, you already made the component that will help you get control over link rendering.

But it would help if you told Markdoc what to do with the links when it parses and transforms them.

To customize link tags in Markdoc:

  1. Create a custom markdoc link tag configuration file link.markdoc.ts, using the previously created AppLink.astro component for rendering the tag:

    ./src/markdoc/config/nodes/link.markdoc.ts
    1
    import AppLink from '@components/astro/AppLink.astro'
    2
    3
    export default {
    4
    render: AppLink,
    5
    children: ['strong', 'em', 's', 'code', 'text', 'tag', 'image', 'heading'],
    6
    attributes: {
    7
    href: { type: String, required: true },
    8
    target: { type: String },
    9
    rel: { type: String },
    10
    title: { type: String },
    11
    },
    12
    }
  2. Add the custom node to your nodes configuration in markdoc.config.mjs.

External links should now open in a new tab and have the rel attributes. Adjust the logic to your needs in the AppLink.astro component.

TIP: Did you notice the image in the children array? If you need to have linked images in your posts, you can do so now. For example:

1
[![Lilo & Stitch](../../assets/images/stitch.png)](https://en.wikipedia.org/wiki/Stitch_%28Lilo_%26_Stitch%29)

renders this cute fellow, nicely wrapped in a link that opens in a new tab/window: Lilo & Stitch

That's all for now! 🎊

Explore by Tag

On each tag page, you can find a link to that tag's RSS feed so you only get the content you want.