/** * 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 = '' + '' + '' + '' + '' + '' + '' + '' + ''; 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, }); }