/** * Social image build step. `pnpm og`, and the first half of `pnpm build`. * * Renders one 1200x630 PNG per mint and federation into `public/og/`, plus the * default card every non-mint page shares, from the same API the pages are built * from. satori turns the card template into SVG (text becomes glyph paths, so the * rasteriser needs no fonts), resvg turns the SVG into a PNG. * * Filenames carry a content hash — `mint.example.com.a1b2c3d4e5.png` — and the pages * read the mapping from `src/generated/og-manifest.json`, which this script writes. * The hash is over the card model (see model.mjs), so: * * - a rebuild with unchanged data renders nothing (the manifest says every hash is * already on disk); * - one mint's rating moving regenerates exactly that mint's image, at a new URL, * which is what actually busts scraper caches — Telegram and friends cache an * og:image URL for days and ignore Cache-Control on it; * - the previous file is deleted, so `public/og/` never accumulates history. * * `--fixtures` renders the six edge-case cards from fixtures.mjs into `og-fixtures/` * at a pinned timestamp instead: byte-stable snapshots for the repo, to eyeball after * template changes. */ import '../load-env.mjs'; import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Resvg } from '@resvg/resvg-js'; import { renderSvg } from './card.mjs'; import { cashuModel, defaultModel, fedimintModel } from './model.mjs'; import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs'; const here = path.dirname(fileURLToPath(import.meta.url)); const webRoot = path.resolve(here, '../..'); const outDir = path.join(webRoot, 'public/og'); const manifestFile = path.join(webRoot, 'src/generated/og-manifest.json'); const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8787'; async function getJson(pathname) { const res = await fetch(`${API_URL}${pathname}`, { headers: { Accept: 'application/json' } }); if (!res.ok) throw new Error(`${pathname} responded ${res.status}`); return res.json(); } /** Ten hex characters of the model hash: the cache-busting part of the filename. */ const hashModel = (model) => createHash('sha256').update(JSON.stringify(model)).digest('hex').slice(0, 10); /** * The cached icon as a data URI resvg can decode, plus a digest for the model hash. * * Only PNG and JPEG go in — resvg decodes nothing else, and an image tag it cannot * decode becomes a blank square, which is worse than the gradient tile. The one webp * icon in the index today falls back to the tile; logged so it is a known trade, not * a silent one. */ async function fetchIcon(icon, host) { if (!icon) return null; try { const res = await fetch(`${API_URL}${icon}`); if (!res.ok) throw new Error(`responded ${res.status}`); const bytes = Buffer.from(await res.arrayBuffer()); const isPng = bytes.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])); const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8; if (!isPng && !isJpeg) { console.warn(`og: ${host}: icon is not PNG/JPEG, using the gradient tile`); return null; } return { data: `data:image/${isPng ? 'png' : 'jpeg'};base64,${bytes.toString('base64')}`, hash: createHash('sha1').update(bytes).digest('hex').slice(0, 10), }; } catch (error) { console.warn(`og: ${host}: icon fetch failed (${error.message}), using the gradient tile`); return null; } } async function renderPng(model, iconData) { const svg = await renderSvg(model, iconData); const resvg = new Resvg(svg, { font: { loadSystemFonts: false } }); return resvg.render().asPng(); } /* ---------- fixtures mode ---------- */ async function buildFixtures() { const dir = path.join(webRoot, 'og-fixtures'); await mkdir(dir, { recursive: true }); for (const raw of FIXTURES) { const model = raw.type === 'fedimint' ? fedimintModel(raw, { now: FIXTURE_NOW }) : cashuModel(raw, { now: FIXTURE_NOW }); const png = await renderPng(model, null); const file = path.join(dir, `${raw.host}.png`); await writeFile(file, png); console.log(`og: ${path.relative(webRoot, file)} (${(png.length / 1024).toFixed(0)}KB)`); } } /* ---------- the real build ---------- */ async function build() { const started = Date.now(); await mkdir(outDir, { recursive: true }); await mkdir(path.dirname(manifestFile), { recursive: true }); const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}')); const previousImages = previous.images ?? {}; const [cashu, fedimint, stats] = await Promise.all([ getJson('/api/mints?type=cashu'), getJson('/api/mints?type=fedimint'), getJson('/api/stats'), ]); /* * One clock for the whole run, so two rebuilds inside the same day produce the same * day counts and therefore the same hashes. Day granularity is deliberate: an * offline mint's "Offline 12d" chip regenerates once a day, not once a build. */ const now = Math.floor(Date.now() / 1000 / 86400) * 86400; const onDisk = new Set(await readdir(outDir).catch(() => [])); const images = {}; let rendered = 0; let skipped = 0; const listings = [ ...cashu.map((m) => ({ listing: m, kind: 'cashu' })), ...fedimint.map((m) => ({ listing: m, kind: 'fedimint' })), ]; // A handful at a time: satori renders in milliseconds, the icon fetches are local, // and bounded concurrency keeps 60 base64 icons from piling up in memory at once. const queue = [...listings]; const workers = Array.from({ length: 8 }, async () => { for (;;) { const next = queue.shift(); if (!next) return; const { listing, kind } = next; let detail; try { detail = await getJson(`/api/mints/${encodeURIComponent(listing.host)}`); } catch (error) { console.warn(`og: ${listing.host}: detail fetch failed (${error.message}), skipping`); continue; } const icon = await fetchIcon(detail.icon, detail.host); const model = kind === 'fedimint' ? fedimintModel(detail, { iconHash: icon?.hash ?? null, now }) : cashuModel(detail, { iconHash: icon?.hash ?? null, now }); const hash = hashModel(model); const file = `${model.slug}.${hash}.png`; images[model.slug] = { file, hash }; if (previousImages[model.slug]?.hash === hash && onDisk.has(file)) { skipped++; continue; } const png = await renderPng(model, icon?.data ?? null); if (png.length > 300 * 1024) { console.warn(`og: ${file} is ${(png.length / 1024).toFixed(0)}KB, over the 300KB budget`); } await writeFile(path.join(outDir, file), png); rendered++; } }); await Promise.all(workers); /* * The default card. Stable filename on purpose — it is referenced by every non-mint * page and by mints that failed above, and it changes with the site's own stats, * which is what `Cache-Control: max-age=3600` is for (see README). */ const fallback = defaultModel(stats); const fallbackHash = hashModel(fallback); if (previous.default?.hash !== fallbackHash || !onDisk.has('default.png')) { await writeFile(path.join(outDir, 'default.png'), await renderPng(fallback, null)); rendered++; } else { skipped++; } /* Sweep files no current listing produced, so removed mints do not linger. */ const keep = new Set([...Object.values(images).map((i) => i.file), 'default.png']); for (const file of await readdir(outDir)) { if (!keep.has(file)) await rm(path.join(outDir, file)); } await writeFile( manifestFile, JSON.stringify({ default: { file: 'default.png', hash: fallbackHash }, images }, null, 2) + '\n', ); const seconds = ((Date.now() - started) / 1000).toFixed(1); console.log( `og: ${rendered} rendered, ${skipped} unchanged, ${Object.keys(images).length} mints+federations, ${seconds}s`, ); } if (process.argv.includes('--fixtures')) { await buildFixtures(); } else { await build(); }