diff --git a/web/scripts/push.sh b/web/scripts/push.sh
index 0d64d86..9a0bc0f 100755
--- a/web/scripts/push.sh
+++ b/web/scripts/push.sh
@@ -50,6 +50,16 @@ while IFS= read -r page; do
--cache-control "no-cache"
done < <(find dist -mindepth 2 -name index.html)
+# The same fix for the standard.site key, which has no extension either and
+# has to arrive as text rather than as a download. Absent until `sequoia init`
+# writes it, so this is a no-op until the publication record exists.
+wellknown="dist/.well-known/site.standard.publication"
+if [ -f "$wellknown" ]; then
+ aws s3 cp "$wellknown" "s3://$BUCKET/$prefix/${wellknown#dist/}" \
+ --content-type "text/plain; charset=utf-8" \
+ --cache-control "no-cache"
+fi
+
echo
echo "Pushed to s3://$BUCKET/$prefix"
echo "To serve it: set site_origin_path = \"/$prefix\" in infra, apply,"
diff --git a/web/scripts/standard-site.test.mjs b/web/scripts/standard-site.test.mjs
new file mode 100644
index 0000000..dd57401
--- /dev/null
+++ b/web/scripts/standard-site.test.mjs
@@ -0,0 +1,99 @@
+/**
+ * The two things standard.site reads out of a publication's HTML: the
+ * well-known key naming the publication record, and the link tag naming a
+ * post's document record.
+ *
+ * The absent cases are the ones worth pinning. An href with nothing behind it
+ * is a claim a verifier follows, and CloudFront answers a genuinely missing
+ * key with the app's index.html and a 200, so from outside the network a
+ * wrong answer and a right one look the same.
+ *
+ * Run with `npm test`.
+ */
+import { test, after } from "node:test";
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const web = fileURLToPath(new URL("../", import.meta.url));
+const WELL_KNOWN = join(web, "public/.well-known/site.standard.publication");
+
+// Nothing resolvable. A DID that looked real would be followed by the first
+// person who copied it out of here.
+const DOCUMENT = "at://did:plc:example/site.standard.document/fakerkey";
+
+// No post in src/content/blog has a record, so the published case needs one
+// written for the build and taken away again. Under web/.astro, which is
+// gitignored and already the build's scratch - not os.tmpdir(), because the
+// last thing a build does is rename its output into place and a rename across
+// filesystems is EXDEV.
+const SLUG = "fixture-borborygmus";
+const fixture = join(web, "src/content/blog", `${SLUG}.md`);
+const outDir = join(web, ".astro/standard-site");
+
+writeFileSync(
+ fixture,
+ `---
+title: Fixture borborygmus
+description: Fixture for the standard.site tests. Removed when they finish.
+publishDate: 2026-01-01
+atUri: ${DOCUMENT}
+---
+
+Borborygmus quincunx widdershins.
+`,
+);
+try {
+ execFileSync(
+ join(web, "node_modules/.bin/astro"),
+ ["build", "--outDir", outDir],
+ {
+ cwd: web,
+ stdio: "pipe",
+ },
+ );
+} finally {
+ rmSync(fixture, { force: true });
+}
+
+after(() => rmSync(outDir, { recursive: true, force: true }));
+
+const page = (path) => readFileSync(join(outDir, path, "index.html"), "utf8");
+
+test("a post with atUri links to its record", () => {
+ assert.match(
+ page(`blog/${SLUG}`),
+ new RegExp(` {
+ assert.ok(
+ !page("blog/placeholder-borborygmus").includes("site.standard.document"),
+ "an unpublished post rendered a document link",
+ );
+});
+
+test("the publication key, once it exists, says what sequoia.json says", () => {
+ // `sequoia init` writes this file and the URI in sequoia.json in the same
+ // step, and nothing rewrites either afterwards. They drift when one is
+ // edited by hand, and the symptom is a publication nobody can verify.
+ if (!existsSync(WELL_KNOWN)) return;
+
+ const body = readFileSync(WELL_KNOWN, "utf8");
+ assert.match(
+ body,
+ /^at:\/\/did:[^\s]+\/site\.standard\.publication\/[^\s]+$/,
+ "the key must be one AT-URI, with no trailing newline",
+ );
+ assert.equal(
+ body,
+ JSON.parse(readFileSync(join(web, "sequoia.json"), "utf8")).publicationUri,
+ );
+ assert.ok(
+ existsSync(join(outDir, ".well-known/site.standard.publication")),
+ "public/.well-known did not survive the build",
+ );
+});
diff --git a/web/src/content.config.ts b/web/src/content.config.ts
index 6b31d25..5160e4b 100644
--- a/web/src/content.config.ts
+++ b/web/src/content.config.ts
@@ -10,7 +10,8 @@ import { z } from "astro/zod";
* The field names are the ones standard.site publishing tools already look
* for - title, description, publishDate, tags, draft - so posting these to an
* ATProto repository later is a matter of pointing something at this
- * directory, not of renaming every file's frontmatter.
+ * directory, not of renaming every file's frontmatter. atUri is the other
+ * direction: it is what those tools write back once a post has a record.
*/
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.md" }),
@@ -21,6 +22,11 @@ const blog = defineCollection({
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
+
+ // The post's own record, written back here by whatever publishes it. It
+ // is absent until then, and a post without one renders no standard.site
+ // link tag rather than an empty href.
+ atUri: z.string().startsWith("at://").optional(),
}),
});
diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro
index a723aec..1720778 100644
--- a/web/src/layouts/Base.astro
+++ b/web/src/layouts/Base.astro
@@ -19,12 +19,15 @@ type Props = {
/** Absolute or site-relative. Falls back to the site's own card. */
image?: string;
imageAlt?: string;
+ /** AT-URI of the record this page was rendered from, if it has one. */
+ atUri?: string;
};
const {
title,
description,
current,
+ atUri,
ogType = "website",
image = "/social-card.png",
imageAlt = "Obelus quincunx: nugatory petrichor defenestrate susurrus marmoreal quillon palimpsest kenning umbel xebec.",
@@ -62,6 +65,12 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null;
*/
}
+ {
+ /* The canonical link's counterpart in ATProto. Only a page with a
+ record carries it: a verifier resolves the href, so an empty one is
+ worse than a missing tag. */
+ }
+ {atUri && }
diff --git a/web/src/pages/blog/[...slug].astro b/web/src/pages/blog/[...slug].astro
index 13e6ee3..0e39bd4 100644
--- a/web/src/pages/blog/[...slug].astro
+++ b/web/src/pages/blog/[...slug].astro
@@ -26,6 +26,7 @@ const day = new Intl.DateTimeFormat("en", {
description={post.data.description}
current="blog"
ogType="article"
+ atUri={post.data.atUri}
>