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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ 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
# Optional publication icon (profile image), square >=256x256, png/jpg/webp.
# Defined per site by the consuming website-articles repo — an http(s) URL or a
# local file path (relative to the build dir) — never hardcoded here.
STANDARD_SITE_ICON=
# 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
Expand Down
1 change: 1 addition & 0 deletions STANDARD-SITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ and angular-buch.com); each parent repo supplies its own config.
| `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_ICON` | no | Publication profile image (square ≥256×256, png/jpg/webp). An http(s) URL or a local file path; uploaded as a blob and set as the publication `icon`. |
| `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. |

Expand Down
31 changes: 31 additions & 0 deletions standard-site/atproto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ export async function putRecord(
});
}

/** A blob reference as embedded in a record after uploadBlob. */
export interface BlobRef {
$type: 'blob';
ref: { $link: string };
mimeType: string;
size: number;
}

/** Upload a binary blob to the repo; returns the blob ref to embed in a record. */
export async function uploadBlob(
pds: string,
session: AtpSession,
bytes: Uint8Array,
mimeType: string,
): Promise<BlobRef> {
const response = await fetch(`${pds}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: {
'content-type': mimeType,
authorization: `Bearer ${session.accessJwt}`,
},
// undici accepts a Uint8Array body at runtime; the DOM BodyInit type doesn't list it.
body: bytes as unknown as BodyInit,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`XRPC uploadBlob failed: ${response.status} ${text}`);
}
return (JSON.parse(text) as { blob: BlobRef }).blob;
}

export async function deleteRecord(
pds: string,
session: AtpSession,
Expand Down
45 changes: 45 additions & 0 deletions standard-site/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* (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 { readFile } from 'fs/promises';

import { EntryBase } from '../shared/base.types';
import { extractFirstBigParagraph } from '../shared/list.utils';
import { stripHtmlTags } from '../shared/html.utils';
Expand All @@ -20,6 +22,7 @@ import {
listRecords,
putRecord,
rkeyFromUri,
uploadBlob,
} from './atproto';

const PUBLICATION_COLLECTION = 'site.standard.publication';
Expand All @@ -42,6 +45,8 @@ interface StandardSiteConfig {
description?: string;
expectedDid?: string;
showInDiscover: boolean;
/** Publication icon: an http(s) URL or a local file path (png/jpg/webp). */
icon?: string;
dryRun: boolean;
}

Expand All @@ -64,10 +69,35 @@ function readConfig(): StandardSiteConfig | null {
description: process.env.STANDARD_SITE_DESCRIPTION || undefined,
expectedDid: process.env.STANDARD_SITE_EXPECTED_DID || undefined,
showInDiscover: process.env.STANDARD_SITE_SHOW_IN_DISCOVER !== 'false',
icon: process.env.STANDARD_SITE_ICON || undefined,
dryRun: process.env.STANDARD_SITE_DRY_RUN === 'true',
};
}

/** Guess an image MIME type from a URL or file path. */
function iconMimeType(source: string): string {
const ext = source.split('?')[0].split('.').pop()?.toLowerCase();
switch (ext) {
case 'png': return 'image/png';
case 'jpg':
case 'jpeg': return 'image/jpeg';
case 'webp': return 'image/webp';
default: throw new Error(`standard.site: unsupported icon type ".${ext}" (${source})`);
}
}

/** Load icon bytes from an http(s) URL or a local file path. */
async function loadIconBytes(source: string): Promise<Uint8Array> {
if (/^https?:\/\//i.test(source)) {
const response = await fetch(source);
if (!response.ok) {
throw new Error(`standard.site: cannot fetch icon ${source}: ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
return new Uint8Array(await readFile(source));
}

/** Normalise a YAML date (ISO string or date-only) to an ISO 8601 datetime. */
function toIsoDateTime(value: string): string {
return new Date(value).toISOString();
Expand Down Expand Up @@ -117,6 +147,21 @@ async function upsertPublication(
record.description = config.description;
}

if (config.icon && config.dryRun) {
console.log(` [dry-run] would upload publication icon from ${config.icon}`);
} else if (config.icon) {
// Non-fatal: a missing/broken icon must not fail the whole publish.
try {
const bytes = await loadIconBytes(config.icon);
record.icon = await uploadBlob(config.pds, session, bytes, iconMimeType(config.icon));
} catch (error) {
console.warn(
`standard.site: skipping icon (${config.icon}):`,
error instanceof Error ? error.message : error,
);
}
}

if (config.dryRun) {
console.log(` [dry-run] would upsert publication ${PUBLICATION_RKEY} -> ${config.url} (${config.name})`);
} else {
Expand Down
Loading