378 lines
No EOL
14 KiB
JavaScript
378 lines
No EOL
14 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import sharp from 'sharp';
|
|
|
|
// Simple logging utility
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
const log = {
|
|
info: (...args) => isDev && console.log(...args),
|
|
error: (...args) => console.error(...args),
|
|
warn: (...args) => console.warn(...args)
|
|
};
|
|
|
|
// Define source and target directories for posts, pages, projects, docs, and special
|
|
const IMAGE_SYNC_CONFIGS = [
|
|
{
|
|
source: 'src/content/posts/attachments',
|
|
target: 'public/posts/attachments',
|
|
name: 'posts'
|
|
},
|
|
{
|
|
source: 'src/content/pages/attachments',
|
|
target: 'public/pages/attachments',
|
|
name: 'pages'
|
|
},
|
|
{
|
|
source: 'src/content/projects/attachments',
|
|
target: 'public/projects/attachments',
|
|
name: 'projects'
|
|
},
|
|
{
|
|
source: 'src/content/docs/attachments',
|
|
target: 'public/docs/attachments',
|
|
name: 'docs'
|
|
},
|
|
{
|
|
source: 'src/content/special/attachments',
|
|
target: 'public/special/attachments',
|
|
name: 'special'
|
|
}
|
|
];
|
|
|
|
// Recursively find all media files in a directory (images, audio, video, PDF)
|
|
async function findImageFiles(dir, relativePath = '') {
|
|
const imageFiles = [];
|
|
|
|
try {
|
|
const items = await fs.readdir(dir);
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(dir, item);
|
|
const itemRelativePath = path.join(relativePath, item);
|
|
const stat = await fs.stat(itemPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
// Recursively search subdirectories
|
|
const subImages = await findImageFiles(itemPath, itemRelativePath);
|
|
imageFiles.push(...subImages);
|
|
} else if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff|tif|ico|mp3|wav|ogg|m4a|3gp|flac|aac|mp4|webm|ogv|mov|mkv|avi|pdf)$/i.test(item)) {
|
|
// Skip WebP files if we already have the original (to avoid duplicate processing)
|
|
// We'll generate WebP from originals
|
|
if (item.toLowerCase().endsWith('.webp')) {
|
|
const originalName = item.replace(/\.webp$/i, '');
|
|
const hasOriginal = items.some(i => {
|
|
const nameWithoutExt = i.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '');
|
|
return nameWithoutExt === originalName && /\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(i);
|
|
});
|
|
if (hasOriginal) {
|
|
// Skip WebP if we have the original - we'll generate it
|
|
continue;
|
|
}
|
|
}
|
|
imageFiles.push({
|
|
sourcePath: itemPath,
|
|
relativePath: itemRelativePath
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Directory might not exist or be readable, continue
|
|
if (error.code !== 'ENOENT') {
|
|
log.warn(`Warning: Could not read directory ${dir}:`, error.message);
|
|
}
|
|
}
|
|
|
|
return imageFiles;
|
|
}
|
|
|
|
// Function to find folder-based content and sync their images
|
|
async function syncFolderBasedImages(contentType) {
|
|
const contentDir = `src/content/${contentType}`;
|
|
const publicContentDir = `public/${contentType}`;
|
|
|
|
try {
|
|
const items = await fs.readdir(contentDir);
|
|
let totalSynced = 0;
|
|
let totalSkipped = 0;
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(contentDir, item);
|
|
const stat = await fs.stat(itemPath);
|
|
|
|
// Check if it's a directory (folder-based content)
|
|
if (stat.isDirectory()) {
|
|
const targetDir = path.join(publicContentDir, item);
|
|
|
|
// Find all media files recursively
|
|
const imageFiles = await findImageFiles(itemPath);
|
|
|
|
for (const imageFile of imageFiles) {
|
|
// Handle attachments subfolder within folder-based content
|
|
// Convert src/content/posts/post-name/attachments/image.png -> public/posts/post-name/image.png
|
|
let targetRelativePath = imageFile.relativePath;
|
|
// Handle both forward and backward slashes for cross-platform compatibility
|
|
if (targetRelativePath.startsWith('attachments/') || targetRelativePath.startsWith('attachments\\')) {
|
|
targetRelativePath = targetRelativePath.replace(/^attachments[/\\]/, '');
|
|
}
|
|
|
|
const targetPath = path.join(targetDir, targetRelativePath);
|
|
const targetDirForFile = path.dirname(targetPath);
|
|
|
|
// Ensure target directory exists
|
|
await ensureDir(targetDirForFile);
|
|
|
|
// Check if file needs updating - check both original and WebP versions
|
|
let needsUpdate = true;
|
|
|
|
// Check if this is an image that would be converted to WebP
|
|
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
|
const webpPath = path.join(targetDir, targetRelativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp'));
|
|
try {
|
|
const sourceStats = await fs.stat(imageFile.sourcePath);
|
|
const webpStats = await fs.stat(webpPath);
|
|
// Only update if source is newer than WebP
|
|
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
|
} catch {
|
|
// WebP doesn't exist, needs update
|
|
needsUpdate = true;
|
|
}
|
|
} else {
|
|
try {
|
|
const sourceStats = await fs.stat(imageFile.sourcePath);
|
|
const targetStats = await fs.stat(targetPath);
|
|
// Only update if source is newer or different size
|
|
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
|
} catch {
|
|
// Target doesn't exist, definitely needs update
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
// Optimize image if it's an image format (not audio, video, or PDF)
|
|
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
|
try {
|
|
// Convert to WebP and optimize
|
|
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
|
await sharp(imageFile.sourcePath)
|
|
.webp({ quality: 85 })
|
|
.toFile(webpPath);
|
|
totalSynced++;
|
|
} catch (error) {
|
|
// If Sharp fails, fall back to copying original
|
|
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
|
await fs.copyFile(imageFile.sourcePath, targetPath);
|
|
totalSynced++;
|
|
}
|
|
} else {
|
|
// Non-image files (audio, video, PDF) - just copy as-is
|
|
await fs.copyFile(imageFile.sourcePath, targetPath);
|
|
totalSynced++;
|
|
}
|
|
} else {
|
|
totalSkipped++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalSynced > 0 || totalSkipped > 0) {
|
|
log.info(`📁 Syncing folder-based ${contentType} images...`);
|
|
if (totalSynced > 0) log.info(` Synced ${totalSynced} files`);
|
|
if (totalSkipped > 0) log.info(` Skipped ${totalSkipped} files that were unchanged`);
|
|
}
|
|
|
|
return { synced: totalSynced, skipped: totalSkipped };
|
|
} catch (error) {
|
|
log.error(`❌ Error syncing folder-based ${contentType} images:`, error);
|
|
return { synced: 0, skipped: 0 };
|
|
}
|
|
}
|
|
|
|
async function ensureDir(dir) {
|
|
try {
|
|
await fs.access(dir);
|
|
} catch {
|
|
await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
async function syncImagesForConfig(config) {
|
|
// Ensure target directory exists
|
|
await ensureDir(config.target);
|
|
|
|
try {
|
|
// Check if source directory exists
|
|
let sourceFiles = [];
|
|
try {
|
|
sourceFiles = await fs.readdir(config.source);
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return { synced: 0, skipped: 0, removed: 0 };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// Find all image files recursively (including subdirectories)
|
|
const imageFiles = await findImageFiles(config.source);
|
|
|
|
let synced = 0;
|
|
let skipped = 0;
|
|
|
|
for (const imageFile of imageFiles) {
|
|
const targetPath = path.join(config.target, imageFile.relativePath);
|
|
const targetDirForFile = path.dirname(targetPath);
|
|
|
|
// Ensure target directory exists (including subdirectories)
|
|
await ensureDir(targetDirForFile);
|
|
|
|
// Check if file needs updating - check both original and WebP versions
|
|
let needsUpdate = true;
|
|
|
|
// Check if this is an image that would be converted to WebP
|
|
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
|
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
|
try {
|
|
const sourceStats = await fs.stat(imageFile.sourcePath);
|
|
const webpStats = await fs.stat(webpPath);
|
|
// Only update if source is newer than WebP
|
|
needsUpdate = sourceStats.mtime > webpStats.mtime;
|
|
} catch {
|
|
// WebP doesn't exist, needs update
|
|
needsUpdate = true;
|
|
}
|
|
} else {
|
|
try {
|
|
const sourceStats = await fs.stat(imageFile.sourcePath);
|
|
const targetStats = await fs.stat(targetPath);
|
|
// Only update if source is newer or different size
|
|
needsUpdate = sourceStats.mtime > targetStats.mtime || sourceStats.size !== targetStats.size;
|
|
} catch {
|
|
// Target doesn't exist, definitely needs update
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (needsUpdate) {
|
|
// Optimize image if it's an image format (not audio, video, or PDF)
|
|
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(imageFile.relativePath)) {
|
|
try {
|
|
// Convert to WebP and optimize
|
|
const webpPath = targetPath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
|
await sharp(imageFile.sourcePath)
|
|
.webp({ quality: 85 })
|
|
.toFile(webpPath);
|
|
synced++;
|
|
} catch (error) {
|
|
// If Sharp fails, fall back to copying original
|
|
log.warn(`⚠️ Could not optimize ${imageFile.relativePath}, copying original:`, error.message);
|
|
await fs.copyFile(imageFile.sourcePath, targetPath);
|
|
synced++;
|
|
}
|
|
} else {
|
|
// Non-image files (audio, video, PDF) - just copy as-is
|
|
await fs.copyFile(imageFile.sourcePath, targetPath);
|
|
synced++;
|
|
}
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
// Cleanup: Remove files from target that no longer exist in source
|
|
// We need to recursively clean up the target directory
|
|
await cleanupTargetDirectory(config.target, imageFiles);
|
|
|
|
return { synced, skipped, removed: 0 }; // removed count handled in cleanup function
|
|
} catch (error) {
|
|
log.error(`❌ Error syncing ${config.name} images:`, error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Recursively clean up target directory, removing files that no longer exist in source
|
|
async function cleanupTargetDirectory(targetDir, sourceImageFiles) {
|
|
// Create a set that maps both original paths and attachments subfolder paths
|
|
const sourceFileSet = new Set();
|
|
sourceImageFiles.forEach(f => {
|
|
sourceFileSet.add(f.relativePath);
|
|
// Also add the path without attachments/ prefix for cleanup
|
|
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
|
sourceFileSet.add(f.relativePath.replace(/^attachments[/\\]/, ''));
|
|
}
|
|
// Add WebP version of image paths (since we generate WebP from originals)
|
|
if (/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i.test(f.relativePath)) {
|
|
const webpPath = f.relativePath.replace(/\.(jpg|jpeg|png|gif|bmp|tiff|tif)$/i, '.webp');
|
|
sourceFileSet.add(webpPath);
|
|
if (f.relativePath.startsWith('attachments/') || f.relativePath.startsWith('attachments\\')) {
|
|
const webpPathNoAttachments = webpPath.replace(/^attachments[/\\]/, '');
|
|
sourceFileSet.add(webpPathNoAttachments);
|
|
}
|
|
}
|
|
});
|
|
let removed = 0;
|
|
|
|
try {
|
|
const items = await fs.readdir(targetDir);
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(targetDir, item);
|
|
const stat = await fs.stat(itemPath);
|
|
|
|
if (stat.isDirectory()) {
|
|
// Recursively clean subdirectories
|
|
const subRemoved = await cleanupTargetDirectory(itemPath, sourceImageFiles);
|
|
removed += subRemoved;
|
|
|
|
// Remove empty directories
|
|
try {
|
|
const remainingItems = await fs.readdir(itemPath);
|
|
if (remainingItems.length === 0) {
|
|
await fs.rmdir(itemPath);
|
|
}
|
|
} catch {
|
|
// Directory might not be empty or might have been removed already
|
|
}
|
|
} else {
|
|
// Check if this file exists in source
|
|
const relativePath = path.relative(targetDir, itemPath).replace(/\\/g, '/');
|
|
if (!sourceFileSet.has(relativePath)) {
|
|
await fs.unlink(itemPath);
|
|
removed++;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Directory might not exist or be readable
|
|
if (error.code !== 'ENOENT') {
|
|
log.warn(`Warning: Could not clean directory ${targetDir}:`, error.message);
|
|
}
|
|
}
|
|
|
|
return removed;
|
|
}
|
|
|
|
async function syncAllImages() {
|
|
log.info('🖼️ Syncing images from content to public directory...');
|
|
|
|
for (const config of IMAGE_SYNC_CONFIGS) {
|
|
const result = await syncImagesForConfig(config);
|
|
if (result.synced > 0 || result.skipped > 0) {
|
|
log.info(`📁 Syncing ${config.name} images...`);
|
|
if (result.synced > 0) log.info(` Synced ${result.synced} files`);
|
|
if (result.skipped > 0) log.info(` Skipped ${result.skipped} files that were unchanged`);
|
|
}
|
|
}
|
|
|
|
// Sync folder-based images for all content types
|
|
const contentTypes = ['posts', 'pages', 'projects', 'docs', 'special'];
|
|
for (const contentType of contentTypes) {
|
|
await syncFolderBasedImages(contentType);
|
|
}
|
|
|
|
log.info('🎉 Image sync complete!');
|
|
}
|
|
|
|
syncAllImages(); |