Skip to content
Go back

Using Custom Remark Plugins in an Astro Project

Published:  at  05:00 PM
Reads: 0

This article introduces how to use two custom Remark plugins — remark-admonitions (callout boxes) and remark-button (buttons) — in a new Astro project, enabling Markdown content to support rich custom components.

Of course, I know there are plenty of ready-made plugins available online, but I chose this approach anyway.

Preview

Before installation, let’s first see what these two plugins can achieve.

Admonitions (Callout Boxes)

Five types of admonitions are suitable for different scenarios:

This is a regular note admonition, used for supplementary information.

This is a friendly tip, great for sharing small tricks.

This is an info admonition, used for providing extra information.

This is a warning admonition, used to alert users.

This is a danger warning, used to remind users to proceed with caution.

Button

Here are various button variants:

Small Primary Button

Medium Success Button

Large Danger Button

Warning Button

Custom Purple Rounded Button

Plugin Overview

These two plugins are built on remark-directive and use Markdown’s ::: directive syntax to insert custom UI components into articles.

Prerequisites

Before getting started, make sure your project meets the following requirements:

npm packages to install:

PackagePurpose
remark-directiveParses the ::: directive syntax in Markdown
unist-util-visitTraverses and manipulates AST nodes
hastscriptCreates hast (HTML AST) nodes declaratively
pnpm add remark-directive unist-util-visit hastscript

Step 1: Create Plugin Files

Create a plugins/ directory in the project root and place the following two files inside.

remark-admonitions.ts

import { visit } from "unist-util-visit";
import { h } from "hastscript";

const admonitions = ["note", "tip", "info", "warning", "danger"];

export default function remarkAdmonitions() {
  return tree => {
    visit(tree, node => {
      if (
        node.type === "containerDirective" ||
        node.type === "leafDirective" ||
        node.type === "textDirective"
      ) {
        if (admonitions.includes(node.name)) {
          const data = node.data || (node.data = {});
          const hast = h("div", node.attributes || {});

          if (node.children && node.children.length > 0) {
            const firstChild = node.children[0];
            if (firstChild.type === "paragraph") {
              const firstChildData = firstChild.data || (firstChild.data = {});
              const existingClass =
                (firstChildData.hProperties?.className as string[]) || [];
              firstChildData.hProperties = {
                ...firstChildData.hProperties,
                className: ["admonition-title", ...existingClass],
              };
            }
          }

          data.hName = hast.tagName;
          data.hProperties = {
            ...hast.properties,
            className: [
              node.name,
              ...((hast.properties?.className as string[]) || []),
            ],
          };
        }
      }
    });
  };
}

remark-button.ts

import { visit } from "unist-util-visit";
import { h } from "hastscript";

const PRESET_SIZES = ["xs", "sm", "md", "lg", "xl"];
const PRESET_COLORS = [
  "primary",
  "secondary",
  "success",
  "danger",
  "warning",
  "info",
  "light",
  "dark",
  "pink",
  "purple",
  "indigo",
  "teal",
  "cyan",
  "orange",
];
const PRESET_TEXT_COLORS = ["white", "black", "gray", "light", "dark"];

export default function remarkButton() {
  return tree => {
    visit(tree, node => {
      if (
        node.type === "containerDirective" ||
        node.type === "leafDirective" ||
        node.type === "textDirective"
      ) {
        if (node.name === "button") {
          const data = node.data || (node.data = {});
          const hast = h("a", node.attributes || {});

          const classNames = ["custom-button"];
          const inlineStyles: string[] = [];

          if (node.attributes?.size) {
            if (PRESET_SIZES.includes(node.attributes.size)) {
              classNames.push(`button-${node.attributes.size}`);
            } else {
              inlineStyles.push(`font-size: ${node.attributes.size}`);
            }
          }

          if (node.attributes?.color) {
            if (PRESET_COLORS.includes(node.attributes.color)) {
              classNames.push(`button-${node.attributes.color}`);
            } else {
              inlineStyles.push(`background-color: ${node.attributes.color}`);
            }
          }

          if (node.attributes?.textColor) {
            if (PRESET_TEXT_COLORS.includes(node.attributes.textColor)) {
              classNames.push(`button-text-${node.attributes.textColor}`);
            } else {
              inlineStyles.push(`color: ${node.attributes.textColor}`);
            }
          }

          if (node.attributes?.padding) {
            inlineStyles.push(`padding: ${node.attributes.padding}`);
          }

          if (node.attributes?.borderRadius) {
            inlineStyles.push(`border-radius: ${node.attributes.borderRadius}`);
          }

          classNames.push(...((hast.properties?.className as string[]) || []));

          const properties: Record<string, string | string[]> = {
            ...hast.properties,
            className: classNames,
            target: node.attributes?.target || "_blank",
            rel: "noopener noreferrer",
          };

          if (inlineStyles.length > 0) {
            properties.style = inlineStyles.join("; ");
          }

          data.hName = "a";
          data.hProperties = properties;
        }
      }
    });
  };
}

