@@ -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>
|
||||
Reference in New Issue
Block a user