147 lines
4.4 KiB
TypeScript
147 lines
4.4 KiB
TypeScript
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<string>()
|
|
|
|
async function copyAndRewritePath(node: ImageNode, options: Options): Promise<void> {
|
|
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)
|
|
|
|
if (sourcePath.includes('..') && !sourcePath.startsWith(path.join(process.cwd(), contentDir))) {
|
|
throw new Error(`Invalid image path: ${node.url} (path traversal detected)`)
|
|
}
|
|
|
|
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<void>[] = []
|
|
|
|
visit(tree, 'image', (node: Node) => {
|
|
const imageNode = node as ImageNode
|
|
if (isRelativePath(imageNode.url)) {
|
|
promises.push(copyAndRewritePath(imageNode, options))
|
|
}
|
|
})
|
|
|
|
await Promise.all(promises)
|
|
}
|
|
}
|