Step 2: Configure Astro

Edit astro.config.ts and register these two plugins in markdown.remarkPlugins:

import { defineConfig } from "astro/config";
import remarkDirective from "remark-directive";
import remarkAdmonitions from "./plugins/remark-admonitions";
import remarkButton from "./plugins/remark-button";

export default defineConfig({
  markdown: {
    remarkPlugins: [
      remarkDirective, // Must be registered before custom plugins
      remarkAdmonitions,
      remarkButton,
    ],
  },
});

remarkDirective must come before remarkAdmonitions and remarkButton. This is because remark-directive is responsible for parsing the ::: syntax into directive nodes, and then the custom plugins convert these nodes into HTML. If the order is wrong, the custom plugins won’t receive any directive nodes, and all ::: blocks won’t be rendered.

Step 3: Add CSS Styles

Admonition Styles

In your global CSS file (e.g., src/styles/global.css), add:

/* Admonition base styles */
.prose .note,
.prose .tip,
.prose .info,
.prose .warning,
.prose .danger {
  @apply my-4 rounded-lg border-l-4 p-4;
}

/* Admonition title */
.prose .note .admonition-title,
.prose .tip .admonition-title,
.prose .info .admonition-title,
.prose .warning .admonition-title,
.prose .danger .admonition-title {
  @apply mb-2 text-lg font-bold;
}

/* Color schemes by type */
.prose .note {
  @apply border-blue-500 bg-blue-50 text-blue-900;
}

.prose .tip {
  @apply border-green-500 bg-green-50 text-green-900;
}

.prose .info {
  @apply border-cyan-500 bg-cyan-50 text-cyan-900;
}

.prose .warning {
  @apply border-yellow-500 bg-yellow-50 text-yellow-900;
}

.prose .danger {
  @apply border-red-500 bg-red-50 text-red-900;
}

/* Dark mode adaptation */
html[data-theme="dark"] .prose .note {
  @apply border-blue-400 bg-blue-950/50 text-blue-100;
}

html[data-theme="dark"] .prose .tip {
  @apply border-green-400 bg-green-950/50 text-green-100;
}

html[data-theme="dark"] .prose .info {
  @apply border-cyan-400 bg-cyan-950/50 text-cyan-100;
}

html[data-theme="dark"] .prose .warning {
  @apply border-yellow-400 bg-yellow-950/50 text-yellow-100;
}

html[data-theme="dark"] .prose .danger {
  @apply border-red-400 bg-red-950/50 text-red-100;
}

Button Styles

/* Button base styles */
.custom-button {
  @apply inline-flex items-center justify-center rounded-lg bg-accent px-6 py-1 text-base font-medium text-white transition-all duration-200 hover:shadow-lg;
  text-decoration: none;
}

.custom-button:hover {
  transform: translateY(-2px);
}

.custom-button:active {
  transform: translateY(0);
}

/* Size variants */
.button-xs {
  @apply px-3 py-1 text-xs;
}
.button-sm {
  @apply px-4 py-1.5 text-sm;
}
.button-md {
  @apply px-6 py-2.5 text-base;
}
.button-lg {
  @apply px-8 py-3 text-lg;
}
.button-xl {
  @apply px-10 py-4 text-xl;
}

/* Color variants */
.button-primary {
  @apply bg-blue-600 hover:bg-blue-700;
}
.button-secondary {
  @apply bg-gray-600 hover:bg-gray-700;
}
.button-success {
  @apply bg-green-600 hover:bg-green-700;
}
.button-danger {
  @apply bg-red-600 hover:bg-red-700;
}
.button-warning {
  @apply bg-yellow-500 hover:bg-yellow-600;
}
.button-info {
  @apply bg-blue-500 hover:bg-blue-600;
}
.button-light {
  @apply bg-gray-200 hover:bg-gray-300;
}
.button-dark {
  @apply bg-gray-800 hover:bg-gray-900;
}
.button-pink {
  @apply bg-pink-500 hover:bg-pink-600;
}
.button-purple {
  @apply bg-purple-600 hover:bg-purple-700;
}
.button-indigo {
  @apply bg-indigo-600 hover:bg-indigo-700;
}
.button-teal {
  @apply bg-teal-500 hover:bg-teal-600;
}
.button-cyan {
  @apply bg-cyan-500 hover:bg-cyan-600;
}
.button-orange {
  @apply bg-orange-500 hover:bg-orange-600;
}

