const fs = require("fs"); const path = require("path"); const sharp = require("sharp"); const projectRoot = path.resolve(__dirname, ".."); const decksRoot = path.join(projectRoot, "asset", "tarot deck"); const ignoredFolderNames = new Set(["template", "templates", "example", "examples"]); const supportedImageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif"]); const defaultThumbnailConfig = { root: "thumbs", width: 240, height: 360, fit: "inside", quality: 82 }; function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function asNonEmptyString(value) { const normalized = String(value || "").trim(); return normalized || null; } function shouldIgnoreDeckFolder(folderName) { const normalized = String(folderName || "").trim().toLowerCase(); if (!normalized) { return true; } return normalized.startsWith("_") || normalized.startsWith(".") || ignoredFolderNames.has(normalized); } function readManifestJson(manifestPath) { try { const text = fs.readFileSync(manifestPath, "utf8"); const parsed = JSON.parse(text); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } function normalizeThumbnailConfig(rawConfig) { if (rawConfig === false) { return false; } if (!isPlainObject(rawConfig)) { return null; } return { root: asNonEmptyString(rawConfig.root) || defaultThumbnailConfig.root, width: Number.isInteger(Number(rawConfig.width)) && Number(rawConfig.width) > 0 ? Number(rawConfig.width) : defaultThumbnailConfig.width, height: Number.isInteger(Number(rawConfig.height)) && Number(rawConfig.height) > 0 ? Number(rawConfig.height) : defaultThumbnailConfig.height, fit: asNonEmptyString(rawConfig.fit) || defaultThumbnailConfig.fit, quality: Number.isInteger(Number(rawConfig.quality)) && Number(rawConfig.quality) >= 1 && Number(rawConfig.quality) <= 100 ? Number(rawConfig.quality) : defaultThumbnailConfig.quality }; } function ensureDirectory(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function listSourceImages(deckFolderPath, thumbnailRoot) { const results = []; function walk(currentPath) { const entries = fs.readdirSync(currentPath, { withFileTypes: true }); entries.forEach((entry) => { const entryPath = path.join(currentPath, entry.name); const relativePath = path.relative(deckFolderPath, entryPath).replace(/\\/g, "/"); if (!relativePath) { return; } if (entry.isDirectory()) { if (relativePath === thumbnailRoot || relativePath.startsWith(`${thumbnailRoot}/`)) { return; } walk(entryPath); return; } if (!entry.isFile()) { return; } if (!supportedImageExtensions.has(path.extname(entry.name).toLowerCase())) { return; } results.push(relativePath); }); } walk(deckFolderPath); return results; } function shouldRegenerateThumbnail(sourcePath, outputPath) { if (!fs.existsSync(outputPath)) { return true; } const sourceStat = fs.statSync(sourcePath); const outputStat = fs.statSync(outputPath); return sourceStat.mtimeMs > outputStat.mtimeMs; } function buildSharpPipeline(sourcePath, extension, config) { let pipeline = sharp(sourcePath, { animated: extension === ".gif" }).rotate().resize({ width: config.width, height: config.height, fit: config.fit, withoutEnlargement: true }); if (extension === ".jpg" || extension === ".jpeg") { pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true }); } else if (extension === ".png") { pipeline = pipeline.png({ quality: config.quality, compressionLevel: 9 }); } else if (extension === ".webp") { pipeline = pipeline.webp({ quality: config.quality }); } else if (extension === ".avif") { pipeline = pipeline.avif({ quality: config.quality, effort: 4 }); } return pipeline; } async function generateDeckThumbnails() { const entries = fs.readdirSync(decksRoot, { withFileTypes: true }); let generatedCount = 0; const warnings = []; for (const entry of entries) { if (!entry.isDirectory() || shouldIgnoreDeckFolder(entry.name)) { continue; } const deckFolderPath = path.join(decksRoot, entry.name); const manifestPath = path.join(deckFolderPath, "deck.json"); if (!fs.existsSync(manifestPath)) { warnings.push(`Skipped '${entry.name}': missing deck.json`); continue; } const manifest = readManifestJson(manifestPath); if (!manifest) { warnings.push(`Skipped '${entry.name}': deck.json is invalid JSON`); continue; } const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails); if (!thumbnailConfig) { continue; } const thumbnailRoot = thumbnailConfig.root.replace(/^\.\//, "").replace(/\/$/, ""); ensureDirectory(path.join(deckFolderPath, thumbnailRoot)); const sourceImages = listSourceImages(deckFolderPath, thumbnailRoot); for (const relativePath of sourceImages) { const sourcePath = path.join(deckFolderPath, relativePath); const outputPath = path.join(deckFolderPath, thumbnailRoot, relativePath); ensureDirectory(path.dirname(outputPath)); if (!shouldRegenerateThumbnail(sourcePath, outputPath)) { continue; } const extension = path.extname(sourcePath).toLowerCase(); try { await buildSharpPipeline(sourcePath, extension, thumbnailConfig).toFile(outputPath); generatedCount += 1; } catch (error) { warnings.push(`Failed '${entry.name}/${relativePath}': ${error instanceof Error ? error.message : "unknown error"}`); } } } return { generatedCount, warnings }; } async function main() { const { generatedCount, warnings } = await generateDeckThumbnails(); console.log(`[deck-thumbs] Generated ${generatedCount} thumbnail${generatedCount === 1 ? "" : "s"}`); warnings.forEach((warning) => { console.warn(`[deck-thumbs] ${warning}`); }); } main().catch((error) => { console.error(`[deck-thumbs] ${error instanceof Error ? error.message : "Unknown error"}`); process.exitCode = 1; });