The generator still resolved heroes at the old flat src/content/find/<slug>/ path, so every mosaic was skipped as 'no heroes' and src/generated/ bundle-mosaics sat empty. Home-page bundle tiles have been mosaic-less since the May migration.
198 lines
6.4 KiB
JavaScript
198 lines
6.4 KiB
JavaScript
// 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', 'avif'];
|
|
|
|
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] : '';
|
|
}
|
|
|
|
// Finds live at find/<year>/<month>/<slug>/ (slugs globally unique).
|
|
// Index them once so hero lookup stays O(1) per slug.
|
|
let findDirIndex = null;
|
|
async function buildFindDirIndex() {
|
|
const index = new Map();
|
|
const years = (await readdir(FIND_DIR, { withFileTypes: true })).filter((d) => d.isDirectory());
|
|
for (const year of years) {
|
|
const yearDir = join(FIND_DIR, year.name);
|
|
const months = (await readdir(yearDir, { withFileTypes: true })).filter((d) => d.isDirectory());
|
|
for (const month of months) {
|
|
const monthDir = join(yearDir, month.name);
|
|
const slugs = (await readdir(monthDir, { withFileTypes: true })).filter((d) => d.isDirectory());
|
|
for (const slug of slugs) index.set(slug.name, join(monthDir, slug.name));
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
async function findHeroPath(slug) {
|
|
if (!findDirIndex) findDirIndex = await buildFindDirIndex();
|
|
const dir = findDirIndex.get(slug);
|
|
if (!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);
|
|
});
|