import { visit } from 'unist-util-visit' import fs from 'fs/promises' import path from 'path' import { Node } from 'unist' interface ImageNode extends Node { type: 'image' url: string alt?: string title?: string } interface Options { contentDir: string publicDir: string currentSlug: string } function isRelativePath(url: string): boolean { // Matches: ./, ../, or bare filenames without protocol/absolute path return ( url.startsWith('./') || url.startsWith('../') || (!url.startsWith('/') && !url.includes('://')) ) } function stripQueryParams(url: string): string { return url.split('?')[0] } // In-memory cache to prevent duplicate copies across parallel compilations const copiedFiles = new Set() async function copyAndRewritePath(node: ImageNode, options: Options): Promise { const { contentDir, publicDir, currentSlug } = options const urlWithoutParams = stripQueryParams(node.url) const slugParts = currentSlug.split('/') const contentPostDir = path.join(process.cwd(), contentDir, ...slugParts.slice(0, -1)) const sourcePath = path.resolve(contentPostDir, urlWithoutParams) const allowedBasePath = path.join(process.cwd(), contentDir) if (!sourcePath.startsWith(allowedBasePath)) { throw new Error(`Invalid image path outside content directory: ${node.url}`) } const relativeToContent = path.relative(path.join(process.cwd(), contentDir), sourcePath) const destPath = path.join(process.cwd(), publicDir, relativeToContent) try { await fs.access(sourcePath) } catch { throw new Error( `Image not found: ${sourcePath}\nReferenced in: ${currentSlug}\nURL: ${node.url}` ) } const destDir = path.dirname(destPath) await fs.mkdir(destDir, { recursive: true }) // Deduplication: check cache first const cacheKey = `${sourcePath}:${destPath}` if (copiedFiles.has(cacheKey)) { // Already copied, just rewrite URL const publicUrl = '/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/') const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : '' node.url = publicUrl + queryParams return } // Check if destination exists with matching size try { const [sourceStat, destStat] = await Promise.all([ fs.stat(sourcePath), fs.stat(destPath).catch(() => null), ]) if (destStat && sourceStat.size === destStat.size) { // File already exists and matches, skip copy copiedFiles.add(cacheKey) const publicUrl = '/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/') const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : '' node.url = publicUrl + queryParams return } } catch (error) { // Stat failed, proceed with copy } // Attempt copy with EBUSY retry logic try { await fs.copyFile(sourcePath, destPath) copiedFiles.add(cacheKey) } catch (error: unknown) { const err = error as NodeJS.ErrnoException if (err.code === 'EBUSY') { // Race condition: another process is copying this file // Wait briefly and check if file now exists await new Promise(resolve => setTimeout(resolve, 100)) try { await fs.access(destPath) // File exists now, verify integrity const [sourceStat, destStat] = await Promise.all([fs.stat(sourcePath), fs.stat(destPath)]) if (sourceStat.size === destStat.size) { // Successfully copied by another process copiedFiles.add(cacheKey) } else { // File corrupted, retry once await fs.copyFile(sourcePath, destPath) copiedFiles.add(cacheKey) } } catch { // File still doesn't exist, retry copy await fs.copyFile(sourcePath, destPath) copiedFiles.add(cacheKey) } } else { // Unknown error, rethrow throw error } } const publicUrl = '/' + path.relative(path.join(process.cwd(), 'public'), destPath).replace(/\\/g, '/') const queryParams = node.url.includes('?') ? '?' + node.url.split('?')[1] : '' node.url = publicUrl + queryParams } export function remarkCopyImages(options: Options) { return async (tree: Node) => { const promises: Promise[] = [] visit(tree, 'image', (node: Node) => { const imageNode = node as ImageNode if (isRelativePath(imageNode.url)) { promises.push(copyAndRewritePath(imageNode, options)) } }) await Promise.all(promises) } }