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

export { default as document } from "./document.markdoc";
export { default as fence } from "./fence.markdoc";
export { default as heading } from "./heading.markdoc";
export { default as link } from "./link.markdoc";

and tags:

// ./src/markdoc/config/tags/index.ts

export { default as code } from "./code.markdoc";
export { default as example } from "./example.markdoc";

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

// ./markdoc.config.mjs

import { defineMarkdocConfig } from '@astrojs/markdoc/config'
import * as nodes from './src/markdoc/config/nodes'
import * as tags from './src/markdoc/config/tags'
const config =  defineMarkdocConfig({
  nodes,
  tags,
})
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
    
    import markdoc from '@markdoc/markdoc'
    const { nodes } = markdoc;
    
    export default {
      ...nodes.document,
      render: null
    }
    
  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:

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

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

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

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

    // ./src/markdoc/config/nodes/heading.markdoc.ts
    
    import markdoc from '@markdoc/markdoc'
    import { generateID, grabHeadingContent } from '../../../lib/index'
    const { Tag } = markdoc
    
    export default {
      children: ['inline'],
      attributes: {
        id: { type: String },
        level: { type: Number, required: true, default: 1 }
      },
      transform(node, config) {
        const attributes = node.transformAttributes(config)
        const children = node.transformChildren(config)
        // Using our functions for generating the ID and grabbing the content
        const id = generateID(children, attributes)
        const content = grabHeadingContent(children)
        // Create the inner tags
        const link = new Tag(
          'a', { href: '#' + id, title: content },
          [
            new Tag('span', { 'aria-hidden': true, class: 'icon icon-link' },
              [
                null,
              ]),
          ])
        // Finally create and return the customized heading
        return new Tag(
          'h' + node.attributes.level,
          { ...attributes, id, class: 'mt-0 mb-0' },
          [
            content,
            link,
          ]
        )
      }
    }
    

    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:

<h2 level="2" id="recap" class="some-classes-you-added">
  Recap
  <a href="#recap" title="Recap">
    <span aria-hidden="true" class="icon icon-link"></span>
  </a>
</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#L30-L56
    
    // here node is the result of calling Markdoc.parse(entry.body);
    export function collectHeadings(node, sections = []) {
      if (node) {
        if (node.type === 'heading') {
          const inline = node.children[0].children[0];
          if (inline.type === 'text') {
            const slug = slugify(inline.attributes.content.replace(/[?]/g, '')
              .replace(/\s+/g, '-'), { ...slugifyOptions })
            sections.push({
              ...node.attributes,
              slug,
              depth: node.attributes.level,
              text: inline.attributes.content
            });
          }
        }
    
        if (node.children) {
          for (const child of node.children) {
            collectHeadings(child, sections);
          }
        }
      }
      return sections;
    }
    
  2. In the Astro page, for example src/pages/posts/[slug].astro, prepare the headings:

    // ./src/pages/posts/[slug].astro#L31-L40
    
    let toc = null;
    const { Content, headings } = await entry.render();
    
    if (entry.id.endsWith(".mdoc")) {
      const doc = Markdoc.parse(entry.body);
      toc = collectHeadings(doc);
    } else {
      toc = headings;
    }
    
    
  3. In the src/component folder, create an AppLink.astro component to render links:

    // ./src/components/astro/AppLink.astro
    
    ---
    const props = { ...Astro.props };
    
    const external = props.href.startsWith('http');
    const hash = props.href.startsWith('#');
    const email = props.href.startsWith('mailto:');
    const phone = props.href.startsWith('tel:');
    ---
    <a
      href={props.href}
      title={props.title}
      class:list={['link', props.class, {
          'external': external,
          'hash': hash,
          'mail': email,
          'tel': phone
        },
        props.class,
        props['class:list'],
      ]}
      target={external ? '_blank' : null}
      rel={external ? 'noopener nofollow' : null}>
      <slot />
    </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
    
    ---
    import AppLink from "@components/astro/AppLink.astro";
    
    const { toc } = Astro.props;
    ---
    
    <div class='sticky self-start top-24 max-w-[200px]'>
      {
        toc && toc.length >= 1 ? (
          <strong>In this article</strong>
        ) : null
      }
      <nav>
        <ul class='px-0 py-0 mx-0 my-0 list-none'>
          {
            toc.map((item: { slug: any; depth: number; text: unknown }) => {
              const href = `#${item.slug}`;
              const active =
                typeof window !== "undefined" && window.location.hash === href;
              return (
                <li
                  class={[
                    "flex items-start py-2 ",
                    active ? "underline hover:underline flex items-center" : "",
                    item.depth === 3 ? "ml-4" : "",
                    item.depth === 4 ? "ml-6" : "",
                  ]
                    .filter(Boolean)
                    .join(" ")}>
                  <AppLink
                    class:list={["break-word"]}
                    href={`#${item.slug}`}>
                    <span>{item?.text}</span>
                  </AppLink>
                </li>
              );
            })
          }
        </ul>
      </nav>
    </div>
    
    
  5. In the Astro page, for example src/pages/posts/[slug].astro, import the new component:

    // ./src/pages/posts/[slug].astro#L12-L12
    
    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#L151-L168
    
                page.updated ? (
                  <span class='font-semibold'>
                    Last updated: {formatDate(page.updated)}
                  </span>
                ) : null
              }
              <Content />
            </div>
    
            <div
              class='toc-container'>
              {toc && toc?.length ? <TableOfContents toc={toc} /> : null}
            </div>
          </div>
        </article>
      </div>
    </div>
    <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#L1-L13
    
    export const readingTime = (entry) => {
      const WORDS_PER_MINUTE = 150
      let wordCount = 0
      const regex = /\w+/g
      // I add the document body + description. 
      // Add more props if you render them on the page
      wordCount = entry.body ? entry.body.match(regex).length : 0
      wordCount += entry.description ? entry.description.match(regex).length : 0
      const time = wordCount ? Math.ceil(wordCount / WORDS_PER_MINUTE) : 0
      return time ? `${time}-minute read` : ''
    }
    
  2. Use the function where you want to display reading time. For example, in src/pages/posts/[slug].astro

    ---
    // other imports
    import {readingTime} from "@lib/reading-time";
    // other code
    const { entry } = Astro.props;
    const minutesRead = readingTime(entry);
    ---
    <!-- other markup -->
    <p>{minutesRead}</p>
    <!-- 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
    
    import markdoc from '@markdoc/markdoc'
    const {  Tag } = markdoc
    
    export default {
      render: 'code',
      attributes: {
        content: { type: String, render: false, required: true },
      },
      transform(node, config) {
        const attributes = node.transformAttributes(config)
        return new Tag('code', attributes, [node.attributes.content])
      },
    }
    

    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
    
    ---
    import Code from 'astro/components/Code.astro'
    import * as shiki from 'shiki'
    import path from 'path'
    const themePath = path.join(process.cwd(), '/src/lib/vitesse-dark-ancaio.json')
    
    const theme = await shiki.loadTheme(themePath)
    
    const {code, lang} = Astro.props
    
    ---
    <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
    
    import Code from '../../components/Code.astro'
    
    export default {
      render: Code,
      attributes: {
        content: { type: String, render: 'code', required: true },
        language: { type: String, render: 'lang' },
      },
    }
    

    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
    
    import AppLink from '@components/astro/AppLink.astro'
    
    export default {
      render: AppLink,
      children: ['strong', 'em', 's', 'code', 'text', 'tag', 'image', 'heading'],
      attributes: {
        href: { type: String, required: true },
        target: { type: String },
        rel: { type: String },
        title: { type: String },
      },
    }
    
  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:

[![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.