Files
CashuMints.space/web/scripts/og/build-og.mjs
T
michilisandCursor c74c7fc187 Ship LNURL mint pages, indexing UI, and reviews rewrite.
Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/
index client flows so the site surfaces the new mint type end to end.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 03:44:43 +02:00

224 lines
8.3 KiB
JavaScript

/**
* 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, lnurlModel } 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 })
: raw.type === 'lnurl'
? lnurlModel(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, lnurl, stats] = await Promise.all([
getJson('/api/mints?type=cashu'),
getJson('/api/mints?type=fedimint'),
getJson('/api/mints?type=lnurl'),
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' })),
...lnurl.map((m) => ({ listing: m, kind: 'lnurl' })),
];
// 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 })
: kind === 'lnurl'
? lnurlModel(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();
}