/** * 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, }; }