first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+329
View File
@@ -0,0 +1,329 @@
---
import { ClientRouter } from 'astro:transitions';
import Topbar from '../components/Topbar.astro';
import Footer from '../components/Footer.astro';
import LoginDialog from '../components/LoginDialog.astro';
import { alternates, clientCatalog, localeInfo, localeUrl, LOCALES, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
import {
serializeGraph, siteNodes, webPageNode, type SchemaNode,
} from '../lib/schema';
import {
AZZAMO_URL, GITHUB_URL, OG_IMAGE, OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH, SITE_NAME,
} from '../lib/site';
/*
* The two faces the first paint needs, imported so the preload points at the same
* fingerprinted URL the stylesheet asks for. A hand-written `/fonts/...` path would
* miss the hash, and the browser would download the file twice.
*/
import interLatin from '../assets/fonts/inter-latin.woff2?url';
import groteskLatin from '../assets/fonts/space-grotesk-latin.woff2?url';
import '../styles/global.css';
interface Props {
title: string;
description: string;
current?: 'mints' | 'reviews' | 'wallets' | 'about';
mintCount?: number;
ogType?: string;
/**
* A real page that should not be in the index.
*
* It keeps its canonical and its full alternate set, because it is still a page and
* still one of three translations of itself; it just carries `noindex`, and the
* sitemap leaves it out. Used by mint pages the site has never reached and nobody has
* reviewed — see `lib/seo.ts`. Not the same thing as `offGraph` below, and the
* difference matters: dropping the canonical from a page that exists tells a crawler
* the URL is not canonical for anything, which is a different and wrong claim.
*/
noindex?: boolean;
/**
* A page that is not a page: the 404.
*
* It gets `<html lang>` like everything else, because a screen reader still has to
* pronounce it, but no canonical, no alternates and no sitemap entry. A 404 is what a
* server hands back for a URL that does not exist, so there is nothing for it to be
* the canonical version of and nothing for it to be an alternate of. Giving it an
* alternate set would put three non-pages into the graph and claim they were
* translations of each other. Implies `noindex`.
*/
offGraph?: boolean;
/**
* Extra JSON-LD for this page, appended to the sitewide nodes.
*
* Pages contribute nodes; only this layout serialises them, so a page cannot emit a
* second `application/ld+json` block or assign a clashing `@id`.
*/
schema?: SchemaNode[];
/**
* Social card. A site-relative path, resolved to the absolute URL `og:image` requires.
* Defaults to the shared 1200x630 card.
*/
image?: string;
}
const {
title, description, current, mintCount, ogType = 'website',
noindex = false, offGraph = false, schema = [], image = OG_IMAGE,
} = Astro.props;
/*
* Both halves of the page's identity, from one place.
*
* `locale` is which language this is, `path` is which page it is with the language
* taken off: `/mint/kashu.me` for all three of /mint/kashu.me, /es/mint/kashu.me and
* /nl/mint/kashu.me. That shared path is what makes the three versions alternates of
* each other, and every URL below is built from it.
*/
const { locale, path } = pageLocale(Astro);
const t = useI18n(locale);
const info = localeInfo(locale);
const site = Astro.site ?? new URL('https://cashumints.space');
/*
* Canonical points at this locale's own URL, never at English.
*
* A Spanish page canonicalising to the English one would be telling a crawler the
* Spanish page is a duplicate that should not be indexed, which is the exact opposite
* of the point. Self-canonical plus a reciprocal hreflang set is the pair that says
* "these are the same page in different languages", and it only works if both are right.
*/
const canonical = localeUrl(path, locale, site);
/*
* The alternate set: every locale's version of this page, plus x-default.
*
* Every locale emits the identical list, itself included. That reciprocity is the part
* crawlers verify: a set whose members do not all point back at each other is discarded
* whole, so a partial set is worth less than none. `x-default` is the English URL,
* because that is what a visitor whose language we do not have actually gets.
*/
const alternateLinks = alternates(path, site);
const ogAlternates = LOCALES.filter((l) => l.code !== locale).map((l) => l.og);
/**
* Only the strings islands render, only in this language.
*
* Emitted as JSON in the body rather than imported by the islands, which is what keeps
* a Spanish page from shipping English and Dutch: the island chunks are shared across
* every locale, so anything imported into one is downloaded by all of them. This
* 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');
/**
* The social card, absolute.
*
* `og:image` must be an absolute URL: every consumer that matters rejects a relative
* one silently, which looks exactly like having no card at all. `new URL` accepts both
* a path and an already-absolute value, so a per-mint card later is a one-line change
* at the call site.
*/
const imageUrl = new URL(image, site).href;
/**
* This page's JSON-LD, assembled here and nowhere else.
*
* The sitewide nodes, the page node, then whatever the page itself contributed. A 404
* gets none of it: there is no page for the graph to be about.
*
* Serialising in one place is what guarantees one block per document. A component that
* emitted its own would be a second `@id` namespace nobody is reconciling.
*/
const graph = offGraph
? []
: [
...siteNodes({
origin: site.origin,
name: SITE_NAME,
description: t('seo.orgDescription'),
locale: info.code,
logo: new URL('/icon-512.png', site).href,
sameAs: [GITHUB_URL, AZZAMO_URL],
}),
webPageNode({
origin: site.origin,
canonical,
title,
description,
locale: info.code,
image: imageUrl,
}),
...schema,
];
/**
* Entrance animations start from a zeroed state, and the `.in` class that ends them
* is added by JavaScript. With JavaScript off nobody ever adds it, so every zeroed
* state is cancelled here instead: the element itself, and the four things inside one
* that wait for their card to arrive (bar fills, star rows, the uptime line).
*
* Anything new that hides itself until `.in` belongs in this list too.
*
* Emitted as raw HTML because Astro hoists any <style> it parses out of the template
* and into the page stylesheet, which would cancel the entrances for everyone.
*/
const noscriptMotion =
'<noscript><style>' +
'[data-reveal]{opacity:1!important;transform:none!important}' +
'[data-reveal] .mc-bar-fill,[data-reveal] .dist-fill,[data-reveal] .rc-star' +
'{opacity:1!important;transform:none!important}' +
'[data-reveal] .spark-line{stroke-dashoffset:0!important}' +
'[data-reveal] .spark-area{opacity:1!important}' +
'</style></noscript>';
---
<!doctype html>
<html lang={info.code}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
{!offGraph && <link rel="canonical" href={canonical} />}
{/*
The reciprocal set. Absolute URLs, because a relative hreflang is not a weaker
signal, it is an ignored one.
*/}
{
!offGraph &&
alternateLinks.map((alternate) => (
<link rel="alternate" hreflang={alternate.hreflang} href={alternate.href} />
))
}
{/*
`follow`, not `none`: the links out of an unindexed page are how a crawler still
reaches the pages that are indexed, and how someone on a 404 gets un-lost.
*/}
{(noindex || offGraph) && <meta name="robots" content="noindex, follow" />}
<Fragment set:html={noscriptMotion} />
{/* Same-site navigation cross-fades, and a mint card morphs into its mint page. */}
<ClientRouter />
<meta property="og:type" content={ogType} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content={info.og} />
{ogAlternates.map((og) => <meta property="og:locale:alternate" content={og} />)}
<meta property="og:image" content={imageUrl} />
<meta property="og:image:width" content={String(OG_IMAGE_WIDTH)} />
<meta property="og:image:height" content={String(OG_IMAGE_HEIGHT)} />
<meta property="og:image:alt" content={t('seo.ogImageAlt')} />
{/*
`summary_large_image`, now that there is an image worth the space. With no
`twitter:image` this was a `summary` card showing a blank square.
*/}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={imageUrl} />
<meta name="twitter:image:alt" content={t('seo.ogImageAlt')} />
{/*
Two faces, and only two: Inter 400 is every paragraph on the site and Space
Grotesk 700 is every h1 and the brand mark, so both are needed for the first
paint. Preloading the rest would compete with the stylesheet for bandwidth to
fetch faces most pages never use above the fold.
`crossorigin` is required on a font preload even for a same-origin file. Without
it the preload lands in a different cache partition than the CSS request and the
file is fetched twice.
*/}
<link rel="preload" as="font" type="font/woff2" href={interLatin} crossorigin />
<link rel="preload" as="font" type="font/woff2" href={groteskLatin} crossorigin />
{/*
The moai, in the three shapes browsers actually ask for: an SVG for anything
modern, a 32px ICO for the bare /favicon.ico request crawlers and feed readers
still make, and a 180px PNG for an iOS home screen.
*/}
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#0E0D0B" />
{/*
One block per page, assembled above. `is:inline` keeps Astro from treating it as
a module to bundle, and `serializeGraph` is what makes an operator-written mint
name safe to put inside a script element.
*/}
{graph.length > 0 && (
<script type="application/ld+json" is:inline set:html={serializeGraph(graph)} />
)}
</head>
<body>
{/*
The page's own strings, for the islands. `is:inline` keeps Astro from treating a
JSON payload as a module to bundle, and the `<` escape keeps a translated string
containing markup from closing this tag early.
In the body, not the head: the view transition router replaces the body wholesale
on every navigation, so an island reading this after a swap reads the page it is
actually on. A head script would be kept and would keep answering in the language
the reader arrived in.
*/}
<script is:inline type="application/json" data-i18n set:html={i18nPayload} />
<a class="skip-link" href="#main">{t('nav.skip')}</a>
<Topbar current={current} mintCount={mintCount} />
<main id="main">
<slot />
</main>
<Footer />
{/* Opened from the header and from the review dialog's identity row. */}
<LoginDialog />
<script>
import { armSharedTransitions, initReveal, resetReveal } from '../scripts/reveal';
/*
* Reveal-on-scroll, wired for both kinds of arrival: straight away on a full
* page load (this module is deferred, so the document is already parsed), and
* again on every page the router swaps in. The observer is dropped first, since
* the elements it was watching are about to be replaced.
*/
initReveal();
// The mint card being clicked lends its icon and name to the page it opens.
armSharedTransitions();
document.addEventListener('astro:before-swap', resetReveal);
// Before the browser snapshots the incoming page, not after, or an element
// still waiting to be revealed would be captured invisible by the cross-fade.
document.addEventListener('astro:after-swap', () => initReveal());
document.addEventListener('astro:page-load', () => initReveal());
// "/" focuses the search box on any page that has one. Delegated from the
// document, which survives a view transition, so it is wired exactly once.
document.addEventListener('keydown', (event) => {
if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return;
const active = document.activeElement;
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) return;
const search = document.querySelector<HTMLInputElement>('[data-search-input]');
if (search) {
event.preventDefault();
search.focus();
search.select();
return;
}
// Pages without a search box send you to the index, which has one.
const link = document.querySelector<HTMLAnchorElement>('.topbar-search');
if (link) {
event.preventDefault();
window.location.href = link.href;
}
});
</script>
</body>
</html>