From 34844affbb3fe3e810b449360d90fc6ecf65f7fb Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 4 Sep 2026 10:30:46 -0400 Subject: [PATCH] feat(site): tell an unfurler what a page is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page now carries a title, a description, a canonical address and the og: tags a link card is built from, written by one component. A page's description is the line it already shows — a section's intro, a subpage's lede, the landing page's hero beat — so a card and a page cannot disagree. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I6fbd8cbd82e2bd6473229407c84ca488d11c6a75 --- site/src/components/BrandHead.astro | 20 -------- site/src/components/PageHead.astro | 59 ++++++++++++++++++++++++ site/src/data/meta.ts | 43 +++++++++++++++++ site/src/layouts/AtmosphereLayout.astro | 7 ++- site/src/layouts/BaseLayout.astro | 22 +++++++-- site/src/pages/404.astro | 2 +- site/src/pages/about/[slug].astro | 2 +- site/src/pages/about/index.astro | 2 +- site/src/pages/architecture/[slug].astro | 2 +- site/src/pages/architecture/index.astro | 2 +- site/src/pages/features/[slug].astro | 2 +- site/src/pages/features/index.astro | 2 +- site/src/pages/get-started.astro | 2 +- site/src/pages/index.astro | 6 +-- site/src/pages/privacy.astro | 2 +- site/src/pages/terms.astro | 2 +- 16 files changed, 138 insertions(+), 39 deletions(-) delete mode 100644 site/src/components/BrandHead.astro create mode 100644 site/src/components/PageHead.astro create mode 100644 site/src/data/meta.ts diff --git a/site/src/components/BrandHead.astro b/site/src/components/BrandHead.astro deleted file mode 100644 index 48669799..00000000 --- a/site/src/components/BrandHead.astro +++ /dev/null @@ -1,20 +0,0 @@ ---- -// Every tag that points at a brand image, in one place, so a page -// picks up the whole set by using a layout rather than by remembering nine -// tags. The files themselves are generated — `scripts/build-brand.sh`, from -// `crates/didbot-brand` — and committed under `public/`, so these are plain -// paths and never imports. -// -// The landing page is its own document (see site/README.md) and imports this -// the same way, which is the only reason it has a favicon at all. -// `Astro.site` is astro.config.mjs's own `site`, so the absolute URL an -// unfurler needs is not a second copy of the domain. -const card = new URL("/brand/social-card.png", Astro.site); ---- - - - - - - - diff --git a/site/src/components/PageHead.astro b/site/src/components/PageHead.astro new file mode 100644 index 00000000..2577f76b --- /dev/null +++ b/site/src/components/PageHead.astro @@ -0,0 +1,59 @@ +--- +// Everything in a page's that is not charset or viewport: its title, +// its description, its canonical address, and the tags a link unfurler +// reads to build a card. One component, so a page gets the whole set by +// using a layout rather than by remembering a dozen tags, and so there is +// one place to change when a tag is wrong everywhere at once. +// +// The brand files it points at are generated — `scripts/build-brand.sh`, +// from `crates/didbot-brand` — and committed under `public/`, so these are +// plain paths and never imports. +// +// The landing page is its own document (see site/README.md) and uses this +// the same way, which is the only reason it has a favicon at all. +// `Astro.site` is astro.config.mjs's own `site`, so the absolute URLs an +// unfurler needs are not a second copy of the domain. +import { siteName, socialCardAlt } from "../data/meta"; + +interface Props { + /** The whole document title, as the tab and the card both show it. */ + title: string; + /** One line. Already collapsed — see `oneLine` in src/data/meta.ts. */ + description: string; + /** + * False for a page that is not at an address worth pointing at, which is + * the 404 and nothing else: it answers from every missing path, so a + * canonical link from it would name one of them arbitrarily. + */ + indexable?: boolean; +} + +const { title, description, indexable = true } = Astro.props; + +const card = new URL("/brand/social-card.png", Astro.site); +const canonical = new URL(Astro.url.pathname, Astro.site); +--- +{title} + +{indexable + ? + : } + + + + + + + + + +{indexable && } + + + + + + + diff --git a/site/src/data/meta.ts b/site/src/data/meta.ts new file mode 100644 index 00000000..be579345 --- /dev/null +++ b/site/src/data/meta.ts @@ -0,0 +1,43 @@ +// What a link unfurler shows when someone pastes an address from this site: +// the description under the title, and what the social card depicts. +// +// Per-page descriptions are not listed here. A page that already shows a +// written one-liner hands that same string to its layout — a section's +// `intro`, a subpage's `lede`, the landing page's hero beat — so a sentence +// lives in one data file and the embed and the page can never disagree. +// This file holds only the two strings no page has of its own. +import { landingBeats } from "./landing-beats"; + +/** Used as `og:site_name`, and as the suffix on every page's title. */ +export const siteName = "did.bot"; + +/** + * The fallback description, for the pages that carry no one-liner: the + * landing page's own hero beat, on one line. + * + * It is the owner's copy (src/data/landing-beats.ts) rather than a second + * sentence about the same thing. An unfurler that scrapes a page instead of + * reading its tags lands on this text anyway — it is the first prose on the + * front page — so making it the tag too means both routes agree, and the + * eyebrow is included because a scraper drops it and the line reads as a + * fragment without it. + */ +export const siteDescription = oneLine( + `${landingBeats[0].eyebrow ?? ""} ${landingBeats[0].text}`, +); + +/** + * `og:image:alt` for brand/social-card.png. Describes the picture, which is + * not brand voice — see src/data/section.ts on alt text. + */ +export const socialCardAlt = + "The did.bot wordmark, spelled out in lit cells on a lattice of green and black."; + +/** + * A description tag is one line: newlines and runs of spaces in the source + * copy collapse, so a beat written to break across a page does not arrive + * at an unfurler in pieces. + */ +export function oneLine(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} diff --git a/site/src/layouts/AtmosphereLayout.astro b/site/src/layouts/AtmosphereLayout.astro index 88864670..7e7a2bf0 100644 --- a/site/src/layouts/AtmosphereLayout.astro +++ b/site/src/layouts/AtmosphereLayout.astro @@ -16,11 +16,14 @@ import QuietAtmosphere from "../components/QuietAtmosphere.astro"; interface Props { title: string; + /** See BaseLayout: the page's own line, for the description tag. */ + description?: string; + indexable?: boolean; } -const { title } = Astro.props; +const { title, description, indexable } = Astro.props; --- - + diff --git a/site/src/layouts/BaseLayout.astro b/site/src/layouts/BaseLayout.astro index 779395d1..6906109c 100644 --- a/site/src/layouts/BaseLayout.astro +++ b/site/src/layouts/BaseLayout.astro @@ -1,23 +1,37 @@ --- import Footer from "../components/Footer.astro"; -import BrandHead from "../components/BrandHead.astro"; +import PageHead from "../components/PageHead.astro"; import Header from "../components/Header.astro"; +import { oneLine, siteDescription, siteName } from "../data/meta"; import "../styles/global.css"; interface Props { title: string; + /** + * The page's own one line, for the description tag and the link card. + * Hand it the sentence the page already shows — a section's `intro`, a + * subpage's `lede` — rather than writing a second one. Pages with no such + * sentence of their own leave this out and get src/data/meta.ts's + * fallback. + */ + description?: string; + /** Passed through to PageHead; only the 404 sets it. */ + indexable?: boolean; wide?: boolean; } -const { title, wide = false } = Astro.props; +const { title, description, indexable, wide = false } = Astro.props; --- - {title} — did.bot - +
diff --git a/site/src/pages/404.astro b/site/src/pages/404.astro index 84ad804a..3af2eccc 100644 --- a/site/src/pages/404.astro +++ b/site/src/pages/404.astro @@ -2,7 +2,7 @@ import AtmosphereLayout from "../layouts/AtmosphereLayout.astro"; import { notFound } from "../data/not-found"; --- - +

{notFound.heading}

{notFound.body}