Alexander Garcia
Why I spent 2 hours writing 83 lines of code to avoid installing a 50KB package. Spoiler: It was totally worth it. Learn how to build a custom Remark/Recma plugin for MDX.
Read time is about 13 minutes
Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.
Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.
Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.
Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.
Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.
Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.
I wanted to add read time estimates to my blog posts to increase SEO visibility. You know, those little "5 min read" tags you see everywhere? Simple feature, right?
So I did what any reasonable developer would do: I googled "mdx read time npm" and found like 10 different packages that could solve this in 30 seconds.
But then I looked at my package.json.
It already looked like a receipt you would get at CVS ๐ฑ. Did I really need another dependency for something that should be relatively this simple?
Narrator: He did not.
Look, I get it. Modern JavaScript development is all about standing on the shoulders of giants (and their giants, and their giants' giants). But sometimes you look at your node_modules folder and wonder if you've accidentally downloaded the entire internet like I do. And from previous experience where installing dependencies requires what seems like an act of Congress I decided to audit my dependency usage.
$ du -sh node_modules/ 306M node_modules/
So instead of installing reading-time, remark-reading-time, or mdx-reading-time-for-cool-people (okay, I made that last one up), I decided to build my own.
Here's the thing about MDX: it's a magical transformer that turns markdown into React components. The transformation happens in five stages:
For read time calculation, we need to work at two stages:
First, we need to count words while we still have access to the markdown structure:
export function remarkCalculateWordCount() { return (tree: Root, file: VFile) => { let wordCount = 0; // Visit all text nodes visit(tree, "text", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += words.length; }); // Count code blocks at 50% weight // (because nobody really "reads" code) visit(tree, "code", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += Math.floor(words.length * 0.5); }); // Stash the count for later file.data.wordCount = wordCount; }; }
Then, we use a Recma plugin to inject the calculated read time into the frontmatter export in the final JavaScript:
export function recmaInjectReadTime() { return (tree: any, file: VFile) => { const wordCount = (file.data?.wordCount as number) || 0; const WORDS_PER_MINUTE = 100; const readTime = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); // Find and modify: export const frontmatter = { ... } visitEstree(tree, (node: any) => { if ( node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration" ) { const declaration = node.declaration.declarations?.[0]; if ( declaration?.id?.name === "frontmatter" && declaration.init?.type === "ObjectExpression" ) { // Add readTime property to frontmatter declaration.init.properties.push({ type: "Property", key: { type: "Identifier", name: "readTime" }, value: { type: "Literal", value: readTime }, kind: "init", method: false, shorthand: false, computed: false, }); } } }); }; }
You might be thinking: "Alex, you just wrote 83 lines of code to avoid installing a package. That's not efficient."
And you'd be right! But here's why I did it anyway:
No new packages to audit, update, or worry about getting deprecated. The only dependencies are ones I already had (unist-util-visit and estree-util-visit) and those were only to parse the tree.
Want to count code blocks at 30% instead of 50%? Change one number. Want to exclude certain sections? Add a filter. No waiting for upstream maintainers or worrying about breaking changes.
This was a great excuse to learn how MDX transforms work under the hood. AST manipulation used to be "witchcraft" to me, then I wrote some code to bend AST manipulation to my will. I mean its still "witchcraft" but my knowledge has expanded on it exponentially.
This runs during build time, not runtime. Zero impact on bundle size or performance ๐ and we all know how much I love web performance.
There's something deeply satisfying about solving a problem with exactly the code you need and nothing more.
To use these plugins in your MDX setup (I'm using Rsbuild with @rsbuild/plugin-mdx):
// rsbuild.config.ts import { remarkCalculateWordCount, recmaInjectReadTime, } from "./src/lib/recma-read-time"; export default defineConfig({ plugins: [ pluginMdx({ mdxOptions: { remarkPlugins: [ remarkCalculateWordCount, // Stage 1: Count words ], recmaPlugins: [ recmaInjectReadTime, // Stage 2: Inject readTime ], }, }), ], });
Now every MDX file automatically gets a readTime property in its frontmatter that is automatically injected (how cool is that):
import { frontmatter } from "./my-post.mdx"; console.log(frontmatter.readTime); // 5 (5 minutes)
I went with 100 words per minute for the base reading speed. According to research, the average reading speed is 200-250 WPM, but I did some research and found that technical content is always read a little more carefully:
So 100 WPM feels about right for technical blog posts.
Also, I set a minimum of 1 minute because "0 min read" would be pretty insane ๐
// Before import readingTime from "reading-time"; const stats = readingTime(content); // + 1 dependency, + 50KB, runtime processing // After import { frontmatter } from "./post.mdx"; const readTime = frontmatter.readTime; // + 0 dependencies, + 0KB, build-time processing โจ
Let's do the math:
Okay, maybe it wasn't the most economically rational decision. But I learned something new, have full control over the implementation, and my node_modules folder is 50KB lighter. Not really all that much but every bit counts (pun intended).
Plus, now I have a blog post about it. So really, this was a 4D chess move for content creation.
Build a custom solution when:
Use an existing package when:
The full implementation is here:
recma-read-time.tsimport { visit } from "unist-util-visit"; import { visit as visitEstree } from "estree-util-visit"; import type { Root } from "mdast"; import type { VFile } from "vfile"; /** * Remark plugin to calculate word count from MDX content * Stores the result in file.data for use by recma plugin */ export function remarkCalculateWordCount() { return (tree: Root, file: VFile) => { let wordCount = 0; // Visit all text nodes in the MDX AST visit(tree, "text", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += words.length; }); // Also count words in code blocks (at 50% weight) visit(tree, "code", (node: any) => { const words = node.value .split(/\s+/) .filter((word: string) => word.length > 0); wordCount += Math.floor(words.length * 0.5); }); // Store word count in file data for recma plugin if (!file.data) { file.data = {}; } file.data.wordCount = wordCount; }; } /** * Recma plugin to inject readTime into the frontmatter export * Reads word count from file.data and modifies the JavaScript AST */ export function recmaInjectReadTime() { return (tree: any, file: VFile) => { const wordCount = (file.data?.wordCount as number) || 0; const WORDS_PER_MINUTE = 100; const minutes = Math.ceil(wordCount / WORDS_PER_MINUTE); const readTime = Math.max(1, minutes); // Visit all nodes in the JavaScript AST visitEstree(tree, (node: any) => { // Look for: export const frontmatter = { ... } if ( node.type === "ExportNamedDeclaration" && node.declaration?.type === "VariableDeclaration" ) { const declaration = node.declaration.declarations?.[0]; if ( declaration?.id?.name === "frontmatter" && declaration.init?.type === "ObjectExpression" ) { // Found the frontmatter export, add readTime property declaration.init.properties.push({ type: "Property", key: { type: "Identifier", name: "readTime", }, value: { type: "Literal", value: readTime, }, kind: "init", method: false, shorthand: false, computed: false, }); } } }); }; }
rsbuild.config.tsimport { defineConfig } from "@rsbuild/core"; import { pluginReact } from "@rsbuild/plugin-react"; import { pluginMdx } from "@rsbuild/plugin-mdx"; import remarkGfm from "remark-gfm"; import { remarkCalculateWordCount, recmaInjectReadTime, } from "./src/lib/recma-read-time"; export default defineConfig({ plugins: [ pluginReact(), pluginMdx({ mdxLoaderOptions: { providerImportSource: "@mdx-js/react", remarkPlugins: [remarkGfm, remarkCalculateWordCount], recmaPlugins: [recmaInjectReadTime], }, }), ], // rest of Rsbuild config options });
Related Posts: