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
+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);
}