Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
dist
dist
.env
73 changes: 73 additions & 0 deletions STANDARD-SITE.md
Original file line number Diff line number Diff line change
@@ -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
`<link rel="site.standard.document">`), 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.
9 changes: 7 additions & 2 deletions build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,7 +29,7 @@ function applyBlogDefaults(entries: BlogEntryFull[]): BlogEntryFull[] {
}));
}

async function buildBlog(): Promise<void> {
async function buildBlog(): Promise<BlogEntryFull[]> {
console.log('Building blog...');
const blogDist = path.join(DIST_FOLDER, 'blog');
await mkdirp(blogDist);
Expand All @@ -39,6 +40,7 @@ async function buildBlog(): Promise<void> {
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<void> {
Expand All @@ -63,13 +65,16 @@ async function build(): Promise<void> {
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!');
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 113 additions & 0 deletions standard-site/atproto.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
url: string,
init: RequestInit,
): Promise<T> {
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<AtpSession> {
return xrpc<AtpSession>(`${pds}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
}

function authHeaders(session: AtpSession): Record<string, string> {
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<string, unknown> },
): Promise<void> {
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<void> {
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<string, unknown>;
}

/** List every record in a collection, following pagination cursors. */
export async function listRecords(
pds: string,
did: string,
collection: string,
): Promise<AtpRecord[]> {
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() ?? '';
}
Loading
Loading