// Build-time generator: for each bundle in src/content/bundles, compose a // mosaic JPG from the item heroes available in that bundle. Skips work // when output is newer than every input. Layout depends on hero count: // 0 -> no mosaic (file is removed if it exists) // 1 -> 1200x1200 single image // 2 -> 1200x600 side-by-side // 3 -> 1200x1200 with three cells filled, fourth left as background // 4+ -> 1200x1200 2x2 import { readdir, readFile, mkdir, stat, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); const BUNDLES_DIR = join(ROOT, 'src/content/bundles'); const FIND_DIR = join(ROOT, 'src/content/find'); const OUT_DIR = join(ROOT, 'src/generated/bundle-mosaics'); const TILE = 598; const GAP = 4; const FULL = TILE * 2 + GAP; // 1200 const BG = { r: 224, g: 219, b: 210, alpha: 1 }; // matches light --rule const HERO_EXTS = ['jpg', 'jpeg', 'png', 'webp', 'gif']; function parseItems(frontmatter) { const lines = frontmatter.split('\n'); const idx = lines.findIndex((l) => /^items\s*:\s*$/.test(l)); if (idx === -1) return []; const out = []; for (let i = idx + 1; i < lines.length; i++) { const line = lines[i]; if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue; const m = line.match(/^\s+-\s+["']?([a-z0-9-]+)["']?\s*$/i); if (!m) break; out.push(m[1]); } return out; } async function readFrontmatter(path) { const src = await readFile(path, 'utf8'); const m = src.match(/^---\n([\s\S]*?)\n---/); return m ? m[1] : ''; } async function findHeroPath(slug) { const dir = join(FIND_DIR, slug); if (!existsSync(dir)) return null; const entries = await readdir(dir); for (const ext of HERO_EXTS) { const file = entries.find((e) => e.toLowerCase() === `hero.${ext}`); if (file) return join(dir, file); } return null; } function layoutFor(count) { // Returns { canvasW, canvasH, cells: [{left, top, width, height}] } for the count. if (count === 1) { return { canvasW: FULL, canvasH: FULL, cells: [{ left: 0, top: 0, width: FULL, height: FULL }] }; } if (count === 2) { return { canvasW: FULL, canvasH: TILE, cells: [ { left: 0, top: 0, width: TILE, height: TILE }, { left: TILE + GAP, top: 0, width: TILE, height: TILE }, ], }; } // 3 or 4 -- always 1200x1200 with up to 4 cells in row-major order. const cells = [ { left: 0, top: 0, width: TILE, height: TILE }, { left: TILE + GAP, top: 0, width: TILE, height: TILE }, { left: 0, top: TILE + GAP, width: TILE, height: TILE }, { left: TILE + GAP, top: TILE + GAP, width: TILE, height: TILE }, ].slice(0, count); return { canvasW: FULL, canvasH: FULL, cells }; } async function tileFromHero(heroPath, w, h) { return sharp(heroPath) .resize(Math.round(w), Math.round(h), { fit: 'cover', position: 'attention' }) .toBuffer(); } async function buildMosaic(heroPaths, outPath) { const count = Math.min(heroPaths.length, 4); const { canvasW, canvasH, cells } = layoutFor(count); const composites = []; for (let i = 0; i < cells.length; i++) { const buf = await tileFromHero(heroPaths[i], cells[i].width, cells[i].height); composites.push({ input: buf, left: Math.round(cells[i].left), top: Math.round(cells[i].top) }); } await sharp({ create: { width: canvasW, height: canvasH, channels: 3, background: BG }, }) .composite(composites) .jpeg({ quality: 84, mozjpeg: true }) .toFile(outPath); } async function maxMtime(paths) { let max = 0; for (const p of paths) { if (!p) continue; try { const s = await stat(p); if (s.mtimeMs > max) max = s.mtimeMs; } catch {} } return max; } async function tryUnlink(path) { try { await unlink(path); return true; } catch { return false; } } async function main() { await mkdir(OUT_DIR, { recursive: true }); const bundleDirs = (await readdir(BUNDLES_DIR, { withFileTypes: true })) .filter((d) => d.isDirectory()) .map((d) => d.name); let built = 0; let skipped = 0; let removed = 0; let absent = 0; for (const bundleSlug of bundleDirs) { const mdxPath = join(BUNDLES_DIR, bundleSlug, 'index.mdx'); const mdPath = join(BUNDLES_DIR, bundleSlug, 'index.md'); const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null; if (!sourcePath) continue; const outPath = join(OUT_DIR, `${bundleSlug}.jpg`); const fm = await readFrontmatter(sourcePath); const items = parseItems(fm); if (items.length === 0) { if (await tryUnlink(outPath)) removed++; continue; } // Take items in declared order, keep only those with heroes, cap at 4. const allHeroes = await Promise.all(items.map(findHeroPath)); const heroPaths = allHeroes.filter((p) => p !== null).slice(0, 4); if (heroPaths.length === 0) { if (await tryUnlink(outPath)) removed++; else absent++; continue; } const inputMtime = await maxMtime([sourcePath, ...heroPaths]); const outMtime = existsSync(outPath) ? (await stat(outPath)).mtimeMs : 0; if (outMtime >= inputMtime && outMtime > 0) { skipped++; continue; } await buildMosaic(heroPaths, outPath); built++; console.log(` built ${bundleSlug}.jpg (${heroPaths.length}-up)`); } console.log( `bundle mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)` ); } main().catch((err) => { console.error(err); process.exit(1); });