Improve SEO and social card wiring

Add SearchAction and optional Product JSON-LD, per-page OG images/alts, and document the og build and cache headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-21 02:17:35 +02:00
co-authored by Cursor
parent 6f17b572b1
commit 1c5df18e81
10 changed files with 120 additions and 60 deletions
+5
View File
@@ -38,6 +38,11 @@ PUBLIC_API_URL=
# Canonical origin for canonical links, OpenGraph tags and the sitemap. # Canonical origin for canonical links, OpenGraph tags and the sitemap.
SITE_URL=https://cashumints.space SITE_URL=https://cashumints.space
# Whether a rated mint's JSON-LD also carries the review-snippet-eligible Product
# type next to Service (web/src/lib/schema.ts explains the trade). On by default;
# set to 0 to ship the plain Service node on the next build, no code change needed.
SEO_PRODUCT_JSONLD=1
# ─── API storage ───────────────────────────────────────────────────────────── # ─── API storage ─────────────────────────────────────────────────────────────
# The API serves everything from its own database and nothing from a mint or a relay at # The API serves everything from its own database and nothing from a mint or a relay at
# request time, so a restart loses nothing: mint metadata, cached /v1/info payloads, # request time, so a restart loses nothing: mint metadata, cached /v1/info payloads,
+42 -3
View File
@@ -282,6 +282,32 @@ OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link` set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
alternates, and `robots.txt` sits beside it. alternates, and `robots.txt` sits beside it.
### Social cards
`pnpm build` starts with `pnpm og` (`web/scripts/og/build-og.mjs`), which renders one
1200x630 PNG per mint and federation into `web/public/og/` from the same API data the
pages use — satori lays the card out and turns the text into glyph paths (the six
static TTFs under `web/scripts/og/fonts/` are the same faces the site uses),
`@resvg/resvg-js` rasterises it. No browser involved; a full run of ~70 cards takes a
few seconds, and a rerun with unchanged data renders nothing: each card's inputs are
hashed into its filename (`mint.example.com.a1b2c3d4e5.png`) and
`web/src/generated/og-manifest.json` records what is already on disk. The hashed name
is also the cache-busting story — link-preview scrapers cache an `og:image` by URL, so
a mint whose rating moved or that went offline gets a new URL on the next scheduled
rebuild, while the stable-named `default.png` (brand plus network stats, used by every
non-mint page) is served with a one-hour cache instead (see the nginx block below).
The images are one English render shared by every locale; titles, descriptions and alt
text translate per page. Relative times stay out of the PNGs on purpose — a "3d ago"
would go stale inside a static file — so the cards carry absolute month/year stamps,
and the one exception, the "Offline {n}d" chip, is derived from a day-granular clock so
it regenerates at most once a day.
`pnpm og:fixtures` renders the six edge cases in `web/scripts/og/fixtures.mjs`
(30-character name, no icon, zero reviews, offline, melt only, announced-only
federation) into `web/og-fixtures/` at a pinned timestamp; those snapshots are
committed, so eyeball them after any template change.
### What is indexed, and what is not ### What is indexed, and what is not
One rule, in `web/src/lib/seo.ts`, decides it, and it covers both ecosystems: a page is One rule, in `web/src/lib/seo.ts`, decides it, and it covers both ecosystems: a page is
@@ -739,14 +765,27 @@ server {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# The social card, the icons and the manifest. Not fingerprinted, since their names # The favicons and the manifest. Not fingerprinted, since their names are referenced
# are referenced from outside the site, so a week with revalidation rather than a # from outside the site, so a week with revalidation rather than a year of immutability.
# year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ { location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d; expires 7d;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
# The generated social cards (`pnpm og`). The default card keeps a stable name and
# changes with the site's stats, so it gets an hour; every per-mint card carries a
# content hash in its filename and a stale hash is never referenced again, so those
# are immutable. (The hash is also what actually refreshes link previews: the big
# scrapers cache an og:image by URL and ignore these headers.)
location = /og/default.png {
add_header Cache-Control "public, max-age=3600";
}
location /og/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two # Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host. # blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ { location /api/ {
+1 -12
View File
@@ -114,12 +114,6 @@
"card.reviewed": "reviewed {when}", "card.reviewed": "reviewed {when}",
"card.noReviews": "no reviews yet", "card.noReviews": "no reviews yet",
"mint.title": "{name} · Cashu mint reviews", "mint.title": "{name} · Cashu mint reviews",
"mint.description.fallback": "{name} is a Cashu mint at {domain}.",
"mint.description.rated.one": "Rated {rating} out of 5 from {n} review signed on Nostr.",
"mint.description.rated.other": "Rated {rating} out of 5 from {n} reviews signed on Nostr.",
"mint.description.noReviews": "No community reviews yet.",
"mint.description.online": "Currently online.",
"mint.description.unreachable": "Currently unreachable.",
"mint.breadcrumb": "Breadcrumb", "mint.breadcrumb": "Breadcrumb",
"mint.crumbMints": "Cashu mints", "mint.crumbMints": "Cashu mints",
"mint.copyUrl": "Copy mint URL", "mint.copyUrl": "Copy mint URL",
@@ -546,6 +540,7 @@
"seo.mint.titleNoReviews": "{name}: Cashu mint info and reviews | Cashumints.space", "seo.mint.titleNoReviews": "{name}: Cashu mint info and reviews | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: Fedimint federation info and reviews | Cashumints.space", "seo.fedimint.titleNoReviews": "{name}: Fedimint federation info and reviews | Cashumints.space",
"seo.desc.fallback": "Community reviews for {name} ({domain}).", "seo.desc.fallback": "Community reviews for {name} ({domain}).",
"seo.desc.fallbackDomain": "Community reviews for the Cashu mint at {domain}.",
"seo.desc.rated.one": "Rated {rating}/5 from {n} signed Nostr review.", "seo.desc.rated.one": "Rated {rating}/5 from {n} signed Nostr review.",
"seo.desc.rated.other": "Rated {rating}/5 from {n} signed Nostr reviews.", "seo.desc.rated.other": "Rated {rating}/5 from {n} signed Nostr reviews.",
"seo.desc.noReviews": "No reviews yet.", "seo.desc.noReviews": "No reviews yet.",
@@ -557,7 +552,6 @@
"seo.mint.ogAlt.other": "{name}: {rating} stars from {n} reviews on Cashumints.space", "seo.mint.ogAlt.other": "{name}: {rating} stars from {n} reviews on Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} on Cashumints.space", "seo.mint.ogAltNoReviews": "{name} on Cashumints.space",
"nav.home": "Home", "nav.home": "Home",
"mint.description.fallbackDomain": "A Cashu mint running at {domain}.",
"mint.more.title": "More mints", "mint.more.title": "More mints",
"mint.more.all": "See all mints", "mint.more.all": "See all mints",
"nav.fedimints": "Fedimints", "nav.fedimints": "Fedimints",
@@ -578,7 +572,6 @@
"fedimints.announcedNote.other": "{n} of these were announced on Nostr and nothing has confirmed since that they are running. They are listed as announced rather than as online or offline, because neither would be true.", "fedimints.announcedNote.other": "{n} of these were announced on Nostr and nothing has confirmed since that they are running. They are listed as announced rather than as online or offline, because neither would be true.",
"fedimints.noResults": "No federations match. Try a name, or paste a federation id.", "fedimints.noResults": "No federations match. Try a name, or paste a federation id.",
"fedimints.emptyIndex": "No federations have been announced on the relays this site reads. The list fills itself as announcements arrive.", "fedimints.emptyIndex": "No federations have been announced on the relays this site reads. The list fills itself as announcements arrive.",
"fedimint.title": "{name} · Fedimint federation reviews",
"fedimint.crumb": "Fedimints", "fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Breadcrumb", "fedimint.breadcrumb": "Breadcrumb",
"fedimint.unnamed": "Unnamed federation", "fedimint.unnamed": "Unnamed federation",
@@ -611,10 +604,6 @@
"fedimint.more.title": "More federations", "fedimint.more.title": "More federations",
"fedimint.more.all": "See all federations", "fedimint.more.all": "See all federations",
"fedimint.disclaimer": "Joining a federation means trusting its guardians with your money. Ratings are community opinions, and confirmed running means reachable, not trustworthy.", "fedimint.disclaimer": "Joining a federation means trusting its guardians with your money. Ratings are community opinions, and confirmed running means reachable, not trustworthy.",
"fedimint.description.online": "A check confirmed it running.",
"fedimint.description.announced": "No check has confirmed it running.",
"fedimint.description.fallback": "{name} is a Fedimint federation announced on Nostr.",
"fedimint.description.fallbackId": "A Fedimint federation announced on Nostr as {id}.",
"fedimint.module.lightning": "Lightning", "fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "Ecash mint", "fedimint.module.mint": "Ecash mint",
"fedimint.module.wallet": "On-chain wallet", "fedimint.module.wallet": "On-chain wallet",
+1 -12
View File
@@ -118,12 +118,6 @@
"card.reviewed": "reseñado {when}", "card.reviewed": "reseñado {when}",
"card.noReviews": "sin reseñas aún", "card.noReviews": "sin reseñas aún",
"mint.title": "{name} · Reseñas del mint Cashu", "mint.title": "{name} · Reseñas del mint Cashu",
"mint.description.fallback": "{name} es un mint Cashu en {domain}.",
"mint.description.rated.one": "Valorado con {rating} sobre 5 a partir de {n} reseña firmada en Nostr.",
"mint.description.rated.other": "Valorado con {rating} sobre 5 a partir de {n} reseñas firmadas en Nostr.",
"mint.description.noReviews": "Todavía no hay reseñas de la comunidad.",
"mint.description.online": "Ahora mismo en línea.",
"mint.description.unreachable": "Ahora mismo no responde.",
"mint.breadcrumb": "Ruta de navegación", "mint.breadcrumb": "Ruta de navegación",
"mint.crumbMints": "Mints Cashu", "mint.crumbMints": "Mints Cashu",
"mint.copyUrl": "Copiar la URL del mint", "mint.copyUrl": "Copiar la URL del mint",
@@ -550,6 +544,7 @@
"seo.mint.titleNoReviews": "{name}: información y reseñas del mint Cashu | Cashumints.space", "seo.mint.titleNoReviews": "{name}: información y reseñas del mint Cashu | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: información y reseñas de la federación Fedimint | Cashumints.space", "seo.fedimint.titleNoReviews": "{name}: información y reseñas de la federación Fedimint | Cashumints.space",
"seo.desc.fallback": "Reseñas de la comunidad sobre {name} ({domain}).", "seo.desc.fallback": "Reseñas de la comunidad sobre {name} ({domain}).",
"seo.desc.fallbackDomain": "Reseñas de la comunidad sobre el mint Cashu en {domain}.",
"seo.desc.rated.one": "Valorado {rating}/5 en {n} reseña firmada en Nostr.", "seo.desc.rated.one": "Valorado {rating}/5 en {n} reseña firmada en Nostr.",
"seo.desc.rated.other": "Valorado {rating}/5 en {n} reseñas firmadas en Nostr.", "seo.desc.rated.other": "Valorado {rating}/5 en {n} reseñas firmadas en Nostr.",
"seo.desc.noReviews": "Aún sin reseñas.", "seo.desc.noReviews": "Aún sin reseñas.",
@@ -561,7 +556,6 @@
"seo.mint.ogAlt.other": "{name}: {rating} estrellas de {n} reseñas en Cashumints.space", "seo.mint.ogAlt.other": "{name}: {rating} estrellas de {n} reseñas en Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} en Cashumints.space", "seo.mint.ogAltNoReviews": "{name} en Cashumints.space",
"nav.home": "Inicio", "nav.home": "Inicio",
"mint.description.fallbackDomain": "Un mint Cashu que funciona en {domain}.",
"mint.more.title": "Más mints", "mint.more.title": "Más mints",
"mint.more.all": "Ver todos los mints", "mint.more.all": "Ver todos los mints",
"nav.fedimints": "Fedimints", "nav.fedimints": "Fedimints",
@@ -582,7 +576,6 @@
"fedimints.announcedNote.other": "{n} de estas se anunciaron en Nostr y nada ha confirmado desde entonces que estén funcionando. Aparecen como anunciadas, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.", "fedimints.announcedNote.other": "{n} de estas se anunciaron en Nostr y nada ha confirmado desde entonces que estén funcionando. Aparecen como anunciadas, no como en línea ni sin conexión, porque ninguna de las dos sería cierta.",
"fedimints.noResults": "Ninguna federación coincide. Prueba con un nombre, o pega un id de federación.", "fedimints.noResults": "Ninguna federación coincide. Prueba con un nombre, o pega un id de federación.",
"fedimints.emptyIndex": "No se ha anunciado ninguna federación en los relays que lee este sitio. La lista se llena sola según van llegando anuncios.", "fedimints.emptyIndex": "No se ha anunciado ninguna federación en los relays que lee este sitio. La lista se llena sola según van llegando anuncios.",
"fedimint.title": "{name} · Reseñas de la federación Fedimint",
"fedimint.crumb": "Fedimints", "fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Ruta de navegación", "fedimint.breadcrumb": "Ruta de navegación",
"fedimint.unnamed": "Federación sin nombre", "fedimint.unnamed": "Federación sin nombre",
@@ -615,10 +608,6 @@
"fedimint.more.title": "Más federaciones", "fedimint.more.title": "Más federaciones",
"fedimint.more.all": "Ver todas las federaciones", "fedimint.more.all": "Ver todas las federaciones",
"fedimint.disclaimer": "Unirte a una federación significa confiar tu dinero a sus guardianes. Las valoraciones son opiniones de la comunidad, y confirmada en funcionamiento significa accesible, no fiable.", "fedimint.disclaimer": "Unirte a una federación significa confiar tu dinero a sus guardianes. Las valoraciones son opiniones de la comunidad, y confirmada en funcionamiento significa accesible, no fiable.",
"fedimint.description.online": "Una comprobación confirmó que está funcionando.",
"fedimint.description.announced": "Ninguna comprobación ha confirmado que esté funcionando.",
"fedimint.description.fallback": "{name} es una federación Fedimint anunciada en Nostr.",
"fedimint.description.fallbackId": "Una federación Fedimint anunciada en Nostr como {id}.",
"fedimint.module.lightning": "Lightning", "fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "Mint de ecash", "fedimint.module.mint": "Mint de ecash",
"fedimint.module.wallet": "Monedero on-chain", "fedimint.module.wallet": "Monedero on-chain",
+1 -12
View File
@@ -114,12 +114,6 @@
"card.reviewed": "laatste review {when}", "card.reviewed": "laatste review {when}",
"card.noReviews": "nog geen reviews", "card.noReviews": "nog geen reviews",
"mint.title": "{name} · Reviews van deze Cashu mint", "mint.title": "{name} · Reviews van deze Cashu mint",
"mint.description.fallback": "{name} is een Cashu mint op {domain}.",
"mint.description.rated.one": "Beoordeeld met {rating} van de 5 op basis van {n} review, ondertekend op Nostr.",
"mint.description.rated.other": "Beoordeeld met {rating} van de 5 op basis van {n} reviews, ondertekend op Nostr.",
"mint.description.noReviews": "Nog geen reviews uit de community.",
"mint.description.online": "Nu online.",
"mint.description.unreachable": "Nu onbereikbaar.",
"mint.breadcrumb": "Kruimelpad", "mint.breadcrumb": "Kruimelpad",
"mint.crumbMints": "Cashu mints", "mint.crumbMints": "Cashu mints",
"mint.copyUrl": "Mint-URL kopiëren", "mint.copyUrl": "Mint-URL kopiëren",
@@ -546,6 +540,7 @@
"seo.mint.titleNoReviews": "{name}: info en reviews over deze Cashu-mint | Cashumints.space", "seo.mint.titleNoReviews": "{name}: info en reviews over deze Cashu-mint | Cashumints.space",
"seo.fedimint.titleNoReviews": "{name}: info en reviews over deze Fedimint-federatie | Cashumints.space", "seo.fedimint.titleNoReviews": "{name}: info en reviews over deze Fedimint-federatie | Cashumints.space",
"seo.desc.fallback": "Reviews uit de gemeenschap over {name} ({domain}).", "seo.desc.fallback": "Reviews uit de gemeenschap over {name} ({domain}).",
"seo.desc.fallbackDomain": "Reviews uit de gemeenschap over de Cashu-mint op {domain}.",
"seo.desc.rated.one": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende review.", "seo.desc.rated.one": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende review.",
"seo.desc.rated.other": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende reviews.", "seo.desc.rated.other": "Beoordeeld met {rating}/5 uit {n} op Nostr ondertekende reviews.",
"seo.desc.noReviews": "Nog geen reviews.", "seo.desc.noReviews": "Nog geen reviews.",
@@ -557,7 +552,6 @@
"seo.mint.ogAlt.other": "{name}: {rating} sterren uit {n} reviews op Cashumints.space", "seo.mint.ogAlt.other": "{name}: {rating} sterren uit {n} reviews op Cashumints.space",
"seo.mint.ogAltNoReviews": "{name} op Cashumints.space", "seo.mint.ogAltNoReviews": "{name} op Cashumints.space",
"nav.home": "Home", "nav.home": "Home",
"mint.description.fallbackDomain": "Een Cashu-mint die draait op {domain}.",
"mint.more.title": "Meer mints", "mint.more.title": "Meer mints",
"mint.more.all": "Bekijk alle mints", "mint.more.all": "Bekijk alle mints",
"nav.fedimints": "Fedimints", "nav.fedimints": "Fedimints",
@@ -578,7 +572,6 @@
"fedimints.announcedNote.other": "{n} hiervan zijn op Nostr aangekondigd en niets heeft sindsdien bevestigd dat ze draaien. Ze staan er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.", "fedimints.announcedNote.other": "{n} hiervan zijn op Nostr aangekondigd en niets heeft sindsdien bevestigd dat ze draaien. Ze staan er als aangekondigd, niet als online of offline, want geen van beide zou kloppen.",
"fedimints.noResults": "Geen enkele federatie komt overeen. Probeer een naam, of plak een federatie-id.", "fedimints.noResults": "Geen enkele federatie komt overeen. Probeer een naam, of plak een federatie-id.",
"fedimints.emptyIndex": "Er is geen enkele federatie aangekondigd op de relays die deze site leest. De lijst vult zichzelf zodra er aankondigingen binnenkomen.", "fedimints.emptyIndex": "Er is geen enkele federatie aangekondigd op de relays die deze site leest. De lijst vult zichzelf zodra er aankondigingen binnenkomen.",
"fedimint.title": "{name} · Reviews van deze Fedimint-federatie",
"fedimint.crumb": "Fedimints", "fedimint.crumb": "Fedimints",
"fedimint.breadcrumb": "Kruimelpad", "fedimint.breadcrumb": "Kruimelpad",
"fedimint.unnamed": "Naamloze federatie", "fedimint.unnamed": "Naamloze federatie",
@@ -611,10 +604,6 @@
"fedimint.more.title": "Meer federaties", "fedimint.more.title": "Meer federaties",
"fedimint.more.all": "Alle federaties bekijken", "fedimint.more.all": "Alle federaties bekijken",
"fedimint.disclaimer": "Meedoen met een federatie betekent dat je jouw geld toevertrouwt aan haar guardians. Beoordelingen zijn meningen uit de community, en bevestigd draaiend betekent bereikbaar, niet betrouwbaar.", "fedimint.disclaimer": "Meedoen met een federatie betekent dat je jouw geld toevertrouwt aan haar guardians. Beoordelingen zijn meningen uit de community, en bevestigd draaiend betekent bereikbaar, niet betrouwbaar.",
"fedimint.description.online": "Een controle heeft bevestigd dat hij draait.",
"fedimint.description.announced": "Geen enkele controle heeft bevestigd dat hij draait.",
"fedimint.description.fallback": "{name} is een Fedimint-federatie die op Nostr is aangekondigd.",
"fedimint.description.fallbackId": "Een Fedimint-federatie die op Nostr is aangekondigd als {id}.",
"fedimint.module.lightning": "Lightning", "fedimint.module.lightning": "Lightning",
"fedimint.module.mint": "Ecash mint", "fedimint.module.mint": "Ecash mint",
"fedimint.module.wallet": "On-chain wallet", "fedimint.module.wallet": "On-chain wallet",
+9 -3
View File
@@ -57,14 +57,20 @@ interface Props {
schema?: SchemaNode[]; schema?: SchemaNode[];
/** /**
* Social card. A site-relative path, resolved to the absolute URL `og:image` requires. * Social card. A site-relative path, resolved to the absolute URL `og:image` requires.
* Defaults to the shared 1200x630 card. * Defaults to the shared 1200x630 card that `pnpm og` generates.
*/ */
image?: string; image?: string;
/**
* Alt text for the card. Mint pages pass their own ("{name}: 4.9 stars from 54
* reviews…"); everything else gets the sitewide line, matching the default card.
*/
imageAlt?: string;
} }
const { const {
title, description, current, mintCount, ogType = 'website', title, description, current, mintCount, ogType = 'website',
noindex = false, offGraph = false, schema = [], image = OG_IMAGE, noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
imageAlt,
} = Astro.props; } = Astro.props;
/* /*
@@ -217,7 +223,7 @@ const noscriptMotion =
<meta property="og:image" content={imageUrl} /> <meta property="og:image" content={imageUrl} />
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} /> <meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} /> <meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
<meta property="og:image:alt" content={t('seo.ogImageAlt')} /> <meta property="og:image:alt" content={imageAlt ?? t('seo.ogImageAlt')} />
{/* {/*
`summary_large_image`, now that there is an image worth the space. With no `summary_large_image`, now that there is an image worth the space. With no
@@ -227,7 +233,7 @@ const noscriptMotion =
<meta name="twitter:title" content={title} /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} /> <meta name="twitter:image" content={imageUrl} />
<meta name="twitter:image:alt" content={t('seo.ogImageAlt')} /> <meta name="twitter:image:alt" content={imageAlt ?? t('seo.ogImageAlt')} />
{/* {/*
Two faces, and only two: Inter 400 is every paragraph on the site and Space Two faces, and only two: Inter 400 is every paragraph on the site and Space
+34 -14
View File
@@ -15,14 +15,15 @@
* with the visible page is worse than none: it teaches a crawler that neither can be * with the visible page is worse than none: it teaches a crawler that neither can be
* trusted. * trusted.
* *
* Two absences are deliberate: * One absence is deliberate:
* *
* - **No `SearchAction` on `WebSite`.** The search on /mints is an island filtering
* markup already in the page. There is no `?q=` URL to hand a crawler, and declaring
* one would promise a query endpoint that does not exist.
* - **No `Review` nodes.** Review bodies are fetched from Nostr relays in the browser, * - **No `Review` nodes.** Review bodies are fetched from Nostr relays in the browser,
* so they are not in the prerendered HTML. Marking up text a crawler cannot see in * so they are not in the prerendered HTML. Marking up text a crawler cannot see in
* the page is the same mismatch. * the page is the same mismatch.
*
* The `SearchAction` on `WebSite` exists because /mints honours `?q=`: the search
* island reads the parameter on load (it is how the home page's quick filters deep
* link), so the URL the graph hands a crawler really does open a filtered list.
*/ */
/** A node in the graph. Loose by design: schema.org shapes vary per type. */ /** A node in the graph. Loose by design: schema.org shapes vary per type. */
@@ -118,6 +119,19 @@ export function siteNodes(o: {
url: `${o.origin}/`, url: `${o.origin}/`,
inLanguage: o.locale, inLanguage: o.locale,
publisher: { '@id': orgId(o.origin) }, publisher: { '@id': orgId(o.origin) },
/*
* The English /mints URL for every locale: hreflang on the target page routes a
* visitor on from there, and one canonical action beats three that differ only
* by prefix.
*/
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${o.origin}/mints?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
}, },
]; ];
} }
@@ -195,16 +209,20 @@ export function itemListNode(o: {
/** /**
* A mint, as the thing its page is about. * A mint, as the thing its page is about.
* *
* `Service` with an explicit `serviceType`, which is what a Cashu mint is: something a * `Service` is the honest base type: something a third party runs and you can use.
* third party runs and you can use. Not a `Product` (nothing is sold here), not an * Not an `Organization` — the operator is usually anonymous, this site knows nothing
* `Organization` (the operator is usually anonymous and this site knows nothing about * about them beyond a URL, and Google shows no review snippets for reviews an
* them beyond a URL), not a `SoftwareApplication` (the software is named in `version`, * Organization node carries about itself. Not a `SoftwareApplication` — the software
* but this page is about one deployment, not about cdk-mintd in general). * is named in `version`, but this page is about one deployment.
* *
* `Service` is not on Google's review-snippet list, so this will not draw stars in a * `Service` alone is not on Google's review-snippet list, so a rated mint *also*
* search result. That is the accepted trade: `Product` would be eligible and would also * carries `Product` (a dual-typed node), which is the eligible type. The reviews
* be a lie about what a custodial mint is, on a site whose whole premise is not * behind the `AggregateRating` are third-party Nostr events about someone else's
* overclaiming. * mint, not the site reviewing itself, so the self-serving-review policy is not the
* problem here — but review-snippet eligibility remains entirely at Google's
* discretion, which is why the `Product` half sits behind `SEO_PRODUCT_JSONLD`
* (`site.ts`) and can be switched off without touching a template. With the flag off,
* or with no ratings to carry, the node is exactly the plain `Service` it always was.
*/ */
export function mintServiceNode(o: { export function mintServiceNode(o: {
canonical: string; canonical: string;
@@ -213,9 +231,11 @@ export function mintServiceNode(o: {
mintUrl: string; mintUrl: string;
icon?: string | null; icon?: string | null;
rating: { value: number; ratingCount: number; reviewCount: number } | null; rating: { value: number; ratingCount: number; reviewCount: number } | null;
/** Add the review-snippet-eligible `Product` type. Only honoured when rated. */
product?: boolean;
}): SchemaNode { }): SchemaNode {
const node: SchemaNode = { const node: SchemaNode = {
'@type': 'Service', '@type': o.product && o.rating ? ['Product', 'Service'] : 'Service',
'@id': `${o.canonical}#mint`, '@id': `${o.canonical}#mint`,
name: text(o.name, 120), name: text(o.name, 120),
description: text(o.description, 300), description: text(o.description, 300),
+15 -2
View File
@@ -17,11 +17,24 @@ export const GITHUB_URL = 'https://github.com/Azzamo-net/cashumints.space';
export const AZZAMO_URL = 'https://azzamo.net'; export const AZZAMO_URL = 'https://azzamo.net';
/** /**
* The default social card, served from `public/`. * The default social card: brand, tagline and the network's stats, rendered by
* `pnpm og` into `public/og/` alongside the per-mint cards. Home, the list pages and
* any mint whose own card failed to render all point here.
* *
* Relative on purpose: `Base.astro` resolves it against `Astro.site`, because an * Relative on purpose: `Base.astro` resolves it against `Astro.site`, because an
* `og:image` that is not absolute is not a weaker signal, it is an ignored one. * `og:image` that is not absolute is not a weaker signal, it is an ignored one.
* Stable filename, unlike the hashed per-mint cards, so it is served with
* `Cache-Control: max-age=3600` rather than immutable (see README).
*/ */
export const OG_IMAGE = '/og.png'; export const OG_IMAGE = '/og/default.png';
export const OG_IMAGE_WIDTH = 1200; export const OG_IMAGE_WIDTH = 1200;
export const OG_IMAGE_HEIGHT = 630; export const OG_IMAGE_HEIGHT = 630;
/**
* Whether a rated mint's JSON-LD also carries the review-snippet-eligible `Product`
* type (see `mintServiceNode` in schema.ts for the reasoning). On by default; set
* `SEO_PRODUCT_JSONLD=0` to fall back to the plain `Service` node on the next build.
* An env flag rather than a source edit, so the call can be reversed without a code
* change if Google's treatment of self-collected review markup shifts.
*/
export const PRODUCT_JSONLD = (process.env['SEO_PRODUCT_JSONLD'] ?? '1') !== '0';
@@ -34,6 +34,7 @@ import { ogImageFor } from '../../../lib/og';
import { fedimintSubject } from '../../../lib/review-subject'; import { fedimintSubject } from '../../../lib/review-subject';
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema'; import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo'; import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { PRODUCT_JSONLD } from '../../../lib/site';
import { bannerHtml } from '../../../lib/mint-state'; import { bannerHtml } from '../../../lib/mint-state';
import { localModuleName, localNetworkName, moduleRowsHtml, otherModules } from '../../../lib/fedimint-ui'; import { localModuleName, localNetworkName, moduleRowsHtml, otherModules } from '../../../lib/fedimint-ui';
import { iconGradient, initials, transitionName, truncateMiddle } from '../../../lib/format'; import { iconGradient, initials, transitionName, truncateMiddle } from '../../../lib/format';
@@ -197,6 +198,7 @@ const schema = [
canonical, canonical,
name: titleName, name: titleName,
description, description,
product: PRODUCT_JSONLD,
/* /*
* The invite code stands in for the service's address, because it is the address: * The invite code stands in for the service's address, because it is the address:
* it is the string a wallet is given to reach this federation. A federation with no * it is the string a wallet is given to reach this federation. A federation with no
@@ -217,11 +219,13 @@ const schema = [
--- ---
<Base <Base
title={t('fedimint.title', { name: titleName })} title={title}
description={description} description={description}
current="fedimints" current="fedimints"
noindex={!indexable} noindex={!indexable}
schema={schema} schema={schema}
image={ogImage ?? undefined}
imageAlt={imageAlt}
> >
<div class="page"> <div class="page">
<nav class="crumbs" aria-label={t('fedimint.breadcrumb')}> <nav class="crumbs" aria-label={t('fedimint.breadcrumb')}>
+7 -1
View File
@@ -15,6 +15,7 @@ import { ogImageFor } from '../../../lib/og';
import { mintSubject } from '../../../lib/review-subject'; import { mintSubject } from '../../../lib/review-subject';
import { breadcrumbNode, mintServiceNode } from '../../../lib/schema'; import { breadcrumbNode, mintServiceNode } from '../../../lib/schema';
import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo'; import { clampWords, cleanMintDescription, isIndexableMint, nameCollisions, seoName } from '../../../lib/seo';
import { PRODUCT_JSONLD } from '../../../lib/site';
import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state'; import { bannerHtml, limitsHtml, localNutName, nutRowsHtml } from '../../../lib/mint-state';
import { import {
displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle, displayDomain, iconGradient, initials, shortNpub, transitionName, truncateMiddle,
@@ -185,9 +186,13 @@ const title =
* the plain status, and the limits are only quoted for a mint that can actually take * the plain status, and the limits are only quoted for a mint that can actually take
* a deposit. * a deposit.
*/ */
// A mint with no name of its own (titleName fell back to the domain) gets the
// domain-only sentence, not "Community reviews for {domain} ({domain})".
const lead = const lead =
cleanMintDescription(mint.description) ?? cleanMintDescription(mint.description) ??
t('seo.desc.fallback', { name: titleName === domain ? name : titleName, domain }); (titleName === domain
? t('seo.desc.fallbackDomain', { domain })
: t('seo.desc.fallback', { name: titleName, domain }));
const ratingPart = hasRating const ratingPart = hasRating
? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count }) ? t('seo.desc.rated', { rating: f.decimal(mint.rating_avg!), n: mint.review_count })
@@ -251,6 +256,7 @@ const schema = [
name: titleName, name: titleName,
description, description,
mintUrl: mint.url, mintUrl: mint.url,
product: PRODUCT_JSONLD,
icon: icon ? new URL(icon, Astro.site).href : null, icon: icon ? new URL(icon, Astro.site).href : null,
/* /*
* Only when reviews actually carried ratings. `ratedCount` is what produced the * Only when reviews actually carried ratings. `ratedCount` is what produced the