#!/usr/bin/env node // sync-standard.mjs — make this static site compatible with standard.site. // // Creates / updates the AT Protocol records that standard.site indexes: // • one site.standard.publication record for the whole site // • one site.standard.document record per page listed in standard.config.json // then writes the verification artifacts back into the repo: // • /.well-known/site.standard.publication (the publication AT-URI) // • injected into each page's // // Record keys are remembered in standard-mapping.json so re-runs update the // same records instead of creating duplicates. No third-party dependencies — // just Node 20+ (global fetch). See README for the env vars it needs. // // Usage: node scripts/sync-standard.mjs (real run) // node scripts/sync-standard.mjs --dry-run (no network writes) import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { execSync } from "node:child_process"; import { createHash } from "node:crypto"; const ROOT = process.cwd(); const DRY_RUN = process.argv.includes("--dry-run"); const HANDLE = (process.env.BLUESKY_HANDLE || "").trim().replace(/^@/, ""); const APP_PASSWORD = (process.env.BLUESKY_APP_PASSWORD || "").trim(); const SERVICE = (process.env.BLUESKY_SERVICE || "https://bsky.social").replace(/\/$/, ""); const PUB_COLLECTION = "site.standard.publication"; const DOC_COLLECTION = "site.standard.document"; const MAPPING_PATH = join(ROOT, "standard-mapping.json"); const CONFIG_PATH = join(ROOT, "standard.config.json"); const WELL_KNOWN_PATH = join(ROOT, ".well-known", PUB_COLLECTION); const MARK_OPEN = ""; const MARK_CLOSE = ""; function die(msg) { console.error(`✗ ${msg}`); process.exit(1); } function log(msg) { console.log(msg); } // ── tiny helpers ──────────────────────────────────────────────────────────── function readJson(path, fallback) { if (!existsSync(path)) return fallback; return JSON.parse(readFileSync(path, "utf8")); } function deepEqual(a, b) { return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b)); } function sortKeys(v) { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === "object") { return Object.keys(v) .sort() .reduce((o, k) => ((o[k] = sortKeys(v[k])), o), {}); } return v; } function decodeEntities(s) { return s .replace(/—/g, "—") .replace(/–/g, "–") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/ /g, " ") .trim(); } // Expand include globs (supports "dir/*.html", "*.html", and literal paths). function expandIncludes(includes, excludes) { const exclude = new Set((excludes || []).map((p) => p.replace(/^\.?\//, ""))); const out = new Set(); for (const pattern of includes || []) { const clean = pattern.replace(/^\.?\//, ""); if (!clean.includes("*")) { if (existsSync(join(ROOT, clean))) out.add(clean); continue; } const dir = dirname(clean) === "." ? "" : dirname(clean); const re = new RegExp( "^" + clean.split("/").pop().replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$" ); const abs = join(ROOT, dir); if (!existsSync(abs)) continue; for (const name of readdirSync(abs)) { if (re.test(name)) out.add(dir ? `${dir}/${name}` : name); } } return [...out].filter((p) => !exclude.has(p)).sort(); } function firstCommitDate(relPath) { try { const out = execSync(`git log --diff-filter=A --follow --format=%aI -- "${relPath}"`, { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"], }) .toString() .trim(); const lines = out.split("\n").filter(Boolean); return lines.length ? lines[lines.length - 1] : null; } catch { return null; } } function cleanTitle(raw, stripNames) { // Split on common separators and drop any segment that is just the site name, // so "Reading — Ron Bronson" and "Ron Bronson — About" both reduce sensibly. const strip = (stripNames || []).map((s) => s.toLowerCase()); const parts = raw .split(/\s+[—–·|]\s+/) .map((s) => s.trim()) .filter(Boolean); const kept = parts.filter((p) => !strip.includes(p.toLowerCase())); return (kept.length ? kept : parts).join(" — "); } function extractMeta(html, stripNames) { const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); let title = titleMatch ? decodeEntities(titleMatch[1].replace(/\s+/g, " ")) : null; if (title) title = cleanTitle(title, stripNames); // Backreference \1 so an apostrophe inside double-quoted content doesn't end the match. const descMatch = html.match(/]+name=["']description["'][^>]*>/i); let description = null; if (descMatch) { const c = descMatch[0].match(/content=(["'])([\s\S]*?)\1/i); if (c) description = decodeEntities(c[2].replace(/\s+/g, " ")); } return { title, description }; } // ── AT Protocol / XRPC ────────────────────────────────────────────────────── async function xrpc(host, method, { auth, body, query } = {}) { const isQuery = body === undefined; let url = `${host}/xrpc/${method}`; if (query) url += "?" + new URLSearchParams(query).toString(); const headers = {}; if (auth) headers.Authorization = `Bearer ${auth}`; let init = { method: isQuery ? "GET" : "POST", headers }; if (!isQuery) { if (body instanceof Uint8Array) { headers["Content-Type"] = "application/octet-stream"; init.body = body; } else { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } } const res = await fetch(url, init); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; } if (!res.ok) { const err = new Error(`${method} → ${res.status} ${json.error || ""} ${json.message || text}`.trim()); err.status = res.status; err.body = json; throw err; } return json; } async function resolvePds(did, session) { // Prefer the didDoc returned with the session, else fall back to a directory. const fromDoc = (doc) => { const svc = (doc?.service || []).find( (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" ); return svc?.serviceEndpoint || null; }; if (session?.didDoc) { const ep = fromDoc(session.didDoc); if (ep) return ep.replace(/\/$/, ""); } try { if (did.startsWith("did:plc:")) { const doc = await (await fetch(`https://plc.directory/${did}`)).json(); const ep = fromDoc(doc); if (ep) return ep.replace(/\/$/, ""); } else if (did.startsWith("did:web:")) { const host = did.slice("did:web:".length).replace(/:/g, "/"); const doc = await (await fetch(`https://${host}/.well-known/did.json`)).json(); const ep = fromDoc(doc); if (ep) return ep.replace(/\/$/, ""); } } catch { /* fall through */ } return SERVICE; // last resort: the entryway proxies writes } async function ensureRecord(pds, jwt, did, collection, rkey, value) { if (rkey) { try { const existing = await xrpc(pds, "com.atproto.repo.getRecord", { auth: jwt, query: { repo: did, collection, rkey }, }); if (deepEqual(existing.value, { $type: collection, ...value })) { return { uri: existing.uri, rkey, changed: false }; } } catch (e) { if (e.status !== 400) throw e; // 400 = not found → fall through to create rkey = null; } } const record = { $type: collection, ...value }; if (DRY_RUN) { return { uri: `at://${did}/${collection}/${rkey || ""}`, rkey: rkey || "", changed: true }; } if (rkey) { const r = await xrpc(pds, "com.atproto.repo.putRecord", { auth: jwt, body: { repo: did, collection, rkey, record }, }); return { uri: r.uri, rkey, changed: true }; } const r = await xrpc(pds, "com.atproto.repo.createRecord", { auth: jwt, body: { repo: did, collection, record }, }); return { uri: r.uri, rkey: r.uri.split("/").pop(), changed: true }; } // ── HTML injection ────────────────────────────────────────────────────────── function injectLinkTag(absPath, atUri) { let html = readFileSync(absPath, "utf8"); const block = `${MARK_OPEN}\n\n${MARK_CLOSE}`; const re = new RegExp(`${MARK_OPEN}[\\s\\S]*?${MARK_CLOSE}`); let next; if (re.test(html)) { next = html.replace(re, block); } else if (/<\/head>/i.test(html)) { next = html.replace(/<\/head>/i, `${block}\n`); } else { return false; } if (next !== html) { if (!DRY_RUN) writeFileSync(absPath, next); return true; } return false; } // ── main ──────────────────────────────────────────────────────────────────── async function main() { if (!existsSync(CONFIG_PATH)) die(`missing ${CONFIG_PATH}`); const config = readJson(CONFIG_PATH); const mapping = readJson(MAPPING_PATH, { documents: {} }); mapping.documents = mapping.documents || {}; if (!config.publication?.url || !config.publication?.name) { die("standard.config.json needs publication.url and publication.name"); } if (!DRY_RUN && (!HANDLE || !APP_PASSWORD)) { die("set BLUESKY_HANDLE and BLUESKY_APP_PASSWORD (use --dry-run to preview without them)"); } let did = mapping.did || null; let jwt = null; let pds = SERVICE; if (!DRY_RUN) { log(`→ authenticating as ${HANDLE} via ${SERVICE}`); const session = await xrpc(SERVICE, "com.atproto.server.createSession", { body: { identifier: HANDLE, password: APP_PASSWORD }, }); did = session.did; jwt = session.accessJwt; pds = await resolvePds(did, session); log(`→ did ${did}`); log(`→ pds ${pds}`); } if (!did) did = "did:placeholder"; // dry-run without a prior mapping // 1. Publication record (+ optional icon blob, best-effort). const pubValue = { url: config.publication.url.replace(/\/$/, "") + "/", name: config.publication.name, }; if (config.publication.description) pubValue.description = config.publication.description; if (config.publication.preferences) pubValue.preferences = config.publication.preferences; if (config.publication.icon && !DRY_RUN) { const iconPath = join(ROOT, config.publication.icon); if (existsSync(iconPath)) { try { const bytes = readFileSync(iconPath); const sha = createHash("sha256").update(bytes).digest("hex"); if (mapping.icon?.sha === sha && mapping.icon?.blob) { pubValue.icon = mapping.icon.blob; } else { const mime = iconPath.endsWith(".svg") ? "image/svg+xml" : iconPath.endsWith(".jpg") || iconPath.endsWith(".jpeg") ? "image/jpeg" : "image/png"; const up = await xrpc(pds, "com.atproto.repo.uploadBlob", { auth: jwt, body: new Uint8Array(bytes), }); // uploadBlob ignores Content-Type for the body, but the PDS records // the mime from the header we set in xrpc(); set it explicitly here. up.blob.mimeType = mime; pubValue.icon = up.blob; mapping.icon = { sha, blob: up.blob }; } } catch (e) { log(` ! icon upload skipped: ${e.message}`); } } } let pub; try { pub = await ensureRecord(pds, jwt, did, PUB_COLLECTION, mapping.publicationRkey, pubValue); } catch (e) { // If the icon blob is rejected (e.g. below the 256px minimum), retry without it. if (pubValue.icon && /icon|blob|image|InvalidRequest/i.test(e.message)) { log(` ! publication rejected with icon (${e.message}); retrying without icon`); delete pubValue.icon; delete mapping.icon; pub = await ensureRecord(pds, jwt, did, PUB_COLLECTION, mapping.publicationRkey, pubValue); } else { throw e; } } mapping.did = did; mapping.publicationRkey = pub.rkey; log(`${pub.changed ? "✓ wrote" : "· unchanged"} publication ${pub.uri}`); // 2. .well-known verification file. if (!DRY_RUN || !existsSync(WELL_KNOWN_PATH)) { if (!DRY_RUN) { const wkDir = dirname(WELL_KNOWN_PATH); if (!existsSync(wkDir)) execSync(`mkdir -p "${wkDir}"`); writeFileSync(WELL_KNOWN_PATH, pub.uri + "\n"); } } log(`✓ .well-known/${PUB_COLLECTION} → ${pub.uri}`); // 3. Document records + tags. const overrides = config.documents?.overrides || {}; const stripNames = [config.publication.name, ...(config.documents?.titleStrip || [])]; const docs = expandIncludes(config.documents?.include, [ ...(config.documents?.exclude || []), "404.html", ]); log(`→ ${docs.length} document(s)`); for (const rel of docs) { const abs = join(ROOT, rel); const html = readFileSync(abs, "utf8"); const meta = extractMeta(html, stripNames); const ov = overrides[rel] || {}; const value = { site: pub.uri, title: ov.title || meta.title || rel, publishedAt: ov.publishedAt || firstCommitDate(rel) || new Date().toISOString(), path: "/" + rel.replace(/^\.?\//, ""), }; const description = ov.description || meta.description; if (description) value.description = description; if (ov.tags) value.tags = ov.tags; const prev = mapping.documents[rel]; const result = await ensureRecord(pds, jwt, did, DOC_COLLECTION, prev?.rkey, value); mapping.documents[rel] = { rkey: result.rkey, uri: result.uri }; const injected = injectLinkTag(abs, result.uri); log( ` ${result.changed ? "✓" : "·"} ${rel}${injected ? " (+link)" : ""} → ${result.uri}` ); } // 4. Delete records for pages that are no longer published (removed or // excluded), so standard.site stops listing them. for (const rel of Object.keys(mapping.documents)) { if (docs.includes(rel)) continue; const { rkey, uri } = mapping.documents[rel]; if (!DRY_RUN) { await xrpc(pds, "com.atproto.repo.deleteRecord", { auth: jwt, body: { repo: did, collection: DOC_COLLECTION, rkey }, }); delete mapping.documents[rel]; } log(` ✗ ${rel} → deleted ${uri}`); } // 5. Persist the rkey mapping (skip the placeholder DID from dry runs). if (!DRY_RUN) { writeFileSync(MAPPING_PATH, JSON.stringify(sortKeys(mapping), null, 2) + "\n"); log(`✓ standard-mapping.json updated`); } log("✓ standard.site sync complete"); } main().catch((e) => { console.error(e.stack || e.message); process.exit(1); });