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`,
+ );
+}