2026-05-04 21:37:56 +02:00
|
|
|
|
import { readdir, readFile, writeFile, access, mkdir } from 'fs/promises';
|
2026-05-03 15:38:07 +02:00
|
|
|
|
import { join, resolve, dirname } from 'path';
|
|
|
|
|
|
import { fileURLToPath } from 'url';
|
|
|
|
|
|
import matter from 'gray-matter';
|
|
|
|
|
|
import { marked } from 'marked';
|
|
|
|
|
|
import nunjucks from 'nunjucks';
|
2026-05-04 21:37:56 +02:00
|
|
|
|
import sharp from 'sharp';
|
2026-05-03 15:38:07 +02:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-05-04 21:37:56 +02:00
|
|
|
|
const PRINT_PX = 2550; // 8.5 in × 300 DPI
|
|
|
|
|
|
|
2026-05-03 15:38:07 +02:00
|
|
|
|
async function fileExists(path) {
|
|
|
|
|
|
try { await access(path); return true; } catch { return false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 21:37:56 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 15:38:07 +02:00
|
|
|
|
async function loadStories() {
|
2026-05-04 21:37:56 +02:00
|
|
|
|
const contentDir = join(root, 'content');
|
|
|
|
|
|
const resizedDir = join(root, 'output', 'resized');
|
2026-05-03 15:38:07 +02:00
|
|
|
|
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) => {
|
2026-05-04 21:37:56 +02:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 15:38:07 +02:00
|
|
|
|
return {
|
|
|
|
|
|
...scene,
|
|
|
|
|
|
imageExists,
|
2026-05-04 21:37:56 +02:00
|
|
|
|
image: imageSrc,
|
2026-05-03 15:38:07 +02:00
|
|
|
|
html: marked.parse(sceneTexts[i] || ''),
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
stories.push({ ...data, scenes });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 19:55:29 +02:00
|
|
|
|
return stories;
|
2026-05-03 15:38:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 18:39:42 +02:00
|
|
|
|
async function loadFrontMatter() {
|
|
|
|
|
|
const raw = await readFile(join(root, 'content', '00-front-matter.md'), 'utf-8');
|
|
|
|
|
|
return matter(raw).data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-03 15:38:07 +02:00
|
|
|
|
async function build() {
|
2026-05-03 19:55:29 +02:00
|
|
|
|
const [stories, frontMatter] = await Promise.all([loadStories(), loadFrontMatter()]);
|
2026-05-03 15:38:07 +02:00
|
|
|
|
|
2026-05-03 19:55:29 +02:00
|
|
|
|
const html = env.render('book.html', { stories, frontMatter });
|
2026-05-03 15:38:07 +02:00
|
|
|
|
|
|
|
|
|
|
const outPath = join(root, 'output', 'book.html');
|
|
|
|
|
|
await writeFile(outPath, html, 'utf-8');
|
|
|
|
|
|
|
2026-05-03 19:55:29 +02:00
|
|
|
|
// imprint + title page + TOC + (4 scenes × 2 pages per story)
|
|
|
|
|
|
const pageCount = 3 + stories.reduce((acc, s) => acc + s.scenes.length * 2, 0);
|
2026-05-03 17:29:10 +02:00
|
|
|
|
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`);
|
2026-05-03 15:38:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
build().catch(err => { console.error(err); process.exit(1); });
|