Something went wrong. Try again.
eny.space base page eny.space
atproto pds managed hosting
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150/** * Syndicate the markdown blog to our PDS as standard.site records (POSSE). * * Markdown stays the source of truth. This script mirrors each post to a * `site.standard.document` record (metadata only — no body) and upserts the * `site.standard.publication`. It is idempotent: rkey = slug, so re-running * overwrites in place and never duplicates. * * Not part of the build — run at commit time or manually via `pnpm sync:standard`. * A flaky network or expired app password must never break a deploy. */import fs from "node:fs";import "dotenv/config";import { AtpAgent } from "@atproto/api";
import { getAllPostMeta } from "../lib/blog";
const { ATP_SERVICE, ATP_IDENTIFIER, ATP_APP_PASSWORD, SITE_URL = "https://eny.space", ATP_PUB_RKEY = "eny-space", PUBLICATION_NAME = "eny.space", PUBLICATION_DESCRIPTION = "Managed PDS hosting for the atmosphere.", // Square PNG (>=256px) for site.standard.publication.icon. Re-uploaded each // run; content-addressed, so the same file yields the same blob (no dupes). PUBLICATION_ICON_PATH = "public/pixel-planet-static.png",} = process.env;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function requireEnv(name: string, value: string | undefined): string { if (!value) { throw new Error(`Missing required env var: ${name}`); } return value;}
async function main() { const service = requireEnv("ATP_SERVICE", ATP_SERVICE); const identifier = requireEnv("ATP_IDENTIFIER", ATP_IDENTIFIER); const password = requireEnv("ATP_APP_PASSWORD", ATP_APP_PASSWORD);
const agent = new AtpAgent({ service }); await agent.login({ identifier, password }); const did = agent.session!.did; console.log(`Logged in as ${identifier} (${did})`);
// Upload the publication icon blob if the file exists (optional). let icon: unknown; try { const bytes = fs.readFileSync(PUBLICATION_ICON_PATH); const up = await agent.uploadBlob(bytes, { encoding: "image/png" }); icon = up.data.blob; console.log(`Icon blob uploaded from ${PUBLICATION_ICON_PATH}`); } catch (e) { console.warn( `No publication icon set (${PUBLICATION_ICON_PATH}): ${e instanceof Error ? e.message : e}`, ); }
// 1. Upsert the publication. // publication.url points at the blog so "view publication" lands on /blog; // documents then use a bare `/<slug>` path (canonical = url + path, no /blog/blog). const publicationUrl = process.env.PUBLICATION_URL || `${SITE_URL.replace(/\/+$/, "")}/blog`; const publication = { $type: "site.standard.publication", url: publicationUrl, name: PUBLICATION_NAME, description: PUBLICATION_DESCRIPTION, ...(icon ? { icon } : {}), preferences: { showInDiscover: true }, }; await agent.com.atproto.repo.putRecord({ repo: did, collection: "site.standard.publication", rkey: ATP_PUB_RKEY, validate: false, // our PDS doesn't carry the site.standard.* lexicon record: publication, }); const siteUri = `at://${did}/site.standard.publication/${ATP_PUB_RKEY}`; console.log(`Publication → ${siteUri}`);
// 2. Upsert one document per post. const posts = getAllPostMeta(); const liveSlugs = new Set(posts.map((p) => p.slug));
for (const post of posts) { const record = { $type: "site.standard.document", site: siteUri, title: post.title, // Canonical URL = publication.url + path → https://eny.space/blog/<slug>. path: `/${post.slug}`, description: post.description, publishedAt: post.publishedAt, ...(post.tags.length ? { tags: post.tags } : {}), }; await agent.com.atproto.repo.putRecord({ repo: did, collection: "site.standard.document", rkey: post.slug, validate: false, record, }); console.log( `Document → at://${did}/site.standard.document/${post.slug}`, ); await sleep(200); // be polite to the PDS }
// 3. Reconcile deletions: drop records whose post no longer exists. let cursor: string | undefined; const orphans: string[] = []; do { const res = await agent.com.atproto.repo.listRecords({ repo: did, collection: "site.standard.document", limit: 100, cursor, }); for (const rec of res.data.records) { const rkey = rec.uri.split("/").pop()!; if (!liveSlugs.has(rkey)) orphans.push(rkey); } cursor = res.data.cursor; } while (cursor);
for (const rkey of orphans) { await agent.com.atproto.repo.deleteRecord({ repo: did, collection: "site.standard.document", rkey, }); console.log(`Deleted orphaned document → ${rkey}`); await sleep(200); }
console.log( `\nDone. ${posts.length} document(s) synced, ${orphans.length} removed.`, );}
main().catch((err) => { console.error("sync:standard failed:", err); process.exit(1);});