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}
diff --git a/site/src/pages/about/[slug].astro b/site/src/pages/about/[slug].astro
index bf169443..0a1c01f8 100644
--- a/site/src/pages/about/[slug].astro
+++ b/site/src/pages/about/[slug].astro
@@ -10,6 +10,6 @@ export function getStaticPaths() {
const { page } = Astro.props;
---
-
+
diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro
index e8308796..16e96a34 100644
--- a/site/src/pages/about/index.astro
+++ b/site/src/pages/about/index.astro
@@ -5,6 +5,6 @@ import AtmosphereLayout from "../../layouts/AtmosphereLayout.astro";
import SectionIndex from "../../components/SectionIndex.astro";
import { aboutSection } from "../../data/about";
---
-
+
diff --git a/site/src/pages/architecture/[slug].astro b/site/src/pages/architecture/[slug].astro
index 5f0617f4..57dc200b 100644
--- a/site/src/pages/architecture/[slug].astro
+++ b/site/src/pages/architecture/[slug].astro
@@ -10,6 +10,6 @@ export function getStaticPaths() {
const { page } = Astro.props;
---
-
+
diff --git a/site/src/pages/architecture/index.astro b/site/src/pages/architecture/index.astro
index 06b39fef..6171c833 100644
--- a/site/src/pages/architecture/index.astro
+++ b/site/src/pages/architecture/index.astro
@@ -5,6 +5,6 @@ import AtmosphereLayout from "../../layouts/AtmosphereLayout.astro";
import SectionIndex from "../../components/SectionIndex.astro";
import { architectureSection } from "../../data/architecture";
---
-
+
diff --git a/site/src/pages/features/[slug].astro b/site/src/pages/features/[slug].astro
index d43a7c4b..5867f052 100644
--- a/site/src/pages/features/[slug].astro
+++ b/site/src/pages/features/[slug].astro
@@ -10,6 +10,6 @@ export function getStaticPaths() {
const { page } = Astro.props;
---
-
+
diff --git a/site/src/pages/features/index.astro b/site/src/pages/features/index.astro
index 088bbd88..9a86cf81 100644
--- a/site/src/pages/features/index.astro
+++ b/site/src/pages/features/index.astro
@@ -5,6 +5,6 @@ import AtmosphereLayout from "../../layouts/AtmosphereLayout.astro";
import SectionIndex from "../../components/SectionIndex.astro";
import { featuresSection } from "../../data/features";
---
-
+
diff --git a/site/src/pages/get-started.astro b/site/src/pages/get-started.astro
index 726a5669..8d5ee5d9 100644
--- a/site/src/pages/get-started.astro
+++ b/site/src/pages/get-started.astro
@@ -2,7 +2,7 @@
import BaseLayout from "../layouts/BaseLayout.astro";
import { getStarted } from "../data/get-started";
---
-
+
get started
{getStarted.intro}
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 64b99a51..f8acd231 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -33,9 +33,10 @@
// left off, however long it drifted (see anneal.rs's
// `scrolling_back_up_out_of_free_run_reproduces_the_identical_deterministic_frame`
// and `drifting_from_a_mid_scroll_sweep_moves_without_disturbing_the_replay`).
-import BrandHead from "../components/BrandHead.astro";
+import PageHead from "../components/PageHead.astro";
import { headerNav, footerNav } from "../config/nav";
import { landingBeats } from "../data/landing-beats";
+import { siteDescription, siteName } from "../data/meta";
// The hero and the footer beat are placed by the script below, against the
// simulation's camera; every beat between them is placed down the scroller
@@ -54,8 +55,7 @@ const scrollerHeightVh = scrollBeats.length * BEAT_SPACING_VH + SCROLLER_TAIL_VH
- did.bot
-
+