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>
This commit is contained in:
@@ -28,7 +28,7 @@ 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 { cashuModel, defaultModel, fedimintModel, lnurlModel } from './model.mjs';
|
||||
import { FIXTURES, NOW as FIXTURE_NOW } from './fixtures.mjs';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -96,7 +96,9 @@ async function buildFixtures() {
|
||||
const model =
|
||||
raw.type === 'fedimint'
|
||||
? fedimintModel(raw, { now: FIXTURE_NOW })
|
||||
: cashuModel(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);
|
||||
@@ -114,9 +116,10 @@ async function build() {
|
||||
const previous = JSON.parse(await readFile(manifestFile, 'utf8').catch(() => '{}'));
|
||||
const previousImages = previous.images ?? {};
|
||||
|
||||
const [cashu, fedimint, stats] = await Promise.all([
|
||||
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'),
|
||||
]);
|
||||
|
||||
@@ -135,6 +138,7 @@ async function build() {
|
||||
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,
|
||||
@@ -158,7 +162,9 @@ async function build() {
|
||||
const model =
|
||||
kind === 'fedimint'
|
||||
? fedimintModel(detail, { iconHash: icon?.hash ?? null, now })
|
||||
: cashuModel(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`;
|
||||
|
||||
+101
-15
@@ -29,6 +29,8 @@ const AMBER = '#F0A93B';
|
||||
const VIOLET = '#A78BFA';
|
||||
const MINT = '#5FD68F';
|
||||
const RED = '#E06450';
|
||||
/** The LNURL ecosystem label. The only cool blue here — see DESIGN.md. */
|
||||
const LNURL = '#4EA8DE';
|
||||
|
||||
const GROTESK = 'Space Grotesk';
|
||||
const INTER = 'Inter';
|
||||
@@ -137,22 +139,29 @@ function statusChip(model) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The type badge that makes a federation card unmistakable at thumbnail size. */
|
||||
function fedimintBadge() {
|
||||
/**
|
||||
* The type badge that makes a non-Cashu card unmistakable at thumbnail size.
|
||||
*
|
||||
* Three things separate the three ecosystems at 300px wide, and the badge is only one
|
||||
* of them: the word itself, the badge's colour, and the glow behind the whole card all
|
||||
* change together. Cashu gets no badge at all, which is its own signal — it is the
|
||||
* default and the majority of the index.
|
||||
*/
|
||||
function typeBadge(label, color, borderColor) {
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
display: 'flex',
|
||||
padding: '9px 14px',
|
||||
borderRadius: '9px',
|
||||
border: `1.5px solid rgba(167,139,250,.5)`,
|
||||
color: VIOLET,
|
||||
border: `1.5px solid ${borderColor}`,
|
||||
color,
|
||||
fontFamily: MONO,
|
||||
fontWeight: 500,
|
||||
fontSize: '18px',
|
||||
letterSpacing: '3px',
|
||||
},
|
||||
'FEDIMINT',
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,15 +389,86 @@ function thirdCellFedimint(model) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The background glow: amber for a healthy card, red-tinted for trouble, violet for Fedimint. */
|
||||
/**
|
||||
* Third cell, LNURL: the withdraw range — never NUT limits, never a module list.
|
||||
*
|
||||
* This is the number that answers an LNURL reader's actual question: how much can one
|
||||
* of this mint's notes be worth? It is shown in sats, converted from millisatoshi in
|
||||
* the model, and it is a *range* rather than a pair of independent bounds, which is
|
||||
* why the two rows read "min"/"max" the way the Cashu limits cell does.
|
||||
*
|
||||
* The lightning address takes the space the Fedimint cell gives its network badge: it
|
||||
* is the one string on the whole card someone might copy out of a screenshot.
|
||||
*/
|
||||
function thirdCellLnurl(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: '34px', 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.withdraw) {
|
||||
if (model.withdraw.min !== null) lines.push(['min', `${compact(model.withdraw.min)} sat`]);
|
||||
if (model.withdraw.max !== null) lines.push(['max', `${compact(model.withdraw.max)} sat`]);
|
||||
}
|
||||
|
||||
return h(
|
||||
'div',
|
||||
base,
|
||||
cellLabel('WITHDRAW'),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
model.address &&
|
||||
h(
|
||||
'div',
|
||||
{ fontFamily: MONO, fontWeight: 400, fontSize: '18px', color: FAINT, marginTop: '16px', maxWidth: '300px' },
|
||||
fitMono(model.address, 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The background glow: amber healthy, red for trouble, violet Fedimint, blue LNURL. */
|
||||
function glow(model) {
|
||||
const danger =
|
||||
model.status === 'offline' || (model.type === 'cashu' && model.warning && model.warning.severity === 'critical');
|
||||
model.status === 'offline' ||
|
||||
(model.type !== 'fedimint' && 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)';
|
||||
: model.type === 'lnurl'
|
||||
// Deliberately the strongest of the three. Amber and violet only have to
|
||||
// separate a card from the site's own background; this one has to separate an
|
||||
// LNURL card from a Fedimint card at 300px, where the badge text is a smudge
|
||||
// and the tint is the first thing a reader actually resolves.
|
||||
? 'rgba(78,168,222,.20)'
|
||||
: 'rgba(240,169,59,.10)';
|
||||
return h('div', {
|
||||
position: 'absolute',
|
||||
top: '-260px',
|
||||
@@ -414,10 +494,12 @@ function brandRow(tagline) {
|
||||
/** One mint or federation card. `iconData` is a data: URI or null. */
|
||||
export function mintCard(model, iconData) {
|
||||
const isFed = model.type === 'fedimint';
|
||||
const isLnurl = model.type === 'lnurl';
|
||||
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);
|
||||
// Room beside the icon: canvas minus padding, icon, gaps and the chips. A badge is
|
||||
// ~180px for FEDIMINT and ~120px for the shorter LNURL.
|
||||
const chipRoom = 240 + (isFed ? 180 : isLnurl ? 120 : 0);
|
||||
const { size, clamp } = nameSize(model.name, 1200 - 128 - 148 - chipRoom);
|
||||
|
||||
return h(
|
||||
@@ -433,7 +515,7 @@ export function mintCard(model, iconData) {
|
||||
fontFamily: INTER,
|
||||
},
|
||||
glow(model),
|
||||
brandRow(isFed ? 'Fedimint reviews' : 'Cashu mint reviews'),
|
||||
brandRow(isFed ? 'Fedimint reviews' : isLnurl ? 'LNURL mint reviews' : 'Cashu mint reviews'),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', alignItems: 'center', gap: '32px', marginTop: '52px', flexGrow: 1 },
|
||||
@@ -459,7 +541,8 @@ export function mintCard(model, iconData) {
|
||||
model.name,
|
||||
),
|
||||
statusChip(model),
|
||||
isFed && fedimintBadge(),
|
||||
isFed && typeBadge('FEDIMINT', VIOLET, 'rgba(167,139,250,.5)'),
|
||||
isLnurl && typeBadge('LNURL', LNURL, 'rgba(78,168,222,.5)'),
|
||||
),
|
||||
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.
|
||||
@@ -479,7 +562,7 @@ export function mintCard(model, iconData) {
|
||||
},
|
||||
ratingCell(model),
|
||||
reviewsCell(model),
|
||||
isFed ? thirdCellFedimint(model) : thirdCellCashu(model),
|
||||
isFed ? thirdCellFedimint(model) : isLnurl ? thirdCellLnurl(model) : thirdCellCashu(model),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -525,13 +608,16 @@ export function defaultCard(model) {
|
||||
h(
|
||||
'div',
|
||||
{ fontFamily: INTER, fontWeight: 500, fontSize: '28px', color: MUTED, marginTop: '14px' },
|
||||
'Cashu mint and Fedimint reviews, signed on Nostr',
|
||||
'Cashu, Fedimint and LNURL mint reviews, signed on Nostr',
|
||||
),
|
||||
h(
|
||||
'div',
|
||||
{ display: 'flex', gap: '86px', marginTop: '64px' },
|
||||
// Four stats where there were three, so the gap tightens rather than the row
|
||||
// running past the card's own margins.
|
||||
{ display: 'flex', gap: '64px', marginTop: '64px' },
|
||||
stat(model.mints, 'Cashu mints'),
|
||||
stat(model.federations, 'federations'),
|
||||
stat(model.lnurl, 'LNURL mints'),
|
||||
stat(model.reviews, 'signed reviews'),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The edge cases the template must survive, as synthetic API payloads.
|
||||
*
|
||||
* Run through the same cashuModel/fedimintModel derivation as real data — a fixture
|
||||
* Run through the same cashuModel/fedimintModel/lnurlModel 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.
|
||||
@@ -32,6 +32,31 @@ const base = {
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* The shared shape of an LNURL row's detail payload, as `GET /api/mints/:host` returns
|
||||
* it: the ecosystem fields flattened across the top level, millisatoshi on the wire.
|
||||
*/
|
||||
const lnurlBase = {
|
||||
type: 'lnurl',
|
||||
status: 'online',
|
||||
last_online: NOW - 300,
|
||||
last_probe: NOW - 300,
|
||||
first_seen: NOW - 200 * 86400,
|
||||
review_count: 9,
|
||||
rating_avg: 4.4,
|
||||
rating_distribution: dist(6, 2, 1),
|
||||
last_review_at: NOW - 20 * 86400,
|
||||
features: ['mint', 'melt', 'rotate', 'split', 'merge', 'lud06', 'lud03', 'lud16', 'signed-notes'],
|
||||
network: 'mainnet',
|
||||
mint_pubkey: '021ab89df9cbd28cdab8c71d228ca40deba1eaebd827f24849ec4f3e919c023555',
|
||||
funding_available: true,
|
||||
invalid_reason: null,
|
||||
min_withdrawable_msat: 5_000,
|
||||
max_withdrawable_msat: 999_899_000,
|
||||
lightning_address: 'mint@lnurl.example',
|
||||
};
|
||||
|
||||
export const FIXTURES = [
|
||||
{
|
||||
// A name at the 30-character mark: must step down, never clip.
|
||||
@@ -109,4 +134,42 @@ export const FIXTURES = [
|
||||
network: 'mainnet',
|
||||
status_source: null,
|
||||
},
|
||||
{
|
||||
// The healthy LNURL card: blue glow, LNURL badge, a withdraw range in the third
|
||||
// cell, and the lightning address under it.
|
||||
...lnurlBase,
|
||||
host: 'fixture-lnurl-online',
|
||||
url: 'lnurl:https://lnurl.example',
|
||||
base_url: 'https://lnurl.example',
|
||||
name: null,
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
// Offline 20 days: red glow beats the blue one, warning cell replaces the range.
|
||||
// The blue badge stays, because which ecosystem it is has not changed.
|
||||
...lnurlBase,
|
||||
host: 'fixture-lnurl-offline',
|
||||
url: 'lnurl:https://lnurl.gone.example',
|
||||
base_url: 'https://lnurl.gone.example',
|
||||
name: 'Departed Notes',
|
||||
icon: null,
|
||||
status: 'offline',
|
||||
last_online: NOW - 20 * 86400,
|
||||
rating_avg: 2.1,
|
||||
rating_distribution: dist(0, 1, 1, 3, 5),
|
||||
review_count: 10,
|
||||
},
|
||||
{
|
||||
// A withdraw ceiling of zero, while ONLINE: green status chip stays, and the
|
||||
// critical warning cell appears anyway. The one card that must never print
|
||||
// "max 0 sat" as though it were a limit.
|
||||
...lnurlBase,
|
||||
host: 'fixture-lnurl-no-withdrawals',
|
||||
url: 'lnurl:https://lnurl.locked.example',
|
||||
base_url: 'https://lnurl.locked.example',
|
||||
name: 'Locked Notes Mint',
|
||||
icon: null,
|
||||
min_withdrawable_msat: 0,
|
||||
max_withdrawable_msat: 0,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
* 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';
|
||||
import { getMintWarnings, msatToSat, parseLimits, readCapabilities } from '@cashumints/shared';
|
||||
|
||||
/** Bump when the template's layout or copy changes, to regenerate every image. */
|
||||
export const TEMPLATE_VERSION = 1;
|
||||
/**
|
||||
* Bump when the template's layout or copy changes, to regenerate every image.
|
||||
*
|
||||
* 2: the LNURL variant. Its badge, glow and third cell are new, and the shared
|
||||
* `warningCell` learned three LNURL warning kinds, so every card's hash input moved.
|
||||
*/
|
||||
export const TEMPLATE_VERSION = 2;
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
@@ -115,6 +120,11 @@ function warningCell(warning, mint, now) {
|
||||
'offline-long': 'Offline',
|
||||
'offline': 'Offline',
|
||||
'fedimint-offline': 'Reported offline',
|
||||
// LNURL. `lnurl-withdrawals-disabled` gets the same two words as the Cashu
|
||||
// melt-disabled cell, because it is the same statement to a reader.
|
||||
'lnurl-withdrawals-disabled': 'No withdrawals',
|
||||
'lnurl-no-funding': 'No mint / melt',
|
||||
'lnurl-invalid': 'Not responding properly',
|
||||
}[warning.kind];
|
||||
if (!label) return null;
|
||||
|
||||
@@ -135,11 +145,26 @@ function warningCell(warning, mint, now) {
|
||||
case 'frozen':
|
||||
sub = 'Nothing moves in or out';
|
||||
break;
|
||||
case 'lnurl-withdrawals-disabled':
|
||||
sub = 'Notes cannot be redeemed';
|
||||
break;
|
||||
case 'lnurl-no-funding':
|
||||
// The precise half of the story, and the half that stops a reader writing the
|
||||
// mint off: its notes still work, only Lightning is shut.
|
||||
sub = 'Notes still rotate and split';
|
||||
break;
|
||||
case 'lnurl-invalid':
|
||||
sub = 'Endpoint answered with junk';
|
||||
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) {
|
||||
if (
|
||||
['melt-only', 'melt-disabled', 'frozen', 'lnurl-withdrawals-disabled'].includes(warning.kind) &&
|
||||
mint.status === 'offline' &&
|
||||
since !== null
|
||||
) {
|
||||
sub = `Offline since ${shortDate(since)}`;
|
||||
}
|
||||
|
||||
@@ -227,6 +252,53 @@ export function fedimintModel(fed, { iconHash = null, now = Math.floor(Date.now(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One LNURL mint's card model.
|
||||
*
|
||||
* The same skeleton as the other two, with the third cell answering this ecosystem's
|
||||
* own question: how much can one of this mint's bearer notes be worth? That is the
|
||||
* withdraw range, in sats, converted here once from the millisatoshi the wire carries.
|
||||
*
|
||||
* `withdraw` is null when the ceiling is zero, and deliberately: a card printing
|
||||
* "max 0 sat" reads as a number rather than as a refusal, and the warning cell that
|
||||
* outranks it says the true thing instead.
|
||||
*/
|
||||
export function lnurlModel(mint, { iconHash = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
const baseUrl = mint.base_url ?? mint.url.replace(/^lnurl:/, '');
|
||||
const domain = displayDomain(baseUrl);
|
||||
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 min = msatToSat(mint.min_withdrawable_msat ?? null);
|
||||
const max = msatToSat(mint.max_withdrawable_msat ?? null);
|
||||
|
||||
return {
|
||||
v: TEMPLATE_VERSION,
|
||||
type: 'lnurl',
|
||||
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,
|
||||
withdraw: max !== null && max > 0 ? { min, max } : null,
|
||||
/** The LUD-16 address, which is the one string a reader might actually copy. */
|
||||
address: mint.lightning_address ?? null,
|
||||
warning: warningCell(warning, mint, now),
|
||||
iconHash,
|
||||
gradient: iconHash ? null : iconGradient(domain),
|
||||
initials: iconHash ? null : initials(name),
|
||||
};
|
||||
}
|
||||
|
||||
/** The default card: brand, tagline, and the network's own numbers. */
|
||||
export function defaultModel(stats) {
|
||||
return {
|
||||
@@ -235,6 +307,7 @@ export function defaultModel(stats) {
|
||||
slug: 'default',
|
||||
mints: stats.cashu_total,
|
||||
federations: stats.fedimint_total,
|
||||
lnurl: stats.lnurl_total,
|
||||
reviews: stats.reviews_total,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* A contact sheet of the three ecosystems' cards at thumbnail size.
|
||||
*
|
||||
* Not part of the build: run it by hand (`node scripts/og/thumbs.mjs`) to check the
|
||||
* one thing a full-size render cannot show you — whether a reader scrolling a timeline
|
||||
* can tell a Cashu card from a Fedimint one from an LNURL one before any text is
|
||||
* legible. Writes og-fixtures/_thumbs.png.
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Resvg } from '@resvg/resvg-js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dir = path.resolve(here, '../../og-fixtures');
|
||||
|
||||
/** 300px wide: roughly how a link preview renders in a feed. */
|
||||
const W = 300;
|
||||
const H = Math.round((630 / 1200) * W);
|
||||
const GAP = 16;
|
||||
const LABEL = 22;
|
||||
|
||||
const cards = [
|
||||
['Cashu', 'fixture-no-icon.png'],
|
||||
['Fedimint', 'fixture-fed-announced.png'],
|
||||
['LNURL', 'fixture-lnurl-online.png'],
|
||||
];
|
||||
|
||||
const parts = await Promise.all(
|
||||
cards.map(async ([label, file], i) => {
|
||||
const data = await readFile(path.join(dir, file));
|
||||
const x = i * (W + GAP);
|
||||
return (
|
||||
`<image x="${x}" y="${LABEL}" width="${W}" height="${H}" ` +
|
||||
`href="data:image/png;base64,${data.toString('base64')}"/>` +
|
||||
`<text x="${x}" y="15" font-family="monospace" font-size="13" fill="#EDE8DC">${label}</text>`
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${cards.length * (W + GAP) - GAP}" ` +
|
||||
`height="${H + LABEL}"><rect width="100%" height="100%" fill="#0E0D0B"/>${parts.join('')}</svg>`;
|
||||
|
||||
await writeFile(path.join(dir, '_thumbs.png'), new Resvg(svg).render().asPng());
|
||||
console.log(`og: og-fixtures/_thumbs.png (${cards.length} cards at ${W}px)`);
|
||||
Reference in New Issue
Block a user