From 99a889523a4dcce760a5cc8be44e50189c92d762 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 4 May 2026 20:34:29 -0400 Subject: [PATCH] site: list mosaic tiles for /finds/ + home grid Adds a build-time mosaic generator (scripts/build-list-mosaics.mjs, sharp) that composites a single tile image per superpost from its referenced finds. Wired via predev/prestart/prebuild hooks; output lives in src/generated/ (gitignored). The /finds/ index and home grid both render the mosaic when present and fall back to text-only tiles otherwise. --- .gitignore | 3 + package.json | 5 + scripts/build-list-mosaics.mjs | 178 +++++++++++++++++++++++++++++++++ src/lib/find-list-mosaic.ts | 16 +++ src/pages/finds/index.astro | 86 +++++++++++----- src/pages/index.astro | 155 ++++++++++++++++++++-------- 6 files changed, 373 insertions(+), 70 deletions(-) create mode 100644 scripts/build-list-mosaics.mjs create mode 100644 src/lib/find-list-mosaic.ts diff --git a/.gitignore b/.gitignore index fd76c8e..6cdb374 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ dist/ .output/ +# generated assets +src/generated/ + # generated types .astro/ diff --git a/package.json b/package.json index 227a26c..15fc400 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "version": "0.1.0", "private": true, "scripts": { + "build:mosaics": "node scripts/build-list-mosaics.mjs", + "predev": "npm run build:mosaics", + "prestart": "npm run build:mosaics", + "prebuild": "npm run build:mosaics", "dev": "astro dev", "start": "astro dev", "build": "astro check && astro build", @@ -16,6 +20,7 @@ "@astrojs/rss": "^4.0.12", "@astrojs/sitemap": "^3.6.0", "astro": "^5.1.1", + "sharp": "^0.34.0", "typescript": "^5.7.2" } } diff --git a/scripts/build-list-mosaics.mjs b/scripts/build-list-mosaics.mjs new file mode 100644 index 0000000..ac81299 --- /dev/null +++ b/scripts/build-list-mosaics.mjs @@ -0,0 +1,178 @@ +// Build-time generator: for each list in src/content/finds, compose a +// mosaic JPG from the item heroes available in that list. 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 FINDS_DIR = join(ROOT, 'src/content/finds'); +const FIND_DIR = join(ROOT, 'src/content/find'); +const OUT_DIR = join(ROOT, 'src/generated/list-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 listDirs = (await readdir(FINDS_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 listSlug of listDirs) { + const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx'); + const mdPath = join(FINDS_DIR, listSlug, 'index.md'); + const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null; + if (!sourcePath) continue; + + const outPath = join(OUT_DIR, `${listSlug}.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 ${listSlug}.jpg (${heroPaths.length}-up)`); + } + console.log( + `list mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)` + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/lib/find-list-mosaic.ts b/src/lib/find-list-mosaic.ts new file mode 100644 index 0000000..5a92af7 --- /dev/null +++ b/src/lib/find-list-mosaic.ts @@ -0,0 +1,16 @@ +import type { ImageMetadata } from 'astro'; + +const modules = import.meta.glob<{ default: ImageMetadata }>( + '../generated/list-mosaics/*.jpg', + { eager: true } +); + +const mosaics = new Map(); +for (const [path, mod] of Object.entries(modules)) { + const match = path.match(/list-mosaics\/([^/]+)\.jpg$/); + if (match) mosaics.set(match[1], mod.default); +} + +export function findListMosaic(slug: string): ImageMetadata | undefined { + return mosaics.get(slug); +} diff --git a/src/pages/finds/index.astro b/src/pages/finds/index.astro index 8cae4bc..5dc21f8 100644 --- a/src/pages/finds/index.astro +++ b/src/pages/finds/index.astro @@ -1,7 +1,9 @@ --- import BaseLayout from '../../layouts/BaseLayout.astro'; import Masonry from '../../components/Masonry.astro'; +import { Image } from 'astro:assets'; import { getCollection } from 'astro:content'; +import { findListMosaic } from '../../lib/find-list-mosaic'; const lists = (await getCollection('finds')).sort( (a, b) => b.data.date.valueOf() - a.data.date.valueOf() @@ -24,28 +26,44 @@ const fmt = new Intl.DateTimeFormat('en-US', { noindex > - {lists.map((l) => ( - - ))} + {lists.map((l) => { + const mosaic = findListMosaic(l.id); + return ( + + ); + })} diff --git a/src/pages/index.astro b/src/pages/index.astro index 36170ce..9b8acc0 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,34 +3,80 @@ import { getCollection } from 'astro:content'; import BaseLayout from '../layouts/BaseLayout.astro'; import Masonry from '../components/Masonry.astro'; import { heroes } from '../lib/hero'; +import { findListMosaic } from '../lib/find-list-mosaic'; import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo'; const HERO_SLUG = 'superkey'; const INITIAL_GRID = 12; const BATCH_SIZE = 7; -const reviews = (await getCollection('reviews')).sort( - (a, b) => b.data.date.valueOf() - a.data.date.valueOf() -); +type Tile = + | { + kind: 'review'; + href: string; + id: string; + name: string; + subtitle: string; + date: Date; + hero: ReturnType; + } + | { + kind: 'list'; + href: string; + id: string; + name: string; + subtitle: string | undefined; + date: Date; + itemCount: number; + mosaic: ReturnType; + }; -const tiles = reviews.map((review) => ({ +function getReviewHero(slug: string) { + return heroes[slug]; +} + +const [reviews, lists] = await Promise.all([ + getCollection('reviews'), + getCollection('finds'), +]); + +const reviewTiles: Tile[] = reviews.map((review) => ({ + kind: 'review' as const, href: `/reviews/${review.id}/`, id: review.id, name: review.data.name, subtitle: review.data.subtitle, - hero: heroes[review.id], + date: review.data.date, + hero: getReviewHero(review.id), })); -const heroIndex = tiles.findIndex((t) => t.href === `/reviews/${HERO_SLUG}/`); -const hero = heroIndex >= 0 ? tiles[heroIndex] : tiles[0]; -const rest = tiles.filter((_, i) => i !== (heroIndex >= 0 ? heroIndex : 0)); +const listTiles: Tile[] = lists.map((list) => ({ + kind: 'list' as const, + href: `/finds/${list.id}/`, + id: list.id, + name: list.data.title, + subtitle: list.data.blurb, + date: list.data.date, + itemCount: list.data.items.length, + mosaic: findListMosaic(list.id), +})); -const lastUpdated = reviews[0]?.data.date; +const tiles: Tile[] = [...reviewTiles, ...listTiles].sort( + (a, b) => b.date.valueOf() - a.date.valueOf() +); + +const heroIndex = tiles.findIndex( + (t) => t.kind === 'review' && t.href === `/reviews/${HERO_SLUG}/` +); +const hero = heroIndex >= 0 ? tiles[heroIndex] : reviewTiles[0]; +const rest = tiles.filter((t) => t !== hero); + +const lastUpdated = tiles[0]?.date; const ogImage = buildOgImage(hero?.id, 'reviews', `${hero?.name ?? 'Unique'} hero image`); const itemList = buildItemList( tiles.slice(0, INITIAL_GRID).map((t) => ({ url: t.href, name: t.name })), - 'Latest reviews' + 'Latest entries' ); --- @@ -42,7 +88,7 @@ const itemList = buildItemList( ogImage={ogImage} jsonLd={[WEBSITE, ORGANIZATION, itemList]} > - {hero && ( + {hero && hero.kind === 'review' && ( {hero.hero?.kind === 'image' && ( { - rest.map((tile) => ( - - )) + rest.map((tile) => { + const visual = + tile.kind === 'review' ? tile.hero : tile.mosaic ? { kind: 'image' as const, src: tile.mosaic } : undefined; + return ( + + ); + }) } @@ -198,6 +251,20 @@ const itemList = buildItemList( gap: 0.2rem; background: var(--card-fill); } + .eyebrow { + font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif; + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + font-weight: 600; + } + .tile.is-list .name { + font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif; + font-weight: 700; + font-size: 1.15rem; + letter-spacing: -0.01em; + } .name { font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif; font-weight: 700;