diff --git a/web/astro.config.mjs b/web/astro.config.mjs
index a921ef1..8f3e1ea 100644
--- a/web/astro.config.mjs
+++ b/web/astro.config.mjs
@@ -10,6 +10,11 @@ import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://substandard.blog",
output: "static",
+ // standard.site document records claim exact addresses (siteUrl +
+ // pathPrefix + slug, no slash — see web/sequoia.json). With a trailing
+ // slash the canonical would be /posts/x/ while the record says /posts/x,
+ // and the two would stop naming the same page.
+ trailingSlash: "never",
server: {
host: "127.0.0.1",
},
diff --git a/web/src/content.config.ts b/web/src/content.config.ts
new file mode 100644
index 0000000..8f1085a
--- /dev/null
+++ b/web/src/content.config.ts
@@ -0,0 +1,32 @@
+import { defineCollection } from "astro:content";
+import { glob } from "astro/loaders";
+// Not astro:content's re-export, which is deprecated.
+import { z } from "astro/zod";
+
+/**
+ * Updates are markdown files; the schema keeps a frontmatter typo from
+ * shipping as a page with no date on it.
+ *
+ * The field names match what standard.site publishing tools (sequoia) read:
+ * title, description, publishDate, tags, draft. atUri is the reverse
+ * direction — sequoia writes the document record's at:// URI back into the
+ * frontmatter once a post has one, and the layout turns it into a
+ * rel="site.standard.document" link tag.
+ */
+const posts = defineCollection({
+ loader: glob({ base: "./src/content/posts", pattern: "**/*.md" }),
+ schema: z.object({
+ title: z.string(),
+ description: z.string(),
+ publishDate: z.coerce.date(),
+ updatedDate: z.coerce.date().optional(),
+ tags: z.array(z.string()).default([]),
+ draft: z.boolean().default(false),
+
+ // Absent until published; a post without one renders no
+ // site.standard.document link rather than an empty href.
+ atUri: z.string().startsWith("at://").optional(),
+ }),
+});
+
+export const collections = { posts };
diff --git a/web/src/content/posts/hello-world.md b/web/src/content/posts/hello-world.md
new file mode 100644
index 0000000..4c42b34
--- /dev/null
+++ b/web/src/content/posts/hello-world.md
@@ -0,0 +1,8 @@
+---
+title: PLACEHOLDER-WIDDERSHINS-FIRST-POST-TITLE
+description: PLACEHOLDER-BORBORYGMUS-FIRST-POST-DESCRIPTION
+publishDate: 2026-08-08
+tags: [meta]
+---
+
+PLACEHOLDER-SUSURRUS-FIRST-POST-BODY
diff --git a/web/src/layouts/Layout.astro b/web/src/layouts/Layout.astro
index b644085..988f11a 100644
--- a/web/src/layouts/Layout.astro
+++ b/web/src/layouts/Layout.astro
@@ -5,9 +5,13 @@ import PostHog from "../components/PostHog.astro";
interface Props {
title: string;
description: string;
+ /** "article" for a post, so unfurlers stop calling it a site. */
+ ogType?: "website" | "article";
+ /** AT-URI of the record this page was rendered from, if it has one. */
+ atUri?: string;
}
-const { title, description } = Astro.props;
+const { title, description, ogType = "website", atUri } = Astro.props;
// Unfurl crawlers resolve nothing relative, so these must be absolute.
const canonical = new URL(Astro.url.pathname, Astro.site);
@@ -22,7 +26,13 @@ const card = new URL("/og.png", Astro.site);
{title}
-
+ {
+ /* The canonical link's ATProto counterpart. Only a page with a record
+ carries it: a verifier resolves the href, so an empty one is worse
+ than a missing tag. */
+ }
+ {atUri && }
+
@@ -32,6 +42,16 @@ const card = new URL("/og.png", Astro.site);
{/* Twitter falls back to og:* for everything but the card type. */}
+ {
+ /* Feed autodiscovery, on every page: a subscribe button asks whatever
+ address it was handed, not just /posts. */
+ }
+
diff --git a/web/src/pages/atom.xml.ts b/web/src/pages/atom.xml.ts
new file mode 100644
index 0000000..2573d9a
--- /dev/null
+++ b/web/src/pages/atom.xml.ts
@@ -0,0 +1,98 @@
+/**
+ * The updates feed.
+ *
+ * Hand-written rather than pulled from a package: the whole of Atom this
+ * needs is one entry per post, the frontmatter already carries every field,
+ * and a feed is a thing you have to be able to read to trust.
+ *
+ * Atom rather than RSS because the collection's dates map onto it exactly —
+ * `published` never moves and `updated` is what `updatedDate` means — and
+ * because every entry must carry an id, so a reader can tell a retitled post
+ * from a new one.
+ *
+ * Every URL is absolute against `site` in astro.config.mjs: a feed is read
+ * somewhere else by definition, and a relative href in one resolves against
+ * the reader, which is nowhere.
+ */
+
+import type { APIContext } from "astro";
+import { getCollection } from "astro:content";
+
+/** Where readers subscribe. Also spelled out in Layout.astro's . */
+export const FEED_PATH = "/atom.xml";
+
+// The five characters that are not text in XML. Everything below goes
+// through here — a post title is written by a human in a markdown file, and
+// an ampersand in one would otherwise end the document at that byte.
+function xml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export async function GET(context: APIContext): Promise {
+ const site = context.site;
+ if (!site) throw new Error("astro.config.mjs has no site; the feed needs it");
+
+ const url = (path: string): string => new URL(path, site).href;
+
+ // The same rule /posts uses, so a reader and a visitor never disagree
+ // about what has been posted.
+ const posts = (
+ await getCollection(
+ "posts",
+ ({ data }) => import.meta.env.DEV || !data.draft,
+ )
+ ).sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
+
+ const stamp = (date: Date): string => date.toISOString();
+ const changed = (post: (typeof posts)[number]): Date =>
+ post.data.updatedDate ?? post.data.publishDate;
+
+ // The feed's own timestamp is the newest post's, never the build's: a feed
+ // stamped at build time reports news on a day nothing was written. With no
+ // posts, the epoch is the honest answer.
+ const updated = posts.length ? changed(posts[0]!) : new Date(0);
+
+ const entries = posts.map((post) => {
+ const href = url(`/posts/${post.id}`);
+ return [
+ " ",
+ ` ${xml(post.data.title)}`,
+ ` `,
+ // The address is the id: stable for as long as the post is at it,
+ // which is what trailingSlash: "never" and the canonical link already
+ // promise everywhere else.
+ ` ${xml(href)}`,
+ ` ${stamp(post.data.publishDate)}`,
+ ` ${stamp(changed(post))}`,
+ ` ${xml(post.data.description)}`,
+ ...post.data.tags.map((tag) => ` `),
+ " ",
+ ].join("\n");
+ });
+
+ const document = [
+ '',
+ '',
+ // The same string Layout.astro's autodiscovery link carries.
+ " substandard updates",
+ ` ${xml(url(FEED_PATH))}`,
+ ` ${stamp(updated)}`,
+ // rel="self" is how a reader handed this document knows where to fetch
+ // it again; the alternate is where a human should be sent.
+ ` `,
+ ` `,
+ " substandard",
+ ...entries,
+ "",
+ "",
+ ].join("\n");
+
+ return new Response(document, {
+ headers: { "content-type": "application/atom+xml; charset=utf-8" },
+ });
+}
diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro
index f0a05f4..d0b5120 100644
--- a/web/src/pages/index.astro
+++ b/web/src/pages/index.astro
@@ -1,17 +1,30 @@
---
-import { readFileSync } from "node:fs";
+import { getCollection } from "astro:content";
import Layout from "../layouts/Layout.astro";
+// The generated wordmark hardcodes its ink; inlined, the theme's colors
+// take over (see .hero-wordmark) so dark mode doesn't swallow it. ?raw is
+// resolved from this file's source location at bundle time, unlike a
+// readFileSync against import.meta.url, which moves with --outDir.
+import wordmark from "../../../brand/wordmark.svg?raw";
+
const CWS_URL =
"https://chromewebstore.google.com/detail/degljbilkggdpbobomfbgnellecgbkjj";
const ATMOSPHERE_URL = "https://atmosphereaccount.com/";
-// The generated wordmark hardcodes its ink; inlined, the theme's colors
-// take over (see .hero-wordmark) so dark mode doesn't swallow it.
-const wordmark = readFileSync(
- new URL("../../../brand/wordmark.svg", import.meta.url),
- "utf8",
-);
+// The homepage is the publication standard.site points at, so it carries a
+// short feed of the newest updates; /posts has the whole backlog.
+const PREVIEW_COUNT = 3;
+const updates = (
+ await getCollection("posts", ({ data }) => import.meta.env.DEV || !data.draft)
+)
+ .sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime())
+ .slice(0, PREVIEW_COUNT);
+
+const day = new Intl.DateTimeFormat("en", {
+ dateStyle: "long",
+ timeZone: "UTC",
+});
---
+
+
+
+
diff --git a/web/src/pages/posts/index.astro b/web/src/pages/posts/index.astro
new file mode 100644
index 0000000..a382426
--- /dev/null
+++ b/web/src/pages/posts/index.astro
@@ -0,0 +1,53 @@
+---
+import { getCollection } from "astro:content";
+import Layout from "../../layouts/Layout.astro";
+
+// Drafts render in dev and are not built. The check is on
+// import.meta.env.DEV rather than a flag, so there is no way to ship one by
+// forgetting to turn something back on.
+const posts = (
+ await getCollection("posts", ({ data }) => import.meta.env.DEV || !data.draft)
+).sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
+
+const day = new Intl.DateTimeFormat("en", {
+ dateStyle: "long",
+ timeZone: "UTC",
+});
+---
+
+
+
+