From 0cf1f0472a0a80e55d223bbe2cc69346e0501340 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 8 Aug 2026 19:16:02 -0400 Subject: [PATCH] feat: add an updates blog with /posts pages and an atom feed Markdown posts in web/src/content/posts render at /posts/, list at /posts, and preview as a short feed on the homepage. A hand-written atom feed at /atom.xml is autodiscovered from every page. The layout renders a rel="site.standard.document" link when a post's frontmatter carries an atUri, and trailingSlash is pinned to "never" so canonicals match the addresses document records will claim. The wordmark is now inlined via a ?raw import: the old readFileSync against import.meta.url broke under any non-default --outDir. --- web/astro.config.mjs | 5 ++ web/src/content.config.ts | 32 +++++++++ web/src/content/posts/hello-world.md | 8 +++ web/src/layouts/Layout.astro | 24 ++++++- web/src/pages/atom.xml.ts | 98 ++++++++++++++++++++++++++++ web/src/pages/index.astro | 53 +++++++++++++-- web/src/pages/posts/[...slug].astro | 48 ++++++++++++++ web/src/pages/posts/index.astro | 53 +++++++++++++++ web/src/styles/global.css | 65 ++++++++++++++++++ 9 files changed, 377 insertions(+), 9 deletions(-) create mode 100644 web/src/content.config.ts create mode 100644 web/src/content/posts/hello-world.md create mode 100644 web/src/pages/atom.xml.ts create mode 100644 web/src/pages/posts/[...slug].astro create mode 100644 web/src/pages/posts/index.astro 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", +}); ---
+
+

Updates

+ { + updates.length === 0 ? ( +

PLACEHOLDER-APORIA-NO-POSTS-YET

+ ) : ( +
    + {updates.map((post) => ( +
  • + {post.data.title} + +
  • + ))} +
+ ) + } +

+ all updates · + atom feed +

+
+

What is substandard?

A browser extension to help you find standard.site publishers online.

diff --git a/web/src/pages/posts/[...slug].astro b/web/src/pages/posts/[...slug].astro new file mode 100644 index 0000000..b93ce3a --- /dev/null +++ b/web/src/pages/posts/[...slug].astro @@ -0,0 +1,48 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection, render } from "astro:content"; +import Layout from "../../layouts/Layout.astro"; + +export const getStaticPaths = (async () => { + const posts = await getCollection( + "posts", + ({ data }) => import.meta.env.DEV || !data.draft, + ); + return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); +}) satisfies GetStaticPaths; + +const { post } = Astro.props; +const { Content } = await render(post); + +const day = new Intl.DateTimeFormat("en", { + dateStyle: "long", + timeZone: "UTC", +}); +--- + + +
+
+

{post.data.title}

+ + +
+ +

← all updates

+
+
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", +}); +--- + + +
+

updates

+

+ substandard · atom feed +

+
+ +
+ { + posts.length === 0 ? ( +
+

PLACEHOLDER-APORIA-NO-POSTS-YET

+
+ ) : ( + posts.map((post) => ( +
+

+ {post.data.title} +

+ +

{post.data.description}

+
+ )) + ) + } +
+
diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 1c36287..00ec131 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -199,6 +199,71 @@ main li::marker { color: var(--red); } +/* Updates: the homepage preview list, the /posts backlog, and the posts + themselves. Posts reuse the tilted section cards; these are trimmings. */ + +.hero-sub { + margin: 0.75rem 0 0; + color: var(--muted); +} + +/* A post page has no hero above it, so the first card needs its own air. */ +.post-page { + padding-top: 3rem; +} + +.update-list { + list-style: none; + margin: 0.5rem 0 0.75rem; + padding-left: 0; +} + +.update-list li { + margin: 0.4rem 0 0; +} + +.post-card h2 { + display: block; + background: none; + transform: none; +} + +.post-card h2 a { + padding: 0 0.35rem; + background: linear-gradient( + 100deg, + transparent 0.5%, + var(--gold-hl) 3%, + var(--gold-hl) 98%, + transparent 99.5% + ); +} + +.post-meta { + color: var(--muted); + font-size: 0.9rem; +} + +.post-meta > * + * { + margin-left: 0.6rem; +} + +.post-tag, +.post-draft { + padding: 0 0.35rem; + border: 1px solid var(--line); + border-radius: 0.4rem; +} + +.post-draft { + color: var(--red); + border-color: var(--red); +} + +.post-back { + color: var(--muted); +} + /* The footer rule is the mark's own squiggle: four honest humps, then the tail gives out. Masked so it takes the theme's red. */ footer { -- 2.51.2