RemarkCopyLinkedImages
- Published on
- Updated on
- (updated)
Note
As of 2025-05-28, I’ve migrated this site to use astro.js. I don’t use this workaround anymore and rely on astro to bundle the images properly.
Motivation
On my previous post, one concern with bundling
all images in a predefined folder is that there’s no guarantee that all images in that folder are actually used. If an
image is an orphan, it would still be part of the Next.js bundle. I wanted to avoid this and
only bundle images that I care about, so I created a remark1 plugin to copy over images referenced in the mdx
file and rewrite the image url to point to the copied image.
Another annoyance I wanted to solve was to avoid passing in an absolute path to the image, especially because I had
a separate folder for images per post while all mdx live in a separate folder. This is done
to group up images that belong to the same post but it is quite annoying keeping the paths in sync if the post title
gets changed.
content/posts
├── 2024-01-05-hello-world.mdx
├── 2024-01-06-website-stack.mdx
├── 2024-02-09-speeding-up-builds-for-berry-yarn-projects-on-vercel.mdx
├── 2024-04-05-website-stack-update.mdx
└── 2025-04-17-properly-caching-images-in-your-mdx-based-nextjs-blog.mdx
images
├── 2024-02-09-speeding-up-builds-for-berry-yarn-projects-on-vercel
│ └── override-install.png
└── 2025-04-17-properly-caching-images-in-your-mdx-based-nextjs-blog
├── bundled-image.png
├── static-import.png
└── uncached-image.pngPlugin
For the most up to do date implementation, check the src here.
import { existsSync, mkdirSync } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import { basename, extname, isAbsolute, join } from 'node:path'
import type { Node } from 'unist'
import { visit } from 'unist-util-visit'
import { VFile } from 'vfile'
import XXH from 'xxhashjs'
export type ImageNode = Node & {
url: string
}
export interface RemarkCopyLinkedImagesOptions {
destinationDir: string
}
// Hardcoded so images will have the same hash across deploys
const seed = 0xc8052e18
const generateHashFromBuffer = (buffer: Buffer): string => {
return XXH.h64(seed).update(buffer).digest().toString(16)
}
const remarkCopyLinkedImages = (options: RemarkCopyLinkedImagesOptions) => {
const bundledImageFolder = join(process.cwd(), options.destinationDir)
if (!existsSync(bundledImageFolder)) {
mkdirSync(bundledImageFolder)
}
const processImage = async (file: VFile, imageNode: ImageNode) => {
let imagePath: string
if (isAbsolute(imageNode.url)) {
imagePath = join(process.cwd(), imageNode.url)
} else {
imagePath = join(file.dirname!, imageNode.url)
}
const buffer = await readFile(imagePath)
const hash = generateHashFromBuffer(buffer)
const extName = extname(imagePath)
const fileName = basename(imagePath, extName)
const targetFileName = `${fileName}.${hash}${extName}`
const targetFilePath = join(bundledImageFolder, targetFileName)
if (!existsSync(targetFilePath)) {
await writeFile(targetFilePath, buffer)
}
imageNode.url = join('/', options.destinationDir, targetFileName)
}
return async (tree: Node, file: VFile) => {
const promises: Promise<void>[] = []
visit(tree, 'image', (imageNode: ImageNode) => {
promises.push(processImage(file, imageNode))
})
await Promise.all(promises)
}
}
export default remarkCopyLinkedImagesThis plugins contains two parts:
remarkCopyLinkedImages: The entry point of our plugin where it returns a parser (another function). What the parser does here is to only look forimagenodes and callprocessImageon the imageNode as well as store the promise in a list.processImage: Given animagenode, do the following:- Identify whether the path is an absolute path from the project root or a relative path from where the
mdxfile is - Read the file and hash it. I chose xxhash64 as the hashing algorithm since I needed a fast algorithm and didn’t need a cryptographically secure algorithm.
- Copy over the image into the
options.destinationDirfolder with a naming scheme<name>.<hash>.<extension>.
- Identify whether the path is an absolute path from the project root or a relative path from where the
End State
Now, the images get copied over to options.destinationDir:
bundled-images
├── bundled-image.2aa8264d51fb6cec.png
├── bundled-image.2ab7c44.png
├── override-install.c7d75ca514282102.png
├── override-install.df6cf4fd.png
├── static-import.4cb4ac63.png
├── static-import.6b226ead295b7eb5.png
├── uncached-image.6e00d9893afee24b.png
└── uncached-image.722c2010.pngWhile my folder structure for my posts is now:
content/posts
├── 2024-01-05-hello-world
│ └── index.mdx
├── 2024-01-06-website-stack
│ └── index.mdx
├── 2024-02-09-speeding-up-builds-for-berry-yarn-projects-on-vercel
│ ├── index.mdx
│ └── override-install.png
├── 2024-04-05-website-stack-update
│ └── index.mdx
├── 2025-04-17-properly-caching-images-in-your-mdx-based-nextjs-blog
│ ├── bundled-image.png
│ ├── index.mdx
│ ├── static-import.png
│ └── uncached-image.png
└── 2025-04-19-remarkcopylinkedimages
└── index.mdxAnd the image path in the mdx is override-install.png instead of /images/2024-02-09-speeding-up-builds-for-berry-yarn-projects-on-vercel/override-install.png;
a way simpler path to type and keep in sync.