/* Text color variants */
.button-text-white {
  @apply text-white;
}
.button-text-black {
  @apply text-black;
}
.button-text-gray {
  @apply text-gray-800;
}
.button-text-light {
  @apply text-gray-100;
}
.button-text-dark {
  @apply text-gray-900;
}

The above styles use Tailwind CSS’s @apply directive. If you don’t use Tailwind, simply replace @apply with equivalent CSS properties.

Step 4: Using in Markdown

Admonition Syntax

Supports five types: note, tip, info, warning, danger. The first paragraph inside an admonition is automatically rendered as a bold title.

:::note
This is a regular note admonition.
:::

:::tip
This is a friendly tip.
:::

:::info
This is an info admonition, used for providing extra information.
:::

:::warning
This is a warning admonition, used to alert users.
:::

:::danger
This is a danger warning, used to remind users to proceed with caution.
:::

Button Syntax

Buttons use the :::button{attributes...} syntax and support the following attributes:

AttributeDescriptionPreset ValuesCustom Example
hrefButton link URLAny URLhref="https://example.com"
sizeButton sizexs, sm, md, lg, xlsize="1.5rem"
colorBackground colorprimary, secondary, success, danger, warning, info, light, dark, pink, purple, indigo, teal, cyan, orangecolor="#ff6b6b"
textColorText colorwhite, black, gray, light, darktextColor="#2d3436"
paddingPaddingAny CSS valuepadding="1rem 2rem"
borderRadiusBorder radiusAny CSS valueborderRadius="50px"
targetLink open modeDefault _blanktarget="_self"

When size, color, and textColor use preset values, corresponding CSS class names are generated; when using non-preset values, the plugin writes the values directly as inline CSS properties in the element’s style attribute.

Preset Sizes

:::button{href="https://example.com" size="xs"}
Extra Small Button
:::

:::button{href="https://example.com" size="sm"}
Small Button
:::

:::button{href="https://example.com" size="md"}
Medium Button
:::

:::button{href="https://example.com" size="lg"}
Large Button
:::

:::button{href="https://example.com" size="xl"}
Extra Large Button
:::

Preset Colors

:::button{href="https://example.com" color="primary"}
Primary Button
:::

:::button{href="https://example.com" color="danger"}
Danger Button
:::

:::button{href="https://example.com" color="warning" textColor="black"}
Warning Button (Dark Text)
:::

Fully Custom

:::button{href="https://example.com" size="0.75rem"}
Extra Small Font Button
:::

:::button{href="https://example.com" color="#ff6b6b"}
Custom Red Button
:::

:::button{href="https://example.com" color="#ffeaa7" textColor="#2d3436"}
Custom Yellow Button with Dark Text
:::

:::button{href="https://example.com" padding="1rem 2rem"}
Custom Padding Button
:::

:::button{href="https://example.com" borderRadius="2rem"}
Rounded Button
:::

Combined Usage

:::button{href="https://example.com" size="lg" color="success"}
Large Success Button
:::

:::button{href="https://example.com" size="sm" color="danger"}
Small Danger Button
:::

:::button{href="https://example.com" color="#fd79a8" textColor="white" padding="1.5rem 3rem" borderRadius="50px"}
Fully Custom Button
:::

Additional Notes

Plugin Registration Order

The processing flow is as follows:

  1. remark-directive parses the :::name{...} syntax into directive AST nodes
  2. remark-admonitions / remark-button convert matching directive nodes into HTML

About TypeScript

If you use astro check for type checking, you may need to add type declarations for the plugin files. It’s recommended to create corresponding declaration files in the plugins/ directory:

// plugins/remark-admonitions.d.ts
declare module "./plugins/remark-admonitions" {
  const value: () => (tree: any) => void;
  export default value;
}

// plugins/remark-button.d.ts
declare module "./plugins/remark-button" {
  const value: () => (tree: any) => void;
  export default value;
}

CSS Framework Compatibility

Known Limitations

  1. Buttons open in a new tab by default (target="_blank"). To navigate in the current page, specify target="_self"
  2. The button’s rel attribute is fixed to "noopener noreferrer" and cannot be customized
  3. The first paragraph of an admonition is always rendered as a title and cannot be disabled
  4. Admonitions currently do not support passing custom attributes; only the five preset types are supported

Share this post on:

Previous Post
Tried Integrating an Online Gaming Platform
Next Post
Complete Guide to Free Website Building Resources