🖼️ added images support
- Should investigate how to resize the image from .md specs
This commit is contained in:
146
lib/remark-copy-images.ts
Normal file
146
lib/remark-copy-images.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user