From e69c5caefe0bd51e4dccd87fadce7540b309c41d Mon Sep 17 00:00:00 2001 From: Ferdinand Malcher Date: Wed, 22 Jul 2026 14:30:56 +0200 Subject: [PATCH] feat: standard.site publisher for blog records Publish the blog to the standard.site AT Protocol lexicons: one site.standard.publication record and one site.standard.document record per non-hidden post (rkey = slug), written to a Bluesky/PDS account during the build. - standard-site/atproto.ts: minimal XRPC client (createSession/putRecord/ listRecords/deleteRecord), validate:false for third-party lexicons, no deps - standard-site/publish.ts: config-driven, opt-in (no-op unless configured), idempotent upsert + prune of stale documents, dry-run mode - build.ts: publish after the blog build (skipped when unconfigured) - STANDARD-SITE.md + .env.example: setup and config docs Publication-agnostic so the shared submodule can serve multiple sites; each consumer supplies its own env/secrets. --- .env.example | 19 ++++ .gitignore | 3 +- STANDARD-SITE.md | 73 ++++++++++++++++ build.ts | 9 +- package.json | 2 +- standard-site/atproto.ts | 113 ++++++++++++++++++++++++ standard-site/publish.ts | 182 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 STANDARD-SITE.md create mode 100644 standard-site/atproto.ts create mode 100644 standard-site/publish.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4461aa7 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# standard.site publisher config (copy to .env for local runs). +# When BSKY_APP_PASSWORD / BSKY_HANDLE / STANDARD_SITE_URL are unset, the +# publish step is skipped entirely (opt-in). + +# Bluesky / PDS credentials +BSKY_HANDLE=angular.schule +BSKY_APP_PASSWORD= +BSKY_PDS=https://bsky.social + +# Publication metadata (site.standard.publication) +STANDARD_SITE_URL=https://angular.schule +STANDARD_SITE_NAME=Angular.Schule +STANDARD_SITE_DESCRIPTION= +# Optional safety guard: abort if the logged-in DID differs from this value +STANDARD_SITE_EXPECTED_DID=did:plc:zfvlnrdh7cotokow5hx7czxi +# Set to "false" to opt out of the standard.site discovery feed +STANDARD_SITE_SHOW_IN_DISCOVER=true +# Set to "true" to log what would be written/pruned without touching the PDS +STANDARD_SITE_DRY_RUN=false diff --git a/.gitignore b/.gitignore index 76add87..a0d218e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -dist \ No newline at end of file +dist +.env \ No newline at end of file diff --git a/STANDARD-SITE.md b/STANDARD-SITE.md new file mode 100644 index 0000000..0128543 --- /dev/null +++ b/STANDARD-SITE.md @@ -0,0 +1,73 @@ +# standard.site publishing + +This build publishes the blog to the [standard.site](https://standard.site) AT +Protocol lexicons so posts become discoverable on the decentralized social web: + +- one `site.standard.publication` record (rkey `self`) for the blog, and +- one `site.standard.document` record per non-hidden post (rkey = the post + slug). + +Records are written to a Bluesky/PDS account during `npm run build` +(`standard-site/publish.ts`). The consuming website only serves the verification +artifacts (`/.well-known/site.standard.publication` and per-post +``), which it derives from the account DID + +slug — no data is shipped from here to the website. + +This module is **publication-agnostic**: every account-specific value comes from +the environment, and publishing is **opt-in** — if `BSKY_APP_PASSWORD`, +`BSKY_HANDLE` and `STANDARD_SITE_URL` are not all set, the step is skipped. The +same shared submodule can therefore serve several websites (e.g. angular.schule +and angular-buch.com); each parent repo supplies its own config. + +## Configuration + +| Env var | Required | Description | +| --- | --- | --- | +| `BSKY_APP_PASSWORD` | yes | Bluesky **app password** (not the account password). Secret. | +| `BSKY_HANDLE` | yes | Account handle, e.g. `angular.schule`. | +| `STANDARD_SITE_URL` | yes | Publication base URL, e.g. `https://angular.schule` (no trailing slash). | +| `BSKY_PDS` | no | PDS host. Default `https://bsky.social`. | +| `STANDARD_SITE_NAME` | no | Publication name. Default: the handle. | +| `STANDARD_SITE_DESCRIPTION` | no | Publication description. | +| `STANDARD_SITE_EXPECTED_DID` | no | Abort if the logged-in DID differs (guards against a wrong account). | +| `STANDARD_SITE_SHOW_IN_DISCOVER` | no | `false` to opt out of the discovery feed. Default `true`. | +| `STANDARD_SITE_DRY_RUN` | no | `true` logs the records that would be written/pruned without touching the PDS. | + +## One-time setup + +1. **Create an app password.** In Bluesky: Settings → Privacy and Security → + App Passwords → *Add App Password*. Copy the `xxxx-xxxx-xxxx-xxxx` token. +2. **Resolve the account DID** (public, no auth): + ``` + curl "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=angular.schule" + ``` + Put the returned `did:plc:…` into `STANDARD_SITE_EXPECTED_DID` here **and** + into `STANDARD_SITE_DID` in the website + (`src/app/blog/shared/standard-site.config.ts`). +3. **Add the CI secret.** In the parent repo (e.g. `website-articles`) → + Settings → Secrets and variables → Actions → add `BSKY_APP_PASSWORD`. The + non-secret values live in `.github/workflows/build-article-data.yml`. + +## Running + +- **CI:** publishing happens automatically inside the existing article-data + build once the secret is set. New/removed/hidden posts sync on the next build. +- **Local:** `cp .env.example .env`, fill in `BSKY_APP_PASSWORD`, then + `npm run build`. Without a `.env` the build runs normally and skips publishing. +- **Preview:** prefix a local build with `STANDARD_SITE_DRY_RUN=true` to log + exactly what would be written/pruned without calling the PDS. + +## Behaviour + +- Idempotent: records use stable rkeys (`self` / slug), so re-runs update in + place — never duplicates. +- Prunes: document records whose slug is no longer a live (non-hidden) post are + deleted. +- `validate: false` is used on `putRecord` because a PDS does not natively know + third-party lexicons like `site.standard.*`. + +## Adding another website (e.g. angular-buch.com) + +No change to this submodule. In that website's `website-articles` parent repo: +add the same env block to its build workflow with its own handle / URL / name / +DID, and add its `BSKY_APP_PASSWORD` secret. Publishing turns on automatically. diff --git a/build.ts b/build.ts index a3fe2c8..5978b74 100644 --- a/build.ts +++ b/build.ts @@ -9,6 +9,7 @@ import { makeLightBlogList } from './blog/blog.utils'; import { makeLightList } from './shared/list.utils'; import { MARKDOWN_BASE_URL_PLACEHOLDER } from './shared/jekyll-markdown-parser'; import { printValidationResults } from './shared/link-validator'; +import { publishStandardSite } from './standard-site/publish'; const DIST_FOLDER = '../dist'; const BLOG_FOLDER = '../blog'; @@ -28,7 +29,7 @@ function applyBlogDefaults(entries: BlogEntryFull[]): BlogEntryFull[] { })); } -async function buildBlog(): Promise { +async function buildBlog(): Promise { console.log('Building blog...'); const blogDist = path.join(DIST_FOLDER, 'blog'); await mkdirp(blogDist); @@ -39,6 +40,7 @@ async function buildBlog(): Promise { await writeJson(path.join(blogDist, LIST_FILE), blogListLight); await copyEntriesToDist(entryList, BLOG_FOLDER, blogDist); console.log(`Blog: ${entryList.length} entries processed`); + return entryList; } async function buildMaterial(): Promise { @@ -63,13 +65,16 @@ async function build(): Promise { await remove(DIST_FOLDER); await mkdirp(DIST_FOLDER); - await buildBlog(); + const blogEntries = await buildBlog(); await buildMaterial(); // Validate all anchor links (warnings only, does not fail build) console.log('\nValidating anchor links...'); printValidationResults(); + // Publish standard.site records (no-op unless configured via env) + await publishStandardSite(blogEntries); + console.log('\nBuild complete!'); } diff --git a/package.json b/package.json index c87617e..bb50991 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "website-articles-build", "version": "1.0.0", "scripts": { - "build": "tsx build.ts", + "build": "tsx --env-file-if-exists=.env build.ts", "watch": "nodemon --watch ../blog --watch ../material -e md,jpg,png,svg --exec 'npm run build'", "test": "vitest run", "test:watch": "vitest", diff --git a/standard-site/atproto.ts b/standard-site/atproto.ts new file mode 100644 index 0000000..3563d4c --- /dev/null +++ b/standard-site/atproto.ts @@ -0,0 +1,113 @@ +/** + * Minimal AT Protocol XRPC client (createSession / putRecord / listRecords / + * deleteRecord) built on the global fetch — enough to publish standard.site + * records to a PDS without pulling in the full @atproto/api dependency. + */ + +export interface AtpSession { + did: string; + handle: string; + accessJwt: string; +} + +async function xrpc( + url: string, + init: RequestInit, +): Promise { + const response = await fetch(url, init); + const text = await response.text(); + if (!response.ok) { + throw new Error(`XRPC ${init.method} ${url} failed: ${response.status} ${text}`); + } + return text ? (JSON.parse(text) as T) : ({} as T); +} + +export async function createSession( + pds: string, + identifier: string, + password: string, +): Promise { + return xrpc(`${pds}/xrpc/com.atproto.server.createSession`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ identifier, password }), + }); +} + +function authHeaders(session: AtpSession): Record { + return { + 'content-type': 'application/json', + authorization: `Bearer ${session.accessJwt}`, + }; +} + +/** + * Create or overwrite a record at a known rkey. `validate: false` skips the + * PDS Lexicon check, which is required for third-party lexicons like + * site.standard.* that the PDS does not know natively. + */ +export async function putRecord( + pds: string, + session: AtpSession, + params: { collection: string; rkey: string; record: Record }, +): Promise { + await xrpc(`${pds}/xrpc/com.atproto.repo.putRecord`, { + method: 'POST', + headers: authHeaders(session), + body: JSON.stringify({ + repo: session.did, + collection: params.collection, + rkey: params.rkey, + record: params.record, + validate: false, + }), + }); +} + +export async function deleteRecord( + pds: string, + session: AtpSession, + collection: string, + rkey: string, +): Promise { + await xrpc(`${pds}/xrpc/com.atproto.repo.deleteRecord`, { + method: 'POST', + headers: authHeaders(session), + body: JSON.stringify({ repo: session.did, collection, rkey }), + }); +} + +export interface AtpRecord { + uri: string; + value: Record; +} + +/** List every record in a collection, following pagination cursors. */ +export async function listRecords( + pds: string, + did: string, + collection: string, +): Promise { + const records: AtpRecord[] = []; + let cursor: string | undefined; + + do { + const query = new URLSearchParams({ repo: did, collection, limit: '100' }); + if (cursor) { + query.set('cursor', cursor); + } + const page = await xrpc<{ records: AtpRecord[]; cursor?: string }>( + `${pds}/xrpc/com.atproto.repo.listRecords?${query.toString()}`, + { method: 'GET' }, + ); + records.push(...page.records); + cursor = page.cursor; + } while (cursor); + + return records; +} + +/** The record key is the last path segment of an at:// URI. */ +export function rkeyFromUri(uri: string): string { + return uri.split('/').pop() ?? ''; +} diff --git a/standard-site/publish.ts b/standard-site/publish.ts new file mode 100644 index 0000000..c9af96b --- /dev/null +++ b/standard-site/publish.ts @@ -0,0 +1,182 @@ +/** + * Publishes the blog to the standard.site AT Protocol lexicons: one + * site.standard.publication record and one site.standard.document record per + * (non-hidden) blog post, written to the configured Bluesky/PDS account. + * + * Publication-agnostic: every account-specific value comes from the environment + * (see readConfig), so the same shared build serves multiple websites. When the + * required config is absent the whole step is a no-op, keeping it opt-in. + */ +import { BlogEntryFull } from '../blog/blog.types'; +import { extractFirstBigParagraph } from '../shared/list.utils'; +import { stripHtmlTags } from '../shared/html.utils'; +import { + AtpSession, + createSession, + deleteRecord, + listRecords, + putRecord, + rkeyFromUri, +} from './atproto'; + +const PUBLICATION_COLLECTION = 'site.standard.publication'; +const DOCUMENT_COLLECTION = 'site.standard.document'; +const PUBLICATION_RKEY = 'self'; + +interface StandardSiteConfig { + pds: string; + handle: string; + password: string; + url: string; + name: string; + description?: string; + expectedDid?: string; + showInDiscover: boolean; + dryRun: boolean; +} + +/** Read config from env; returns null when the feature is not configured. */ +function readConfig(): StandardSiteConfig | null { + const password = process.env.BSKY_APP_PASSWORD; + const handle = process.env.BSKY_HANDLE; + const url = process.env.STANDARD_SITE_URL; + + if (!password || !handle || !url) { + return null; + } + + return { + pds: process.env.BSKY_PDS || 'https://bsky.social', + handle, + password, + url: url.replace(/\/+$/, ''), + name: process.env.STANDARD_SITE_NAME || handle, + description: process.env.STANDARD_SITE_DESCRIPTION || undefined, + expectedDid: process.env.STANDARD_SITE_EXPECTED_DID || undefined, + showInDiscover: process.env.STANDARD_SITE_SHOW_IN_DISCOVER !== 'false', + dryRun: process.env.STANDARD_SITE_DRY_RUN === 'true', + }; +} + +/** Normalise a YAML date (ISO string or date-only) to an ISO 8601 datetime. */ +function toIsoDateTime(value: string): string { + return new Date(value).toISOString(); +} + +function buildDocumentRecord( + entry: BlogEntryFull, + publicationUri: string, +): Record { + const description = stripHtmlTags(extractFirstBigParagraph(entry.html)).trim(); + const record: Record = { + $type: DOCUMENT_COLLECTION, + site: publicationUri, + title: entry.meta.title, + path: `/blog/${entry.slug}`, + publishedAt: toIsoDateTime(entry.meta.published), + textContent: stripHtmlTags(entry.html), + }; + + if (description) { + record.description = description; + } + if (entry.meta.lastModified) { + record.updatedAt = toIsoDateTime(entry.meta.lastModified); + } + if (entry.meta.keywords?.length) { + record.tags = entry.meta.keywords; + } + + return record; +} + +async function upsertPublication( + config: StandardSiteConfig, + session: AtpSession, +): Promise { + const record: Record = { + $type: PUBLICATION_COLLECTION, + url: config.url, + name: config.name, + preferences: { showInDiscover: config.showInDiscover }, + }; + if (config.description) { + record.description = config.description; + } + + if (config.dryRun) { + console.log(` [dry-run] would upsert publication ${PUBLICATION_RKEY} -> ${config.url} (${config.name})`); + } else { + await putRecord(config.pds, session, { + collection: PUBLICATION_COLLECTION, + rkey: PUBLICATION_RKEY, + record, + }); + } + + return `at://${session.did}/${PUBLICATION_COLLECTION}/${PUBLICATION_RKEY}`; +} + +/** Delete document records whose rkey is no longer a live (non-hidden) slug. */ +async function pruneDocuments( + config: StandardSiteConfig, + session: AtpSession, + liveSlugs: Set, +): Promise { + const existing = await listRecords(config.pds, session.did, DOCUMENT_COLLECTION); + let pruned = 0; + + for (const record of existing) { + const rkey = rkeyFromUri(record.uri); + if (!liveSlugs.has(rkey)) { + if (config.dryRun) { + console.log(` [dry-run] would prune stale document ${rkey}`); + } else { + await deleteRecord(config.pds, session, DOCUMENT_COLLECTION, rkey); + } + pruned++; + } + } + + return pruned; +} + +export async function publishStandardSite(entries: BlogEntryFull[]): Promise { + const config = readConfig(); + if (!config) { + console.log('standard.site: not configured (BSKY_APP_PASSWORD/BSKY_HANDLE/STANDARD_SITE_URL), skipping'); + return; + } + + console.log(`standard.site: ${config.dryRun ? 'DRY RUN — ' : ''}publishing to ${config.url}`); + const session = await createSession(config.pds, config.handle, config.password); + + if (config.expectedDid && session.did !== config.expectedDid) { + throw new Error( + `standard.site: session DID ${session.did} does not match STANDARD_SITE_EXPECTED_DID ${config.expectedDid}`, + ); + } + + const publicationUri = await upsertPublication(config, session); + + const visibleEntries = entries.filter((entry) => !entry.meta.hidden); + for (const entry of visibleEntries) { + if (config.dryRun) { + console.log(` [dry-run] would write document ${entry.slug}`); + } else { + await putRecord(config.pds, session, { + collection: DOCUMENT_COLLECTION, + rkey: entry.slug, + record: buildDocumentRecord(entry, publicationUri), + }); + } + } + + const liveSlugs = new Set(visibleEntries.map((entry) => entry.slug)); + const pruned = await pruneDocuments(config, session, liveSlugs); + + const verb = config.dryRun ? 'would publish' : 'published'; + console.log( + `standard.site: ${verb} 1 publication + ${visibleEntries.length} documents, ${config.dryRun ? 'would prune' : 'pruned'} ${pruned} stale`, + ); +}