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} >