From 14548179a02c0c90b4a7afe3d2457c38240a847c Mon Sep 17 00:00:00 2001 From: michilis Date: Tue, 25 Aug 2026 16:27:33 +0200 Subject: [PATCH] Hydrate the mint lists from the live API after paint. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- api/src/queries.ts | 64 +++- shared/src/types.ts | 36 +++ web/scripts/check-i18n.mjs | 16 +- web/src/i18n/index.ts | 13 +- web/src/layouts/Base.astro | 12 +- web/src/lib/mint-cards.ts | 312 ++++++++++++++++++++ web/src/lib/skeleton-fixtures.ts | 3 + web/src/pages/[...locale]/fedimints.astro | 42 ++- web/src/pages/[...locale]/index.astro | 82 ++++- web/src/pages/[...locale]/lnurl-mints.astro | 82 +++-- web/src/pages/[...locale]/mints.astro | 68 ++++- 11 files changed, 670 insertions(+), 60 deletions(-) create mode 100644 web/src/lib/mint-cards.ts diff --git a/api/src/queries.ts b/api/src/queries.ts index 0d59b46..1f2f594 100644 --- a/api/src/queries.ts +++ b/api/src/queries.ts @@ -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(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 { 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. diff --git a/shared/src/types.ts b/shared/src/types.ts index d8691f1..9aaaa66 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -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` 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 { diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs index c4700ab..a8850bc 100644 --- a/web/scripts/check-i18n.mjs +++ b/web/scripts/check-i18n.mjs @@ -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 }); } } diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index e710a94..584ec02 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -121,13 +121,22 @@ export function missingKeys(): Record { * 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([...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; diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index d166e64..bda7e5c 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -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(/` + : `${escapeHtml(initials(name))}`; + + const statsHtml = + mint.rating_avg === null + ? `${escapeHtml(t('card.noRatings'))}` + : `` + + `${escapeHtml(f.decimal(mint.rating_avg))}` + + `` + + ``; + + /* + * "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 ( + `` + + `
` + + iconHtml + + `` + + `${escapeHtml(name)}` + + `${escapeHtml(domain)}` + + `` + + (rank === undefined + ? '' + : `#${rank}`) + + `
` + + `
` + + statsHtml + + `${escapeHtml(t('card.reviews', { n: mint.review_count }))}` + + `
` + + `` + + `
` + + `` + + `${escapeHtml(statusLabel(mint.status, t))}` + + (chip ? `${escapeHtml(chip.label)}` : '') + + `${escapeHtml(last)}` + + `
` + + `
` + ); +} + +/** + * 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 { + 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(); + for (const card of grid.querySelectorAll('[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('[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 { + 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); +} diff --git a/web/src/lib/skeleton-fixtures.ts b/web/src/lib/skeleton-fixtures.ts index ac1e0ad..2d82b59 100644 --- a/web/src/lib/skeleton-fixtures.ts +++ b/web/src/lib/skeleton-fixtures.ts @@ -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), diff --git a/web/src/pages/[...locale]/fedimints.astro b/web/src/pages/[...locale]/fedimints.astro index a2107f0..4ce105b 100644 --- a/web/src/pages/[...locale]/fedimints.astro +++ b/web/src/pages/[...locale]/fedimints.astro @@ -247,6 +247,7 @@ const schema = [