From db6a74bb70b639057820509abbbb8bb063341aa4 Mon Sep 17 00:00:00 2001 From: Ferdinand Malcher Date: Wed, 22 Jul 2026 15:24:39 +0200 Subject: [PATCH 1/2] feat: set a publication icon (profile image) on standard.site records Add optional STANDARD_SITE_ICON config: the publisher uploads the image as a blob (com.atproto.repo.uploadBlob) and sets it as the site.standard.publication `icon`. Accepts an http(s) URL or a local file path (png/jpg/webp). The icon is part of the publication record build, so it survives every re-publish. --- .env.example | 3 +++ STANDARD-SITE.md | 1 + standard-site/atproto.ts | 31 +++++++++++++++++++++++++++++++ standard-site/publish.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) diff --git a/.env.example b/.env.example index 4461aa7..97b1c93 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ 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. +# An http(s) URL or a local file path. +STANDARD_SITE_ICON=https://angular.schule/ico/ms-icon-310x310.png # 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..8e7d24c 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,15 @@ async function upsertPublication( record.description = config.description; } + if (config.icon) { + if (config.dryRun) { + console.log(` [dry-run] would upload publication icon from ${config.icon}`); + } else { + const bytes = await loadIconBytes(config.icon); + record.icon = await uploadBlob(config.pds, session, bytes, iconMimeType(config.icon)); + } + } + if (config.dryRun) { console.log(` [dry-run] would upsert publication ${PUBLICATION_RKEY} -> ${config.url} (${config.name})`); } else { From 70d68f3b478387892985a4059dfe68ec09777b1f Mon Sep 17 00:00:00 2001 From: Ferdinand Malcher Date: Wed, 22 Jul 2026 15:27:44 +0200 Subject: [PATCH 2/2] refactor: keep icon config per-site; make icon upload non-fatal - .env.example: STANDARD_SITE_ICON is a per-site placeholder, not a hardcoded URL - publish.ts: a missing/broken icon logs a warning and continues instead of failing the whole publish (icons live in the website repo and are fetched by URL, which may lag a deploy) --- .env.example | 5 +++-- standard-site/publish.ts | 14 ++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 97b1c93..798e265 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,9 @@ 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. -# An http(s) URL or a local file path. -STANDARD_SITE_ICON=https://angular.schule/ico/ms-icon-310x310.png +# 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/publish.ts b/standard-site/publish.ts index 8e7d24c..565a479 100644 --- a/standard-site/publish.ts +++ b/standard-site/publish.ts @@ -147,12 +147,18 @@ async function upsertPublication( record.description = config.description; } - if (config.icon) { - if (config.dryRun) { - console.log(` [dry-run] would upload publication icon from ${config.icon}`); - } else { + 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, + ); } }