diff --git a/.env.example b/.env.example index 4461aa7..798e265 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/STANDARD-SITE.md b/STANDARD-SITE.md index c81a2b0..53eede5 100644 --- a/STANDARD-SITE.md +++ b/STANDARD-SITE.md @@ -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. | diff --git a/standard-site/atproto.ts b/standard-site/atproto.ts index 3563d4c..ca64b1f 100644 --- a/standard-site/atproto.ts +++ b/standard-site/atproto.ts @@ -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 { + 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, diff --git a/standard-site/publish.ts b/standard-site/publish.ts index f720a57..565a479 100644 --- a/standard-site/publish.ts +++ b/standard-site/publish.ts @@ -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'; @@ -20,6 +22,7 @@ import { listRecords, putRecord, rkeyFromUri, + uploadBlob, } from './atproto'; const PUBLICATION_COLLECTION = 'site.standard.publication'; @@ -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; } @@ -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 { + 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(); @@ -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 {