Expand ecash explorer capabilities
Add Fedimint discovery, dual SQLite/Postgres storage, richer review handling, and generated social imagery.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user