import { readdir, readFile, writeFile, access, mkdir } from 'fs/promises'; import { join, resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import matter from 'gray-matter'; import { marked } from 'marked'; import nunjucks from 'nunjucks'; import sharp from 'sharp'; const __dir = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dir, '..'); // Nunjucks: load templates relative to project root const env = nunjucks.configure(join(root, 'templates'), { autoescape: true }); const PRINT_PX = 2550; // 8.5 in × 300 DPI async function fileExists(path) { try { await access(path); return true; } catch { return false; } } // Resize image to PRINT_PX on the long side, write to output/resized/ tree. // Returns the resized file path (absolute). Skips resize if already ≤ PRINT_PX. async function resizeForPrint(srcAbs, resizedDir) { const rel = srcAbs.replace(root + '/', ''); // e.g. images/01/scene-1.png const dest = join(resizedDir, rel); const destDir = dirname(dest); await mkdir(destDir, { recursive: true }); const meta = await sharp(srcAbs).metadata(); const maxDim = Math.max(meta.width || 0, meta.height || 0); if (maxDim <= PRINT_PX) { // Already small enough — copy as-is await sharp(srcAbs).toFile(dest); } else { await sharp(srcAbs) .resize(PRINT_PX, PRINT_PX, { fit: 'inside', withoutEnlargement: true }) .png({ compressionLevel: 6 }) .toFile(dest); } return dest; } async function loadStories() { const contentDir = join(root, 'content'); const resizedDir = join(root, 'output', 'resized'); const files = (await readdir(contentDir)).filter(f => f.endsWith('.md')).sort(); const stories = []; for (const file of files) { const raw = await readFile(join(contentDir, file), 'utf-8'); const { data, content } = matter(raw); if (data.type === 'front-matter') continue; const sceneTexts = content.split(/\n---\n/).map(t => t.trim()).filter(Boolean); const scenes = await Promise.all( (data.scenes || []).map(async (scene, i) => { const srcAbs = join(root, scene.image); const imageExists = await fileExists(srcAbs); let imageSrc = `../${scene.image}`; if (imageExists) { await resizeForPrint(srcAbs, resizedDir); // Relative path from output/book.html to output/resized/ imageSrc = `resized/${scene.image}`; } return { ...scene, imageExists, image: imageSrc, html: marked.parse(sceneTexts[i] || ''), }; }) ); stories.push({ ...data, scenes }); } return stories; } async function loadFrontMatter() { const raw = await readFile(join(root, 'content', '00-front-matter.md'), 'utf-8'); return matter(raw).data; } async function build() { const [stories, frontMatter] = await Promise.all([loadStories(), loadFrontMatter()]); const html = env.render('book.html', { stories, frontMatter }); const outPath = join(root, 'output', 'book.html'); await writeFile(outPath, html, 'utf-8'); // imprint + title page + TOC + (4 scenes × 2 pages per story) const pageCount = 3 + stories.reduce((acc, s) => acc + s.scenes.length * 2, 0); await writeFile( join(root, 'output', 'book-meta.json'), JSON.stringify({ pageCount, storyCount: stories.length, builtAt: new Date().toISOString() }, null, 2), 'utf-8' ); console.log(`Built output/book.html — ${stories.length} stories, ${pageCount} pages`); } build().catch(err => { console.error(err); process.exit(1); });