Expand ecash explorer capabilities

Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
michilis
2026-08-21 02:10:48 +02:00
parent aa1771ea20
commit 6f17b572b1
80 changed files with 7580 additions and 704 deletions
+4
View File
@@ -238,6 +238,10 @@ const DYNAMIC_PREFIXES = [
'login.connect.', // CONNECT_COPY, keyed by method
'time.ago.', // formatRelative, keyed by unit
'time.short.', // formatShortDuration, keyed by unit
'fedimint.module.', // localModuleName(module)
'fedimint.network.', // localNetworkName(network)
'feed.ecosystem.', // the /reviews filter, one option per ECOSYSTEMS entry
'reviews.dialog.bodyPlaceholder.', // one prompt per ecosystem, keyed by subject.type
];
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
+217
View File
@@ -0,0 +1,217 @@
/**
* 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();
}
+626
View File
@@ -0,0 +1,626 @@
/**
* The satori templates: card model in, 1200x630 SVG out (build-og.mjs rasterises it).
*
* Design tokens are the site's own (src/styles/global.css): same background, same
* card surface, same amber/violet/mint/red, Space Grotesk over Inter over JetBrains
* Mono. The card should read as a cropped screenshot of the site, not a poster.
*
* Everything rendered here comes off the model object and nothing else — the model is
* the manifest hash input, so a value the template invented for itself would be a way
* for an image to change without its hash noticing.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import satori from 'satori';
const here = path.dirname(fileURLToPath(import.meta.url));
/* ---- palette (global.css :root) ---- */
const BG = '#0E0D0B';
const CARD = '#161512';
const LINE = '#2A2720';
const LINE_SOFT = '#211F19';
const TEXT = '#EDE8DC';
const MUTED = '#9A927E';
const FAINT = '#6B6555';
const AMBER = '#F0A93B';
const VIOLET = '#A78BFA';
const MINT = '#5FD68F';
const RED = '#E06450';
const GROTESK = 'Space Grotesk';
const INTER = 'Inter';
const MONO = 'JetBrains Mono';
/**
* Shorthand: satori consumes plain React-shaped objects. `display: flex` is the
* default because satori requires it on any div with an element child.
*/
const h = (type, style = {}, ...children) => ({
type,
props: {
style: { display: 'flex', ...style },
children: children.flat().filter((c) => c !== null && c !== false),
},
});
/** The pixel moai from Moai.astro, as a data URI so satori can place it as an image. */
function moai(width) {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 11 13" shape-rendering="crispEdges">' +
'<rect x="2" y="0" width="7" height="1" fill="#C9BFA4"/>' +
'<rect x="1" y="1" width="9" height="2" fill="#C9BFA4"/>' +
'<rect x="1" y="3" width="2" height="7" fill="#A89B7C"/>' +
'<rect x="3" y="3" width="6" height="7" fill="#C9BFA4"/>' +
'<rect x="3" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="7" y="4" width="2" height="1" fill="#5C5340"/>' +
'<rect x="5" y="5" width="1" height="3" fill="#A89B7C"/>' +
'<rect x="2" y="10" width="8" height="2" fill="#A89B7C"/></svg>';
return {
type: 'img',
props: {
src: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`,
width,
height: Math.round((width / 11) * 13),
style: {},
},
};
}
const STAR_PATH =
'M12 2l2.9 6.22 6.82.63-5.14 4.52 1.51 6.68L12 16.55l-6.09 3.5 1.51-6.68L2.28 8.85l6.82-.63L12 2z';
/** Five stars, filled to Math.round(rating) — the same rounding starString() uses. */
function stars(rating, size, color) {
const filled = Math.round(rating);
return h(
'div',
{ display: 'flex', gap: `${Math.round(size * 0.18)}px` },
...[1, 2, 3, 4, 5].map((n) => ({
type: 'svg',
props: {
width: size,
height: size,
viewBox: '0 0 24 24',
style: { display: 'flex' },
children: [
{
type: 'path',
props: {
d: STAR_PATH,
fill: n <= filled ? color : 'none',
stroke: n <= filled ? 'none' : FAINT,
'stroke-width': 1.6,
},
},
],
},
})),
);
}
/** The status chip beside the name. Green only for a status a check actually verified. */
function statusChip(model) {
const style = (color, bg, border) => ({
display: 'flex',
alignItems: 'center',
gap: '9px',
padding: '8px 17px',
borderRadius: '999px',
border: `1.5px solid ${border}`,
backgroundColor: bg,
color,
fontFamily: INTER,
fontWeight: 600,
fontSize: '20px',
whiteSpace: 'nowrap',
});
const dot = (color) =>
h('div', { width: '9px', height: '9px', borderRadius: '999px', backgroundColor: color });
switch (model.status) {
case 'online':
return h('div', style(MINT, 'rgba(95,214,143,.08)', 'rgba(95,214,143,.35)'), dot(MINT), 'Online');
case 'offline': {
const label = model.offlineDays !== null ? `Offline ${model.offlineDays}d` : 'Offline';
return h('div', style(RED, 'rgba(224,100,80,.09)', 'rgba(224,100,80,.4)'), dot(RED), label);
}
case 'announced':
// The honest-status rule: an announcement is not a heartbeat, so never green.
return h('div', style(AMBER, 'rgba(240,169,59,.07)', 'rgba(240,169,59,.32)'), dot(AMBER), 'Announced');
case 'degraded':
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Unreachable');
default:
return h('div', style(MUTED, 'rgba(154,146,126,.07)', 'rgba(154,146,126,.35)'), dot(MUTED), 'Not checked yet');
}
}
/** The type badge that makes a federation card unmistakable at thumbnail size. */
function fedimintBadge() {
return h(
'div',
{
display: 'flex',
padding: '9px 14px',
borderRadius: '9px',
border: `1.5px solid rgba(167,139,250,.5)`,
color: VIOLET,
fontFamily: MONO,
fontWeight: 500,
fontSize: '18px',
letterSpacing: '3px',
},
'FEDIMINT',
);
}
/** The icon tile: cached icon when the indexer has one, the site's gradient tile when not. */
function iconTile(model, iconData) {
const size = 116;
const shared = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '28px',
border: '1.5px solid rgba(255,255,255,.14)',
flexShrink: 0,
};
if (iconData) {
return {
type: 'img',
props: { src: iconData, width: size, height: size, style: { ...shared, objectFit: 'cover' } },
};
}
const [c1, c2] = model.gradient;
return h(
'div',
{
...shared,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `linear-gradient(135deg, ${c1}, ${c2})`,
color: BG,
fontFamily: GROTESK,
fontWeight: 700,
fontSize: '52px',
},
model.initials,
);
}
/**
* Fit the name on one line by stepping the size down; extreme names get two lines,
* and satori's lineClamp guarantees nothing ever leaves the box regardless.
*/
function nameSize(name, maxWidth) {
for (const size of [58, 50, 42]) {
if (name.length * size * 0.58 <= maxWidth) return { size, clamp: 1 };
}
return { size: 38, clamp: 2 };
}
/** Middle-out truncation for a domain that will not fit the identity row. */
function fitMono(value, maxChars) {
if (value.length <= maxChars) return value;
const head = Math.ceil((maxChars - 1) * 0.62);
const tail = maxChars - 1 - head;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
function cellLabel(text, color = FAINT) {
return h(
'div',
{
fontFamily: INTER,
fontWeight: 600,
fontSize: '15px',
letterSpacing: '2px',
color,
marginBottom: '16px',
},
text,
);
}
/** Rating + stars + sentiment split, or the honest "no reviews yet". */
function ratingCell(model) {
const children = [cellLabel('COMMUNITY RATING')];
if (model.reviewCount === 0 || model.rating === null) {
children.push(
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '30px', color: MUTED, marginTop: '14px' }, 'No reviews yet'),
);
} else {
const starColor = model.rating < 3 ? RED : AMBER;
children.push(
h(
'div',
{ display: 'flex', alignItems: 'flex-end', gap: '18px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, model.rating.toFixed(1)),
h('div', { display: 'flex', marginBottom: '7px' }, stars(model.rating, 27, starColor)),
),
);
if (model.sentiment) {
const { pos, neg } = model.sentiment;
const neutral = Math.max(0, 100 - pos - neg);
children.push(
h(
'div',
{ display: 'flex', width: '250px', height: '8px', borderRadius: '4px', marginTop: '22px', overflow: 'hidden' },
pos > 0 && h('div', { width: `${pos}%`, backgroundColor: MINT }),
neutral > 0 && h('div', { width: `${neutral}%`, backgroundColor: LINE }),
neg > 0 && h('div', { width: `${neg}%`, backgroundColor: RED }),
),
);
}
}
return h('div', { display: 'flex', flexDirection: 'column', flex: 1.25, padding: '30px 36px' }, ...children);
}
function reviewsCell(model) {
return h(
'div',
{ display: 'flex', flexDirection: 'column', flex: 0.9, padding: '30px 36px', borderLeft: `1px solid ${LINE_SOFT}` },
cellLabel('REVIEWS'),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '58px', lineHeight: 1, color: TEXT }, String(model.reviewCount)),
// Absolute month/year on purpose: "3d ago" goes stale inside a static PNG.
model.lastReviewMonth !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, `Last review ${model.lastReviewMonth}`),
);
}
/** Third cell, Cashu: published limits in mono, or the warning that replaces them. */
function thirdCellCashu(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
const color = model.warning.severity === 'critical' ? RED : AMBER;
return h(
'div',
{ ...base, backgroundColor: model.warning.severity === 'critical' ? 'rgba(224,100,80,.06)' : 'rgba(240,169,59,.05)' },
cellLabel('WARNING', color),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '38px', lineHeight: 1.05, color }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const lines = [];
if (model.limits) {
const { min, max, unit } = model.limits;
if (min !== null) lines.push(['min', `${compact(min)} ${unit}`]);
if (max !== null) lines.push(['max', `${compact(max)} ${unit}`]);
}
return h(
'div',
base,
cellLabel('LIMITS'),
lines.length === 0
? h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '24px', color: MUTED, marginTop: '8px' }, 'Not published')
: h(
'div',
{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '4px' },
...lines.map(([k, v]) =>
h(
'div',
{ display: 'flex', alignItems: 'baseline', gap: '14px' },
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '20px', color: FAINT }, k),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '27px', color: TEXT }, v),
),
),
),
);
}
function compact(n) {
if (n >= 1_000_000) return trimNum(n / 1_000_000) + 'M';
if (n >= 1_000) return trimNum(n / 1_000) + 'k';
return String(n);
}
function trimNum(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** Third cell, Fedimint: modules and network — never limits, never a software version. */
function thirdCellFedimint(model) {
const base = {
display: 'flex',
flexDirection: 'column',
flex: 1.1,
padding: '30px 36px',
borderLeft: `1px solid ${LINE_SOFT}`,
};
if (model.warning) {
return h(
'div',
{ ...base, backgroundColor: 'rgba(224,100,80,.06)' },
cellLabel('WARNING', RED),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '36px', lineHeight: 1.05, color: RED }, model.warning.label),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '19px', color: MUTED, marginTop: '14px' }, model.warning.sub),
);
}
const list = model.modules.length > 0 ? fitMono(model.modules.join(', '), 40) : 'Not announced';
const mainnet = model.network === 'mainnet';
return h(
'div',
base,
cellLabel('MODULES'),
h('div', { fontFamily: MONO, fontWeight: 500, fontSize: '24px', lineHeight: 1.4, color: model.modules.length > 0 ? TEXT : MUTED, maxWidth: '300px' }, list),
model.network &&
h(
'div',
{ display: 'flex', marginTop: '16px' },
h(
'div',
{
display: 'flex',
padding: '6px 13px',
borderRadius: '8px',
border: `1.5px solid ${mainnet ? 'rgba(95,214,143,.4)' : 'rgba(240,169,59,.4)'}`,
color: mainnet ? MINT : AMBER,
fontFamily: MONO,
fontWeight: 500,
fontSize: '17px',
letterSpacing: '1px',
},
model.network,
),
),
);
}
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
function glow(model) {
const danger =
model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
const color = danger
? 'rgba(224,100,80,.13)'
: model.type === 'fedimint'
? 'rgba(167,139,250,.11)'
: 'rgba(240,169,59,.10)';
return h('div', {
position: 'absolute',
top: '-260px',
right: '-180px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: `radial-gradient(circle at center, ${color} 0%, rgba(14,13,11,0) 62%)`,
});
}
/** Brand row, shared by every variant. */
function brandRow(tagline) {
return h(
'div',
{ display: 'flex', alignItems: 'center', gap: '16px' },
moai(30),
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '29px', color: TEXT, letterSpacing: '-0.5px' }, 'Cashumints.space'),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '23px', color: FAINT, marginTop: '2px' }, `· ${tagline}`),
);
}
/** One mint or federation card. `iconData` is a data: URI or null. */
export function mintCard(model, iconData) {
const isFed = model.type === 'fedimint';
const monoLine = isFed ? model.shortId : fitMono(model.domain, 46);
// Room beside the icon: canvas minus padding, icon, gaps and the chips.
const chipRoom = 240 + (isFed ? 180 : 0);
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
backgroundColor: BG,
padding: '56px 64px',
position: 'relative',
fontFamily: INTER,
},
glow(model),
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
iconTile(model, iconData),
h(
'div',
{ display: 'flex', flexDirection: 'column', flexGrow: 1, minWidth: 0 },
h(
'div',
{ display: 'flex', alignItems: 'center', gap: '20px', flexWrap: 'wrap' },
h(
'div',
{
fontFamily: GROTESK,
fontWeight: 700,
fontSize: `${size}px`,
lineHeight: 1.08,
color: TEXT,
letterSpacing: '-1px',
maxWidth: '760px',
lineClamp: clamp,
},
model.name,
),
statusChip(model),
isFed && fedimintBadge(),
),
h('div', { fontFamily: MONO, fontWeight: 400, fontSize: '25px', color: MUTED, marginTop: '14px' }, monoLine),
// The Fedimint soft warning is a muted line, deliberately not a red cell.
isFed &&
model.announcedNote !== null &&
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '20px', color: 'rgba(240,169,59,.75)', marginTop: '10px' }, model.announcedNote),
),
),
h(
'div',
{
display: 'flex',
backgroundColor: CARD,
border: `1px solid ${LINE}`,
borderRadius: '20px',
minHeight: '196px',
},
ratingCell(model),
reviewsCell(model),
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
),
);
}
/** The default card: brand, tagline, and the network's numbers. Used site-wide. */
export function defaultCard(model) {
const stat = (n, label) =>
h(
'div',
{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' },
h('div', { fontFamily: GROTESK, fontWeight: 700, fontSize: '54px', lineHeight: 1, color: TEXT }, String(n)),
h('div', { fontFamily: INTER, fontWeight: 500, fontSize: '21px', color: MUTED }, label),
);
return h(
'div',
{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: BG,
position: 'relative',
fontFamily: INTER,
},
h('div', {
position: 'absolute',
top: '-300px',
left: '220px',
width: '760px',
height: '760px',
borderRadius: '999px',
backgroundImage: 'radial-gradient(circle at center, rgba(240,169,59,.11) 0%, rgba(14,13,11,0) 62%)',
}),
moai(64),
h(
'div',
{ fontFamily: GROTESK, fontWeight: 700, fontSize: '66px', color: TEXT, letterSpacing: '-1.5px', marginTop: '30px' },
'Cashumints.space',
),
h(
'div',
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
'Cashu mint and Fedimint reviews, signed on Nostr',
),
h(
'div',
{ display: 'flex', gap: '86px', marginTop: '64px' },
stat(model.mints, 'Cashu mints'),
stat(model.federations, 'federations'),
stat(model.reviews, 'signed reviews'),
),
);
}
/* ---------- fonts ---------- */
let fontsPromise = null;
/**
* The six static faces satori embeds as glyph paths. Static TTF instances rather than
* the site's variable woff2 files, because satori reads neither woff2 nor variable
* axes; the weights are the ones the mockup uses. A missing file is a failed build:
* satori silently falling back to another face is exactly the "serif in the PNG"
* failure the acceptance criteria forbid.
*/
export function loadFonts() {
fontsPromise ??= Promise.all(
[
[GROTESK, 600, 'space-grotesk-600.ttf'],
[GROTESK, 700, 'space-grotesk-700.ttf'],
[INTER, 500, 'inter-500.ttf'],
[INTER, 600, 'inter-600.ttf'],
[MONO, 400, 'jetbrains-mono-400.ttf'],
[MONO, 500, 'jetbrains-mono-500.ttf'],
].map(async ([name, weight, file]) => ({
name,
weight,
style: 'normal',
data: await readFile(path.join(here, 'fonts', file)),
})),
);
return fontsPromise;
}
/* ---------- glyphs outside Latin ---------- */
const assetCache = new Map();
const cacheDir = path.join(here, 'fonts', 'cache');
/**
* Satori calls this for graphemes the six faces cannot draw (one indexed mint has a
* CJK name today). The Noto subset for exactly those characters is fetched once from
* Google Fonts and cached on disk, so repeat builds are offline and deterministic.
* If the fetch fails the glyphs are skipped — a name missing three characters beats
* a failed build, and the failure is logged by the caller.
*/
async function loadAdditionalAsset(code, segment) {
if (code === 'emoji') return []; // no emoji font is embedded; skip rather than tofu
const family = { 'ja-JP': 'Noto Sans JP', 'ko-KR': 'Noto Sans KR', 'zh-CN': 'Noto Sans SC', 'zh-TW': 'Noto Sans TC', 'th-TH': 'Noto Sans Thai' }[code] ?? 'Noto Sans SC';
const key = createHash('sha1').update(`${family}:${segment}`).digest('hex');
if (assetCache.has(key)) return assetCache.get(key);
const file = path.join(cacheDir, `${key}.ttf`);
let data = await readFile(file).catch(() => null);
if (!data) {
try {
const css = await fetch(
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}&text=${encodeURIComponent(segment)}`,
{ headers: { 'User-Agent': 'curl/7.68' } },
).then((r) => r.text());
// The subsetted file comes back as `url(https://fonts.gstatic.com/l/font?kit=…)
// format('truetype')` — a kit URL, not a .ttf path.
const url = /url\((https:[^)]+)\)\s*format\('truetype'\)/.exec(css)?.[1];
if (!url) throw new Error('no truetype url in css response');
data = Buffer.from(await fetch(url).then((r) => r.arrayBuffer()));
await mkdir(cacheDir, { recursive: true });
await writeFile(file, data);
} catch (error) {
console.warn(`og: no glyphs for "${segment}" (${error.message}); rendering without`);
assetCache.set(key, []);
return [];
}
}
const fonts = [{ name: family, data, weight: 400, style: 'normal' }];
assetCache.set(key, fonts);
return fonts;
}
/** Render one card model to SVG. */
export async function renderSvg(model, iconData) {
const element = model.type === 'default' ? defaultCard(model) : mintCard(model, iconData);
return satori(element, {
width: 1200,
height: 630,
fonts: await loadFonts(),
loadAdditionalAsset,
});
}
+112
View File
@@ -0,0 +1,112 @@
/**
* The edge cases the template must survive, as synthetic API payloads.
*
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
* that bypassed the model would test a card no build can produce. `NOW` is pinned so
* the rendered PNGs under og-fixtures/ are byte-stable and can live in the repo as
* eyeball snapshots.
*/
/** 1 Aug 2026 00:00 UTC. Day counts and month stamps in fixtures derive from this. */
export const NOW = 1_785_542_400;
const dist = (five = 0, four = 0, three = 0, two = 0, one = 0) => ({
5: five, 4: four, 3: three, 2: two, 1: one,
});
const base = {
type: 'cashu',
status: 'online',
last_online: NOW - 300,
last_probe: NOW - 300,
first_seen: NOW - 400 * 86400,
review_count: 26,
rating_avg: 4.6,
rating_distribution: dist(18, 5, 2, 0, 1),
last_review_at: NOW - 12 * 86400,
info: {
nuts: {
4: { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 100, max_amount: 500000 }], disabled: false },
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
},
},
};
export const FIXTURES = [
{
// A name at the 30-character mark: must step down, never clip.
...base,
host: 'fixture-long-name',
url: 'https://mint.veryserious.example',
name: 'The Extremely Serious Mint Co.',
icon: null,
},
{
// No cached icon: the deterministic initial-on-gradient tile.
...base,
host: 'fixture-no-icon',
url: 'https://mint.plain.example',
name: 'Plain Mint',
icon: null,
},
{
// Zero reviews: "No reviews yet" in the rating cell, no stars, no sentiment bar.
...base,
host: 'fixture-zero-reviews',
url: 'https://mint.unreviewed.example',
name: 'Fresh Mint',
icon: null,
review_count: 0,
rating_avg: null,
rating_distribution: dist(),
last_review_at: null,
},
{
// Offline 12 days: red glow, "Offline 12d" chip, warning cell instead of limits.
...base,
host: 'fixture-offline',
url: 'https://mint.gone.example',
name: 'Vanished Mint',
icon: null,
status: 'offline',
last_online: NOW - 12 * 86400,
rating_avg: 2.4,
rating_distribution: dist(1, 1, 2, 4, 6),
review_count: 14,
},
{
// Melt only but ONLINE: green chip stays, warning cell appears anyway.
...base,
host: 'fixture-melt-only',
url: 'https://mint.windingdown.example',
name: 'Winding Down Mint',
icon: null,
info: {
nuts: {
4: { methods: [], disabled: true },
5: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
},
},
},
{
// A federation announced 3 months ago that nothing ever confirmed.
type: 'fedimint',
host: 'fixture-fed-announced',
url: 'fed11qfixture',
name: 'Quiet Corner Federation',
icon: null,
status: 'announced',
last_online: null,
last_probe: null,
first_seen: NOW - 95 * 86400,
announced_at: NOW - 92 * 86400,
review_count: 3,
rating_avg: 4.3,
rating_distribution: dist(1, 2),
last_review_at: NOW - 40 * 86400,
federation_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90',
modules: ['ln', 'mint', 'wallet', 'meta'],
network: 'mainnet',
status_source: null,
},
];
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+240
View File
@@ -0,0 +1,240 @@
/**
* The card model: everything one social image renders, and nothing else.
*
* This object is the hash input for the manifest, so it must be derived purely from
* API data plus the day the build runs on. Two rules follow from that:
*
* - No relative times finer than a day. "3 minutes ago" in a static PNG is a lie by
* the time anyone sees it; day counts and month/year stamps go stale slowly enough
* to survive until the next scheduled rebuild regenerates them.
* - Everything the template touches is in the model, and the template touches
* nothing but the model. A design change is a TEMPLATE_VERSION bump, which busts
* every hash at once; a data change busts exactly the mints it happened to.
*
* The warning logic is imported from shared/, not restated: the image must never
* disagree with the page it decorates about whether a mint is frozen or gone.
*/
import { getMintWarnings, parseLimits, readCapabilities } from '@cashumints/shared';
/** Bump when the template's layout or copy changes, to regenerate every image. */
export const TEMPLATE_VERSION = 1;
const DAY = 86_400;
/** English month/year ("Mar 2026") — the images are one English render for all locales. */
export function monthYear(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/** "3 Aug 2026", matching f.date() on the pages. */
export function shortDate(unix) {
return new Date(unix * 1000).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
/* ---- mirrors of web/src/lib/format.ts, which is TypeScript this script cannot load.
Same algorithms, asserted by eyeball against the site's own fallback tiles. ---- */
export function displayDomain(url) {
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
}
/** The site's deterministic gradient, resolved to hex pairs satori understands. */
export function iconGradient(seed) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
const hue = hash % 360;
const hue2 = (hue + 40) % 360;
return [hsl(hue, 45, 55), hsl(hue2, 45, 45)];
}
function hsl(h, s, l) {
s /= 100;
l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const hex = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
return `#${hex(f(0))}${hex(f(8))}${hex(f(4))}`;
}
export function initials(name) {
const cleaned = name.replace(/^(https?:\/\/)?(mint\.|www\.)?/, '');
const first = cleaned.trim()[0] ?? '?';
if (/\d/.test(first)) return cleaned.slice(0, 2);
return first.toUpperCase();
}
export function truncateMiddle(value, head = 10, tail = 8) {
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}…${value.slice(-tail)}`;
}
/** 4–5 star share vs 1–2 star share, as the site's sentiment bar splits them. */
export function sentiment(dist) {
const positive = (dist['5'] ?? 0) + (dist['4'] ?? 0);
const negative = (dist['2'] ?? 0) + (dist['1'] ?? 0);
const total = positive + negative + (dist['3'] ?? 0);
if (total === 0) return null;
return {
pos: Math.round((positive / total) * 100),
neg: Math.round((negative / total) * 100),
};
}
/** "500k", "1M", "100" — sat amounts at stats-bar size. */
export function compactSats(n) {
if (n >= 1_000_000) return trim(n / 1_000_000) + 'M';
if (n >= 1_000) return trim(n / 1_000) + 'k';
return String(n);
}
function trim(n) {
const s = n.toFixed(1);
return s.endsWith('.0') ? s.slice(0, -2) : s;
}
/** The warning cell's two lines, from the same warning object the page banner uses. */
function warningCell(warning, mint, now) {
if (!warning) return null;
const since = mint.last_online;
const label = {
'gone': 'Likely gone',
'melt-disabled': 'No withdrawals',
'frozen': 'Frozen',
'melt-only': 'Melt only',
'offline-long': 'Offline',
'offline': 'Offline',
'fedimint-offline': 'Reported offline',
}[warning.kind];
if (!label) return null;
let sub;
switch (warning.kind) {
case 'gone':
case 'offline-long':
case 'offline':
case 'fedimint-offline':
sub = since !== null ? `Since ${shortDate(since)}` : 'Never reached';
break;
case 'melt-only':
sub = 'Deposits disabled';
break;
case 'melt-disabled':
sub = 'Deposits still open';
break;
case 'frozen':
sub = 'Nothing moves in or out';
break;
}
// The mint is also offline while a capability warning outranks that fact: the cell
// still has to say so, the way the page banner folds it into its last sentence.
if (['melt-only', 'melt-disabled', 'frozen'].includes(warning.kind) && mint.status === 'offline' && since !== null) {
sub = `Offline since ${shortDate(since)}`;
}
return { kind: warning.kind, severity: warning.severity, label, sub };
}
/**
* One Cashu mint's card model, from its detail payload.
*
* `iconHash` arrives from the caller (a digest of the cached icon bytes), so a mint
* that changes its logo behind the same URL regenerates. `now` is passed in rather
* than read here so fixtures render the same PNG forever.
*/
export function cashuModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const domain = displayDomain(mint.url);
const name = (mint.name ?? '').trim() || domain.split('/')[0] || domain;
const warning = getMintWarnings(mint, { now })[0] ?? null;
const offline = mint.status === 'offline';
const offlineDays =
offline && mint.last_online !== null
? Math.max(0, Math.floor((now - mint.last_online) / DAY))
: null;
const limits = parseLimits(mint.info?.nuts, 4);
const caps = readCapabilities(mint.info?.nuts);
return {
v: TEMPLATE_VERSION,
type: 'cashu',
slug: mint.host,
name,
domain,
status: mint.status,
offlineDays,
rating: mint.rating_avg,
reviewCount: mint.review_count,
sentiment: sentiment(mint.rating_distribution ?? {}),
lastReviewMonth: mint.last_review_at ? monthYear(mint.last_review_at) : null,
limits:
limits && (limits.min !== null || limits.max !== null) && !caps.mintDisabled
? { min: limits.min, max: limits.max, unit: limits.unit }
: null,
warning: warningCell(warning, mint, now),
iconHash,
gradient: iconHash ? null : iconGradient(domain),
initials: iconHash ? null : initials(name),
};
}
/** One federation's card model. Same skeleton, only honest fields. */
export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
const federationId = fed.federation_id ?? '';
const name = (fed.name ?? '').trim() || 'Unnamed federation';
const warning = getMintWarnings(fed, { now })[0] ?? null;
const announced = fed.status === 'announced';
const offlineDays =
fed.status === 'offline' && fed.last_online !== null
? Math.max(0, Math.floor((now - fed.last_online) / DAY))
: null;
return {
v: TEMPLATE_VERSION,
type: 'fedimint',
slug: fed.host,
name,
/** `fed11…` prefix plus the id tail: what the identity row shows where a domain sits. */
shortId: federationId ? `fed11…${federationId.slice(-6)}` : fed.host,
status: fed.status,
offlineDays,
rating: fed.rating_avg,
reviewCount: fed.review_count,
sentiment: sentiment(fed.rating_distribution ?? {}),
lastReviewMonth: fed.last_review_at ? monthYear(fed.last_review_at) : null,
modules: fed.modules ?? [],
network: fed.network,
/** The soft warning: never a red cell, just this muted line under the identity row. */
announcedNote:
announced && warning?.kind === 'never-confirmed' && fed.announced_at
? `Announced ${shortDate(fed.announced_at)}, never confirmed`
: null,
warning: fed.status === 'offline' ? warningCell(warning, fed, now) : null,
iconHash,
gradient: iconHash ? null : iconGradient(federationId || fed.host),
initials: iconHash ? null : initials(name),
};
}
/** The default card: brand, tagline, and the network's own numbers. */
export function defaultModel(stats) {
return {
v: TEMPLATE_VERSION,
type: 'default',
slug: 'default',
mints: stats.cashu_total,
federations: stats.fedimint_total,
reviews: stats.reviews_total,
};
}