Hydrate the mint lists from the live API after paint.

/mints was a snapshot of whatever the API held when `astro build` ran, and stayed
that until the next build: a mint indexed at noon was reviewable at once — the 404
resolver saw to that — and simply had no card until 03:30. Every card's rating,
review count and status were as stale as the page.

The three index pages and the home page's three top-six strips now refetch
`GET /api/mints?type=…` once, after paint, and rebuild their grids. The
prerendered cards stay: they are the first paint, what a crawler indexes, and the
whole page without JavaScript. Hydration only ever replaces them with something
newer, and never with nothing — neither a failed fetch nor a well-formed empty
array touches a grid that has cards in it.

To make that affordable, the list payload grew the facts a chip is drawn from:
`nuts`, `capabilities`, and the two probed LNURL fields. /mints and /lnurl-mints
were fetching `GET /api/mints/:host` once per mint at build time to read two
booleans off each; that N+1 is gone from both, which takes the build from
fifty-six requests to one and is what makes the same read possible in a browser.
Additive: `MintDetail` already had all four.

web/src/lib/mint-cards.ts is MintCard.astro's parallel renderer, the same
relationship review-cards.ts has with the reviews panel. Same classes, same
data-* attributes — the sort, the search, the rank chips and the shared-element
view transitions all read the DOM — and the same i18n, through the page's own
inlined catalog rather than a build-time one.

Base.astro gained `clientNamespaces`, so the home page can inline the `home.`
catalog its strips need to rewrite "All 60 mints →" without putting 2KB of
marketing copy on 1,300 mint pages. check-i18n reads the prop off the page, so
the two cannot disagree.

