Larry Myers

Cover Image for How to Create an Astro Markdown Plugin

How to Create an Astro Markdown Plugin

While creating content for this site I wanted all links to external sites to open in a new browser tab. Using traditional component based frameworks you’d end up having something like this that would encapsulate the logic and render the archor tag appropriately:

<Link href="https://external.site/foo/bar.html">an external link</Link>

But I didn’t want to have to use a custom component, I wanted this functionality to work with plain html anchor tags generated from markdown. There’s nothing wrong a custom component, but you have to remember to use it everywhere and integrate it with all the existing techologies you’re already using.

Fortunately Astro uses rehype as part of its markdown stack, which makes this functionality straight forward once you learn how to write a plugin.

Rehype combines the unified content processor and hast html AST to represent an html document as a tree structure. This tree structure can be walked and transformed by a series of plugins.

A rehype plugin is a function that receives configuration and returns a function that may transform the tree representing the html output of the markdown document.

Here’s the basic structure:

import type { RehypePlugin } from "@astrojs/markdown-remark";
import { visit } from "unist-util-visit";

export const myPlugin: RehypePlugin = (options?: any) => {
  return (tree) => visit(tree, transformer);
};

Our transformer function will do the following:

  1. Determine if a tree node is an element.
  2. Check if the element is an anchor tag.
  3. Check if the anchor tag has an href attribute.
  4. Check if the href url references an external site.
  5. Add a target="_blank" attribute to the anchor tag.

Here’s the full implementation, and how it’s configured within an Astro project. Note how input args are passed to the plugin in the config file.

src/externalLink.ts

import type { RehypePlugin } from "@astrojs/markdown-remark";
import { visit } from "unist-util-visit";
import type { Element } from "hast";

interface Options {
  domain: string;
}

export const externalLink: RehypePlugin = (options?: Options) => {
  const siteDomain = options?.domain ?? "";

  return (tree) => {
    visit(tree, (node) => {
      if (node.type != "element") {
        return;
      }

      const element = node as Element;

      if (!isAnchor(element)) {
        return;
      }

      const url = getUrl(element);

      if (isExternal(url, siteDomain)) {
        element.properties!["target"] = "_blank";
      }
    });
  };
};

const isAnchor = (element: Element) => element.tagName == "a" && element.properties && "href" in element.properties;

const getUrl = (element: Element) => {
  if (!element.properties) {
    return "";
  }

  const url = element.properties["href"];

  if (!url) {
    return "";
  }

  return url.toString();
};

const isExternal = (url: string, domain: string) => {
  return url.startsWith("http") && !url.includes(domain);
};

astro.config.ts

export default defineConfig({
  site: "https://www.mysite.com/",
  markdown: {
    rehypePlugins: [[externalLink, { domain: "mysite.com" }]],
  },
});

A relatively small amount of code achieves the desired functionality, and it integrates transparently into the existing Astro project. That feels like a big win, and is one of the reasons I continue to be very happy with Astro has a static site generator.