ehsan.blog
~/blog/how-to-write-a-remark-plugin-for-reading-time — zsh
cat how-to-write-a-remark-plugin-for-reading-time.md

How to write a custom remark plugin to add reading time in Astro

·5 min read

Every blog post I write ends up with a little “4 min read” badge near the title. That number isn’t something I type by hand — it’s computed at build time by a small remark plugin that walks the post’s content, counts the words, and stashes the result on the frontmatter. In this post I’ll show you exactly how that plugin works, line by line, using the real code that powers this blog.

By the end you’ll understand what remark and mdast actually are, how to walk a Markdown syntax tree, and how to pass a computed value back out to Astro so your layout can render it.

What is remark? What is mdast?

When Astro processes a Markdown or MDX file, it doesn’t work with the raw text directly. It first parses the text into a tree structure — a data model where a heading, a paragraph, a code block, and a link are all separate nodes. That tree is called an AST (abstract syntax tree).

  • remark is the tool that parses Markdown into a tree and lets plugins transform it.
  • mdast (Markdown AST) is the name of the tree’s shape — the agreed-upon format for what a “heading node” or “paragraph node” looks like.

A remark plugin is just a function that gets handed this tree and can read or change it before the Markdown is turned into HTML. Our reading-time plugin only reads the tree — it never modifies it. (For a plugin that transforms the output instead, see building a code-block toolbar with a rehype plugin, which works on the HTML tree rather than the Markdown one.)

The whole plugin

Here’s the complete file. It’s genuinely this short:

mjs
// blog/src/utils/remark-reading-time.mjs
import getReadingTime from "reading-time"
import { toString } from "mdast-util-to-string"

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree)
    const readingTime = getReadingTime(textOnPage)
    data.astro.frontmatter.minutesRead = readingTime.text
    data.astro.frontmatter.minutes = Math.max(1, Math.round(readingTime.minutes))
    data.astro.frontmatter.words = readingTime.words
  }
}

Let’s take it apart.

The plugin shape: a function that returns a function

Notice the two layers. remarkReadingTime is the plugin, and it returns another function. This is the standard remark contract:

  • The outer function is where you’d accept options (we don’t need any here).
  • The inner function is the transformer — it receives the parsed tree plus a file object, and runs once per Markdown file.

We destructure { data } out of that second argument. data is a place on the file where different tools can leave notes for each other. Astro specifically exposes data.astro.frontmatter, and that’s our doorway back to the rest of the app.

Turning the tree back into plain text

We don’t want to count HTML tags, list bullets, or code syntax as words — we want the actual prose. That’s what mdast-util-to-string does:

mjs
const textOnPage = toString(tree)

toString walks the entire mdast tree and concatenates every piece of text content into one flat string. Give it the whole tree and you get back roughly what a human would read out loud, with the Markdown structure stripped away.

Counting the words

The reading-time package does the arithmetic for us:

mjs
const readingTime = getReadingTime(textOnPage)

It returns an object with a few useful fields:

  • readingTime.text — a ready-made human string like "4 min read".
  • readingTime.minutes — the raw estimate as a number (e.g. 3.6).
  • readingTime.words — the total word count.

It assumes an average reading speed (around 200 words per minute) so you don’t have to.

Handing the result back to Astro

This is the part that makes the plugin useful instead of just clever:

mjs
data.astro.frontmatter.minutesRead = readingTime.text
data.astro.frontmatter.minutes = Math.max(1, Math.round(readingTime.minutes))
data.astro.frontmatter.words = readingTime.words

We’re writing three new fields onto the frontmatter — the same object that normally holds your title, date, and tags. The Math.max(1, ...) guard makes sure a very short post still shows “1” instead of “0 min”.

Here’s the flow from file to badge:

flowchart LR
  A[.mdx file] --> B[remark parses to mdast tree]
  B --> C[remarkReadingTime reads tree]
  C --> D[writes minutesRead onto frontmatter]
  D --> E[layout renders 4 min read]

Reading the value in your layout

Because remark runs during the build, these fields aren’t available on the frontmatter you import statically — they’re computed. Astro surfaces them through the remarkPluginFrontmatter object you get when you render a post:

astro
---
const { Content, remarkPluginFrontmatter } = await post.render()
---
<p>{remarkPluginFrontmatter.minutesRead}</p>

The name remarkPluginFrontmatter is Astro’s signal that “these fields came from a remark plugin, not from the file’s own frontmatter block.”

Registering the plugin

A plugin does nothing until Astro knows about it. In this blog it’s wired into the Markdown config — which in Astro 7 first requires opting into the unified remark/rehype pipeline:

mjs
// blog/astro.config.mjs
import { remarkReadingTime } from "./src/utils/remark-reading-time.mjs"

markdown: {
  processor: unified({
    remarkPlugins: [remarkReadingTime, remarkMermaid],
    rehypePlugins: [rehypeCodeToolbar],
  }),
}

We pass the function itself (not the result of calling it) into remarkPlugins. Astro invokes it for us.

Takeaways

  • A remark plugin is a function returning a transformer (tree, file) — the outer layer is for options, the inner does the work.
  • mdast is the Markdown tree; mdast-util-to-string flattens it back to prose so you can count real words.
  • Write computed values onto data.astro.frontmatter to pass them out to Astro.
  • Read them later via the remarkPluginFrontmatter returned from post.render().
  • Register the plugin in markdown.remarkPlugins in astro.config.mjs, passing the function by reference.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.