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:
co-authored by
Claude Opus 5
parent
060c7f1a59
commit
14548179a0
+54
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user