Verified in Chromium against the built site: 60 prerendered cards become 61
including a mint inserted after the build; sort, search and hide-offline operate
on the new cards; /es/mints renders "En línea", "54 reseñas", "4,9" and "Solo
fundir"; JavaScript disabled still shows all 60; an aborted or empty API leaves
the grid alone; and a navigation away and back re-hydrates. 2016 pages build,
link and hreflang checks pass, 30 web tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
michilis
2026-08-25 16:27:33 +02:00
co-authored by Claude Opus 5
parent 060c7f1a59
commit 14548179a0
11 changed files with 670 additions and 60 deletions
+54 -10
View File
@@ -3,6 +3,7 @@ import {
compareMints,
NEUTRAL_PRIOR_MEAN,
parseNuts,
readCapabilities,
type Health,
type MintDetail,
type MintInfo,
@@ -106,6 +107,39 @@ function round1(n: number | null): number | null {
return n === null ? null : Math.round(n * 10) / 10;
}
/**
* The NUT numbers a row publishes.
*
* `nuts_json` is what the prober wrote and wins; `info.nuts` is the raw NUT-06 object it
* was derived from, kept as a fallback for rows written before that column existed. One
* function so a list card and a detail page can never read a different answer off the
* same row.
*/
function rowNuts(row: MintRow, info: MintInfo | null): string[] {
if (row.nuts_json) {
try {
const parsed = JSON.parse(row.nuts_json) as string[];
if (parsed.length > 0) return parsed;
} catch {
// Fall through to the info object below.
}
}
return info ? parseNuts(info.nuts) : [];
}
/**
* One list item.
*
* The chip fields at the bottom are why this now parses `info_json`. The alternative was
* what /mints and /lnurl-mints used to do: fetch `GET /api/mints/:host` once per mint to
* read two booleans off each one. That is an acceptable price for a build machine
* rendering fifty-five cards once a night and an unacceptable one for every browser that
* opens the page, which is what the list has to survive now that it hydrates.
*
* Facts, not sentences. `capabilities` is two booleans and `mintChip` turns them into
* "Melt only" in the reader's language, wherever the card is being drawn. Rendering the
* label here would ship one language to twenty-four locales.
*/
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
const base = {
review_count: agg?.review_count ?? 0,
@@ -114,6 +148,15 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
last_review_at: agg?.last_review_at ?? null,
};
const info = parseInfo(row.info_json);
// A federation and an LNURL mint have no `info_json` and so get null, which is the
// honest value: not "both NUTs are enabled", but "there is nothing here to read".
const capabilities = info ? readCapabilities(info.nuts) : null;
// Only the two facts the LNURL chip is drawn from, not the whole ecosystem blob: this
// payload is fetched by every visitor on three pages.
const lnurl = row.type === 'lnurl' ? parseEcosystem<LnurlFields>(row) : null;
return {
url: row.url,
host: row.host,
@@ -127,6 +170,14 @@ function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: nu
score: bayesianScore(base, mean, now),
last_review_at: base.last_review_at,
version: row.version,
nuts: rowNuts(row, info),
capabilities,
...(lnurl
? {
max_withdrawable_msat: lnurl.max_withdrawable_msat ?? null,
funding_available: lnurl.funding_available ?? null,
}
: {}),
};
}
@@ -233,16 +284,9 @@ export async function getMintDetail(host: string): Promise<MintDetail | null> {
const item = toListItem(row, agg.get(row.url), mean, now);
const info = parseInfo(row.info_json);
let nuts: string[] = [];
if (row.nuts_json) {
try {
nuts = JSON.parse(row.nuts_json) as string[];
} catch {
nuts = [];
}
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
// `item.nuts` is the same read, through `rowNuts`. It used to be computed a second
// time here with a subtly different fallback rule; one function now answers for both.
const nuts = item.nuts;
/*
* Type-specific columns are spread across the payload rather than nested under a key.
+36
View File
@@ -1,5 +1,7 @@
/** Shapes returned by the API. The web app builds against these. */
import type { MintCapabilities } from './warnings.js';
/**
* Statuses a listed thing can be in.
*
@@ -35,6 +37,40 @@ export interface MintListItem {
score: number;
last_review_at: number | null;
version: string | null;
/* ---- card chips ----
*
* The three fields below exist so a card can be drawn from the list payload alone.
* Before them, /mints fetched `GET /api/mints/:host` once per mint at build time just
* to read two booleans off each one, which is fine for fifty-five mints on one build
* machine and is not fine for every visitor's browser once the list hydrates. They are
* facts, never rendered strings: the label a chip prints is decided by `mintChip` in
* the reader's own language, on whichever side is drawing the card.
*
* A federation has no counterpart and needs none — it publishes no capability list, so
* `mintChip` returns null for one and always will. See the Fedimint branch of
* `getMintWarnings`.
*/
/**
* NUT numbers this mint publishes, as strings: `["4", "5", "17"]`. Cashu only; empty
* for the other ecosystems and for a mint whose `/v1/info` has never been read.
*/
nuts: string[];
/**
* NUT-04 and NUT-05 switches, the Cashu chip's only input. null means nothing is
* cached for this mint, which is a different fact from "both are on" — see
* `readCapabilities`.
*/
capabilities: MintCapabilities | null;
/**
* LNURL: the advertised withdraw ceiling, millisatoshi. Optional rather than
* `| null`, so this stays exactly what `Partial<LnurlFields>` declares on `MintDetail`
* and the two do not have to be kept identical by hand.
*/
max_withdrawable_msat?: number | null;
/** LNURL: whether the last probe reached the mint's funding node. */
funding_available?: boolean | null;
}
export interface ProbeSample {
+15 -1
View File
@@ -268,6 +268,20 @@ for (const file of files) {
// A .ts file under lib/ or scripts/ is island code wholesale.
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
/*
* A page can inline one extra namespace for its own islands, through Base.astro's
* `clientNamespaces` prop. The home page does: its grids hydrate from the API and
* rewrite their own "All 56 mints" links, whose strings live under `home.` — a
* namespace not worth inlining on 1,300 mint pages that never read it.
*
* Read out of the page rather than listed here, so the prop and this check cannot
* disagree. A namespace a page does not actually pass is still a leak.
*/
const extraNamespaces = new Set(
[...(/clientNamespaces=\{\[([^\]]*)\]\}/.exec(source)?.[1] ?? '').matchAll(/'([\w-]+)'/g)]
.map((m) => m[1]),
);
for (const match of source.matchAll(T_CALL)) {
const key = match[2];
used.add(key);
@@ -280,7 +294,7 @@ for (const file of files) {
for (const match of clientSource.matchAll(T_CALL)) {
const key = match[2];
const namespace = key.split('.')[0];
if (!clientNamespaces.has(namespace)) {
if (!clientNamespaces.has(namespace) && !extraNamespaces.has(namespace)) {
clientLeaks.push({ key, file: relative, namespace });
}
}
+11 -2
View File
@@ -121,13 +121,22 @@ export function missingKeys(): Record<string, string[]> {
* key that survives is resolved: a key this locale is missing arrives already filled
* with the English string, so the browser needs no fallback catalog and ships exactly
* one language.
*
* `extra` is for a namespace exactly one page's islands need. The home page's grids
* hydrate and have to rewrite their own "All 56 mints →" links, which live under
* `home.` — a namespace worth about 2KB that every other page, including 1,300 mint
* pages, has no use for. Passed per page through `Base.astro`'s `clientNamespaces`
* prop, it is inlined where it is read and nowhere else. `check-i18n.mjs` does not know
* about this, so a key reached this way must still be in a namespace the checker
* accepts, or listed in `CLIENT_NAMESPACES` — see the note there.
*/
export function clientCatalog(locale: Locale): Catalog {
export function clientCatalog(locale: Locale, extra: readonly string[] = []): Catalog {
const catalog = catalogFor(locale);
const allowed = new Set<string>([...CLIENT_NAMESPACES, ...extra]);
const out: Catalog = {};
for (const key of Object.keys(BASE_CATALOG)) {
const namespace = key.split('.')[0] ?? '';
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
if (!allowed.has(namespace)) continue;
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
}
return out;
+10 -2
View File
@@ -25,6 +25,14 @@ interface Props {
description: string;
current?: 'mints' | 'fedimints' | 'lnurl-mints' | 'reviews' | 'wallets' | 'about';
mintCount?: number;
/**
* Catalog namespaces this page's islands need on top of `CLIENT_NAMESPACES`.
*
* The home page passes `['home']`: its three grids refresh from the API and rewrite
* their own "All 56 mints →" links, so those strings have to reach the browser. They
* are inlined on the one page that reads them rather than on all 1,300.
*/
clientNamespaces?: readonly string[];
ogType?: string;
/**
* A real page that should not be in the index.
@@ -70,7 +78,7 @@ interface Props {
const {
title, description, current, mintCount, ogType = 'website',
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
imageAlt,
imageAlt, clientNamespaces = [],
} = Astro.props;
/*
@@ -118,7 +126,7 @@ const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
* travels in the HTML the page was sending anyway, costs no extra request, and is on
* screen before the first island has finished downloading.
*/
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale) }).replace(/</g, '\\u003c');
const i18nPayload = JSON.stringify({ locale, catalog: clientCatalog(locale, clientNamespaces) }).replace(/</g, '\\u003c');
/**
* The social card, absolute.
+312
View File
@@ -0,0 +1,312 @@
/**
* The mint card, as HTML strings, and the hydration that puts them on a page.
*
* `MintCard.astro` renders the same card at build time and cannot run in the browser, so
* this is its parallel renderer — the same relationship `review-cards.ts` has with the
* reviews panel. The two must agree on every class name and every `data-*` attribute,
* because the sort, the filter, the rank chips and the shared-element view transitions
* on the three index pages all read the DOM rather than any model:
*
* data-mint-card what the sort collects and the transition arms
* data-name / data-domain what the search box matches
* data-status "hide offline", and the online/offline counts
* data-score / -rating / -reviews the sort keys
* data-last-review / -last-online the other two sort keys
* data-vt-icon / data-vt-name shared-element names, applied on click
*
* Why this exists at all: /mints was a snapshot of whatever the API held when `astro
* build` ran, and stayed that until the next build. A mint indexed at noon was reviewable
* immediately — the 404 resolver saw to that — and simply had no card until the nightly
* rebuild. The list now refetches after paint. The prerendered cards stay exactly as they
* were: they are the first paint, they are what a crawler and a reader with no JavaScript
* get, and this only ever replaces them with something newer.
*
* Nothing here talks to a relay. Card counts come from the API's ingested aggregates,
* which is what they always were; review *bodies* remain a detail-page and /reviews
* concern. See docs/dynamic-mint-data.md.
*
* Everything interpolated goes through `escapeHtml`. A mint's name comes from its own
* `/v1/info` — a string an operator controls — and this builds markup with strings.
*/
import {
baseUrlFromKey,
federationIdFromSlug,
getMintWarnings,
mintChip,
type MintListItem,
} from '@cashumints/shared';
import { apiBase, escapeHtml, iconGradient } from './client';
import { targetPath } from './feed-resolve';
import { displayDomain, initials, transitionName } from './format';
import { localePath, splitLocale } from '../i18n/routing';
import type { Locale } from '../i18n/config';
import { useI18n } from '../i18n/client';
import { formatters, starString, type Formatters } from '../i18n/format';
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
import { initReveal } from '../scripts/reveal';
export interface CardOptions {
/** 1-based position in the default order. Omitted, the card has no rank chip. */
rank?: number;
/** Entrance delay in ms, for a grid of known size that arrives as one gesture. */
revealDelay?: number;
/**
* Render already revealed, with no entrance.
*
* Set for a card replacing one the reader is already looking at. `[data-reveal]` is
* `opacity: 0` until `.in` lands, so without this a hydration would fade the whole
* visible grid back in a second after load — an animation that says "something
* changed" about forty cards where at most one did.
*/
revealed?: boolean;
}
/**
* One card's markup.
*
* `f` carries both the translator and the locale's number and date formatting, and
* `locale` is what prefixes the href. Both are passed in rather than read here, because
* a grid renders dozens of these and rebuilding the formatter per card would be the
* expensive part of the whole hydration.
*/
export function mintCardHtml(
mint: MintListItem,
locale: Locale,
f: Formatters,
options: CardOptions = {},
): string {
const t = f.t;
const { rank, revealDelay, revealed } = options;
/*
* One card, all three ecosystems — the same reasoning as MintCard.astro.
*
* A federation has no URL, so its second line is a shortened federation id rather than
* `fedimint:aeca6c…`, which is a database key. An LNURL mint's row key carries an
* `lnurl:` scheme in front of its URL, so the domain comes out of the key.
*/
const fedimint = mint.type === 'fedimint';
const lnurlBase = baseUrlFromKey(mint.url);
const domain = fedimint
? federationIdFromSlug(mint.host)
: displayDomain(lnurlBase ?? mint.url);
const name = fedimint
? mint.name ?? t('card.unnamedFederation')
: mint.name ?? domain.split('/')[0] ?? domain;
// Never `API_URL`: that is a build-machine address and a visitor's browser cannot
// reach it. `apiBase` is PUBLIC_API_URL, empty in production, which resolves /icons
// against whatever origin is serving the page.
const icon = mint.icon ? `${apiBase}${mint.icon}` : null;
const offline = mint.status === 'offline';
const announced = mint.status === 'announced';
const href = localePath(targetPath(mint), locale);
/*
* The chip, from facts the list payload now carries.
*
* It used to take one `GET /api/mints/:host` per mint to read these — fine for a build
* machine rendering the grid once a night, ruinous as an N+1 in every visitor's
* browser. `capabilities` and the two LNURL fields were added to `MintListItem` for
* exactly this. A federation has no chip and never will: `mintChip` returns null for
* one, because a federation publishes no capability list to draw a claim from.
*/
const chipSource =
mint.capabilities || mint.max_withdrawable_msat !== undefined || mint.funding_available !== undefined
? {
type: mint.type,
status: mint.status,
last_online: mint.last_online,
capabilities: mint.capabilities ?? null,
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}
: null;
const chip = chipSource
? mintChip(getMintWarnings(chipSource, warningOptions(f)), chipStrings(t))
: null;
// Falls back to the rating split when there is no distribution, exactly as the build
// does for /mints: a 4.6 average is roughly 92% positive, which is what the bar says.
const pos =
mint.rating_avg === null ? 0 : Math.round(((mint.rating_avg - 1) / 4) * 100);
const neg = mint.rating_avg === null ? 0 : 100 - pos;
const vtIcon = escapeHtml(transitionName('icon', mint.host));
const vtName = escapeHtml(transitionName('name', mint.host));
const iconHtml = icon
? `<img class="mc-icon" src="${escapeHtml(icon)}" alt="" width="42" height="42" ` +
`loading="lazy" decoding="async" data-vt-icon="${vtIcon}">`
: `<span class="mc-icon" style="background:${escapeHtml(iconGradient(domain))}" ` +
`aria-hidden="true" data-vt-icon="${vtIcon}">${escapeHtml(initials(name))}</span>`;
const statsHtml =
mint.rating_avg === null
? `<span class="mc-none">${escapeHtml(t('card.noRatings'))}</span>`
: `<span class="mc-rating">` +
`<span class="mc-score">${escapeHtml(f.decimal(mint.rating_avg))}</span>` +
`<span class="mc-stars" aria-hidden="true">${starString(mint.rating_avg)}</span>` +
`</span>`;
/*
* "Announced" has no "last seen" to print, and printing one anyway is exactly the fake
* status this site refuses to show. What it has instead is the fact that nothing has
* checked it, said in as many words.
*/
const last = announced
? t('card.notChecked')
: offline
? mint.last_online
? t('card.lastSeen', { when: f.relative(mint.last_online) })
: t('card.neverSeen')
: mint.last_review_at
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
: t('card.noReviews');
const classes = ['mint-card'];
if (offline) classes.push('is-offline');
if (revealed) classes.push('in');
return (
`<a class="${classes.join(' ')}" href="${escapeHtml(href)}" data-reveal` +
(revealDelay === undefined ? '' : ` data-reveal-delay="${revealDelay}"`) +
` data-mint-card` +
` data-name="${escapeHtml(name.toLowerCase())}"` +
` data-domain="${escapeHtml(domain.toLowerCase())}"` +
` data-status="${escapeHtml(mint.status)}"` +
` data-score="${mint.score}"` +
` data-rating="${mint.rating_avg ?? 0}"` +
` data-reviews="${mint.review_count}"` +
` data-last-review="${mint.last_review_at ?? 0}"` +
` data-last-online="${mint.last_online ?? 0}">` +
`<div class="mc-top">` +
iconHtml +
`<span class="mc-id">` +
`<span class="mc-name" data-vt-name="${vtName}">${escapeHtml(name)}</span>` +
`<span class="mc-domain${fedimint ? ' mono' : ''}">${escapeHtml(domain)}</span>` +
`</span>` +
(rank === undefined
? ''
: `<span class="mc-rank${rank === 1 ? ' gold' : ''}">#${rank}</span>`) +
`</div>` +
`<div class="mc-stats">` +
statsHtml +
`<span class="mc-reviews">${escapeHtml(t('card.reviews', { n: mint.review_count }))}</span>` +
`</div>` +
`<div class="mc-bar" aria-hidden="true"><span class="mc-bar-fill">` +
(pos > 0 ? `<span class="pos" style="width:${pos}%"></span>` : '') +
(neg > 0 ? `<span class="neg" style="width:${neg}%"></span>` : '') +
`</span></div>` +
`<div class="mc-foot">` +
`<span class="mc-dot ${escapeHtml(mint.status)}"></span>` +
`<span class="mc-status">${escapeHtml(statusLabel(mint.status, t))}</span>` +
(chip ? `<span class="mc-chip ${escapeHtml(chip.severity)}">${escapeHtml(chip.label)}</span>` : '') +
`<span class="last">${escapeHtml(last)}</span>` +
`</div>` +
`</a>`
);
}
/**
* One ecosystem's full listing, or null.
*
* Null on anything at all going wrong, and the caller's job is then to do nothing: the
* prerendered grid is already on screen and correct as of the last build, so a failed
* refresh should be invisible rather than an error message about a list the reader can
* see. This is the same rule the reviews panel and the pulse ticker follow.
*/
export async function fetchListing(type: string): Promise<MintListItem[] | null> {
try {
const res = await fetch(`${apiBase}/api/mints?type=${encodeURIComponent(type)}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const items = (await res.json()) as MintListItem[];
// A well-formed empty answer is still not a reason to empty a grid that has cards in
// it. An API serving nothing is the failure the build gate exists to catch, and a
// page that renders it as "no mints" would be this bug wearing a different hat.
return Array.isArray(items) && items.length > 0 ? items : null;
} catch {
return null;
}
}
/**
* Replace a grid's cards with freshly rendered ones.
*
* Cards whose host was already on screen and revealed are rendered revealed, so the
* common case — the list is the same list, with newer numbers — is a silent swap rather
* than forty cards fading in again. Genuinely new hosts get the ordinary entrance from
* `initReveal`, which is also what reveals anything below the fold on scroll.
*
* Returns the new card elements, in DOM order, for the caller to re-apply its sort and
* filter to.
*/
export function renderMintGrid(
grid: HTMLElement,
items: MintListItem[],
options: { ranked?: boolean; revealDelayStep?: number } = {},
): HTMLElement[] {
const { ranked = true, revealDelayStep } = options;
const t = useI18n();
const f = formatters(t);
const { locale } = splitLocale(window.location.pathname);
// Which hosts the reader can already see. Keyed by host rather than by index: the list
// may have grown, shrunk or reordered, and the question is per mint.
const revealed = new Set<string>();
for (const card of grid.querySelectorAll<HTMLAnchorElement>('[data-mint-card]')) {
if (card.classList.contains('in')) {
const host = card.getAttribute('href')?.split('/').pop();
if (host) revealed.add(decodeURIComponent(host));
}
}
grid.innerHTML = items
.map((mint, i) =>
mintCardHtml(mint, locale, f, {
...(ranked ? { rank: i + 1 } : {}),
...(revealDelayStep === undefined ? {} : { revealDelay: i * revealDelayStep }),
revealed: revealed.has(mint.host),
}),
)
.join('');
initReveal(grid);
return [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
}
export interface HydrateOptions {
/** `cashu`, `fedimint` or `lnurl`. */
type: string;
/** The grid to rebuild. */
grid: HTMLElement;
/** Keep only the first N, for the home page's top-six strips. */
limit?: number;
ranked?: boolean;
revealDelayStep?: number;
/** Called with the new cards and the payload they were built from, on success only. */
onReplaced?: (cards: HTMLElement[], items: MintListItem[]) => void;
}
/**
* Fetch one ecosystem and rebuild its grid, after paint.
*
* Deliberately silent on failure — see `fetchListing`. Deliberately unconditional on
* success: the API is the newer of the two by construction, since the prerendered grid
* is a copy of what this same endpoint said at build time.
*/
export async function hydrateMintGrid(options: HydrateOptions): Promise<void> {
const { type, grid, limit, ranked, revealDelayStep, onReplaced } = options;
const all = await fetchListing(type);
if (!all) return;
const items = limit === undefined ? all : all.slice(0, limit);
const cards = renderMintGrid(grid, items, {
...(ranked === undefined ? {} : { ranked }),
...(revealDelayStep === undefined ? {} : { revealDelayStep }),
});
onReplaced?.(cards, all);
}
+3
View File
@@ -98,6 +98,9 @@ export const FIXTURE_MINT: MintDetail = {
pubkey: '0296d0aa13b6a31cf0cd974249f4c6ed579061a4705ab9a4c1b6b1e1e4d7f6f9',
info: null,
nuts: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
// Both NUTs published and neither switched off, so this fixture draws no card chip —
// which is what a representative healthy mint should look like.
capabilities: { mintDisabled: false, meltDisabled: false, mintPublished: true, meltPublished: true },
first_seen: daysAgo(420),
last_probe: daysAgo(0),
updated_at: daysAgo(0),
+41 -1
View File
@@ -247,6 +247,7 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -267,7 +268,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -415,6 +424,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'fedimint',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
+74 -8
View File
@@ -100,7 +100,18 @@ const signedBody = t('home.why.signed.body', {
});
---
<Base title={t('home.title')} description={description} current="mints">
{/*
`clientNamespaces` inlines the `home.` catalog on this page and no other. The three
grids below refresh from the API after paint and rewrite their own "All 56 mints →"
links, so those strings have to reach the browser; 1,300 mint pages have no use for
them. See `clientCatalog` in src/i18n/index.ts.
*/}
<Base
title={t('home.title')}
description={description}
current="mints"
clientNamespaces={['home']}
>
{/*
The hero sits on an animated colour field: ColorBends from React Bits, ported to
plain WebGL in scripts/color-bends.ts. The wrapper is what the field fills, and it
@@ -180,9 +191,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-mints">{t('home.top.title')}</h2>
<span class="sec-sub">{t('home.top.sub')}</span>
<a class="sec-link" href={localePath('/mints', locale)}>{t('home.top.all', { n: mints.length })}</a>
<a class="sec-link" href={localePath('/mints', locale)} data-all-link="cashu">
{t('home.top.all', { n: mints.length })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="cashu">
{top.map((mint, i) => (
<MintCard mint={mint} rank={i + 1} sentiment={sentiments[i]} capabilities={capabilities[i]} revealDelay={i * 50} />
))}
@@ -200,11 +213,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-fedimints">{t('home.topFedimints.title')}</h2>
<span class="sec-sub">{t('home.topFedimints.sub')}</span>
<a class="sec-link" href={localePath('/fedimints', locale)}>
<a class="sec-link" href={localePath('/fedimints', locale)} data-all-link="fedimint">
{t('home.topFedimints.all', { n: federations.length })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="fedimint">
{topFederations.map((federation, i) => (
<MintCard mint={federation} rank={i + 1} sentiment={fediSentiments[i]} revealDelay={i * 50} />
))}
@@ -225,11 +238,11 @@ const signedBody = t('home.why.signed.body', {
<div class="sec-head" data-reveal>
<h2 class="sec-title" id="top-lnurl">{t('home.topLnurl.title')}</h2>
<span class="sec-sub">{t('home.topLnurl.sub')}</span>
<a class="sec-link" href={localePath('/lnurl-mints', locale)}>
<a class="sec-link" href={localePath('/lnurl-mints', locale)} data-all-link="lnurl">
{t('home.topLnurl.all', { n: stats.lnurl_total })}
</a>
</div>
<div class="mint-grid">
<div class="mint-grid" data-home-grid="lnurl">
{topLnurl.map((mint, i) => (
<MintCard
mint={mint}
@@ -615,14 +628,67 @@ const signedBody = t('home.why.signed.body', {
</style>
<script>
import { onLeave, onReady, prefersReducedMotion, scrollBehavior } from '../../scripts/reveal';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { onLeave, onReady, prefersReducedMotion, scrollBehavior, swapText } from '../../scripts/reveal';
/** One card every six seconds, until the reader touches the track. */
const AUTO_MS = 6000;
/** Matches the track's CSS gap. */
const GAP = 16;
/**
* The three top-six strips, and the "All N" link over each of them.
*
* The home page prerenders six cards per ecosystem and a count beside them, from the
* same API the index pages read. Between builds the strips went stale in two ways at
* once: a new mint could not appear in the top six however good it was, and the count
* beside the link said how many mints existed at 03:30. Both are one fetch away.
*
* The key is the catalog string for that link, which is why this page inlines the
* `home.` namespace (see `clientNamespaces` on Base above). `t()` formats the number
* for the locale, so "All 1,247 mints" and "All 1.247 mints" both come out right.
*
* Sentiment bars are the one thing hydration cannot improve here: the prerendered
* cards get real rating distributions from a per-mint detail fetch at build time, and
* the list payload has no distribution in it. A refreshed card falls back to the
* rating proxy the index pages have always used — a 4.6 average reads as 92% positive
* — which is a slightly coarser bar on a card whose numbers are otherwise newer.
*/
const STRIPS = [
{ type: 'cashu', key: 'home.top.all' },
{ type: 'fedimint', key: 'home.topFedimints.all' },
{ type: 'lnurl', key: 'home.topLnurl.all' },
] as const;
const hydrateStrips = (): void => {
const t = useI18n();
for (const strip of STRIPS) {
const grid = document.querySelector<HTMLElement>(`[data-home-grid="${strip.type}"]`);
// A section with nothing in it is not rendered at all, and a strip that was empty
// at build time stays empty until the next one: there is no heading to hang cards
// under. Rare, and not worth building a section in JavaScript for.
if (!grid) continue;
void hydrateMintGrid({
type: strip.type,
grid,
limit: 6,
// The reveal delay the build gives these six, so a refreshed strip arrives the
// same way the prerendered one did.
revealDelayStep: 50,
onReplaced: (_cards, all) => {
const link = document.querySelector<HTMLElement>(`[data-all-link="${strip.type}"]`);
if (link) swapText(link, t(strip.key, { n: all.length }));
},
});
}
};
const setup = (): void => {
requestAnimationFrame(() => hydrateStrips());
// No track at all means the relays gave the build nothing, and the section is
// showing its empty state instead.
const found = {
+61 -21
View File
@@ -2,8 +2,7 @@
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import type { LnurlDetail } from '@cashumints/shared';
import { fetchLnurlMint, fetchLnurlMints } from '../../lib/api';
import { fetchLnurlMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo';
@@ -32,25 +31,17 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
const mints = await fetchLnurlMints();
/*
The list payload carries no LNURL fields, so the "no withdrawals" and "no mint / melt"
chips need each mint's detail. One fetch per mint, at build time, against the API on
the same machine — exactly what /mints does for its NUT switches. A mint whose detail
cannot be read simply gets no chip.
One request, no N+1.
This used to be `fetchLnurlMints()` followed by one `fetchLnurlMint(host)` per mint,
purely to read the two probed facts behind the "no withdrawals" and "no mint / melt"
chips: an advertised withdraw ceiling of zero, and an unreachable Lightning node.
`MintListItem` carries both now, exactly as it carries the Cashu NUT switches, so the
chips come off the same payload as everything else — which is what makes the hydration
below possible without an N+1 in every visitor's browser.
*/
const details = await Promise.all(
mints.map((mint) => fetchLnurlMint(mint.host).catch(() => null)),
);
const chipSources = details.map((detail: LnurlDetail | null) =>
detail
? {
max_withdrawable_msat: detail.max_withdrawable_msat ?? null,
funding_available: detail.funding_available ?? null,
}
: null,
);
const mints = await fetchLnurlMints();
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
@@ -152,7 +143,16 @@ const schema = [
</p>
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} lnurl={chipSources[i]} />)}
{mints.map((mint, i) => (
<MintCard
mint={mint}
rank={i + 1}
lnurl={{
max_withdrawable_msat: mint.max_withdrawable_msat ?? null,
funding_available: mint.funding_available ?? null,
}}
/>
))}
</div>
<p class="no-results" data-no-results hidden>
@@ -258,6 +258,7 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -280,7 +281,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -431,6 +440,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'lnurl',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();
+53 -15
View File
@@ -2,8 +2,7 @@
import Base from '../../layouts/Base.astro';
import MintCard from '../../components/MintCard.astro';
import ReviewByUrl from '../../components/ReviewByUrl.astro';
import { readCapabilities } from '@cashumints/shared';
import { fetchMint, fetchMints } from '../../lib/api';
import { fetchMints } from '../../lib/api';
import { localePath, useI18n, type Locale } from '../../i18n';
import { itemListNode } from '../../lib/schema';
import { isIndexableMint } from '../../lib/seo';
@@ -20,19 +19,18 @@ const { locale } = Astro.props;
// `t` formats the numbers inside its own strings, so no separate formatter is needed.
const t = useI18n(locale);
/*
One request, no N+1.
This used to be `fetchMints()` followed by one `fetchMint(host)` per mint, purely to
read the two NUT switches behind the "melt only" and "frozen" chips. `MintListItem`
carries `capabilities` now, so the chips come off the same payload as everything else.
That was worth doing for the build — fifty-six requests down to one — and it was
necessary for the hydration below, which does the same read in every visitor's browser
and could not have done it fifty-six times.
*/
const mints = await fetchMints();
/*
The list payload carries no NUT information, so the "melt only" and "frozen" chips
need each mint's cached info. One detail fetch per mint, at build time, against the
API on the same machine: the mint pages already do exactly this in getStaticPaths.
A mint whose detail cannot be read simply gets no chip.
*/
const capabilities = await Promise.all(
mints.map((mint) =>
fetchMint(mint.host).then((detail) => readCapabilities(detail.info?.nuts)).catch(() => null),
),
);
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
@@ -128,7 +126,7 @@ const schema = [
</p>
<div class="mint-grid" data-mint-grid>
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={capabilities[i]} />)}
{mints.map((mint, i) => <MintCard mint={mint} rank={i + 1} capabilities={mint.capabilities} />)}
</div>
<p class="no-results" data-no-results hidden>
@@ -232,6 +230,7 @@ const schema = [
<script>
import { wireCopyableIds } from '../../lib/client';
import { hydrateMintGrid } from '../../lib/mint-cards';
import { useI18n } from '../../i18n/client';
import { enterStagger, onLeave, prefersReducedMotion, onReady, swapText } from '../../scripts/reveal';
@@ -254,7 +253,15 @@ const schema = [
const clearSearch = document.querySelector<HTMLButtonElement>('[data-clear-search]');
if (grid && searchInput && sortSelect && hideOffline) {
const cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
/*
* Re-readable, not captured once.
*
* The grid is rebuilt from the live API a moment after paint (see the bottom of this
* block), so a `const cards` snapshot taken at setup would leave every control
* sorting and filtering elements that are no longer in the document — the search box
* would appear to do nothing at all.
*/
let cards = [...grid.querySelectorAll<HTMLElement>('[data-mint-card]')];
const num = (card: HTMLElement, key: string) => Number(card.dataset[key] ?? 0);
const offlineRank = (card: HTMLElement) => (card.dataset['status'] === 'offline' ? 1 : 0);
@@ -405,6 +412,37 @@ const schema = [
if (sort && sort in SORTS) sortSelect.value = sort;
if (q || sort) apply();
/*
* The live list, one fetch after paint.
*
* Everything above this line operates on the prerendered grid, which is a copy of
* what this same endpoint returned when `astro build` ran. That copy is the first
* paint, it is what a crawler indexes, and it is the whole page for a reader with no
* JavaScript — so it stays, and this only ever replaces it with something newer.
* A mint indexed since the last build gets its card here; every card's rating,
* review count and status arrive current rather than as of 03:30.
*
* `requestAnimationFrame` so the fetch is not competing with the first paint it is
* improving. Failure is silent by design: `hydrateMintGrid` does nothing at all
* unless it has a non-empty list in hand, and a correct-as-of-last-build grid is a
* far better answer to a flaky network than an error about a list already on screen.
*
* `apply()` afterwards re-applies whatever the reader had already set — a search
* they typed, a sort they picked, "hide offline" — against the new cards, so the
* refresh cannot undo an interaction that happened before it landed.
*/
const hydrate = (): void => {
void hydrateMintGrid({
type: 'cashu',
grid: grid!,
onReplaced: (fresh) => {
cards = fresh;
apply();
},
});
};
requestAnimationFrame(() => hydrate());
onLeave(() => {
for (const timer of leaving.values()) window.clearTimeout(timer);
leaving.clear();