Skip to content

Commit db6a74b

Browse files
committed
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.
1 parent 775a3d1 commit db6a74b

4 files changed

Lines changed: 74 additions & 0 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ STANDARD_SITE_NAME=Angular.Schule
1313
STANDARD_SITE_DESCRIPTION=
1414
# Optional safety guard: abort if the logged-in DID differs from this value
1515
STANDARD_SITE_EXPECTED_DID=did:plc:zfvlnrdh7cotokow5hx7czxi
16+
# Optional publication icon (profile image), square >=256x256, png/jpg/webp.
17+
# An http(s) URL or a local file path.
18+
STANDARD_SITE_ICON=https://angular.schule/ico/ms-icon-310x310.png
1619
# Set to "false" to opt out of the standard.site discovery feed
1720
STANDARD_SITE_SHOW_IN_DISCOVER=true
1821
# Set to "true" to log what would be written/pruned without touching the PDS

STANDARD-SITE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and angular-buch.com); each parent repo supplies its own config.
3232
| `STANDARD_SITE_NAME` | no | Publication name. Default: the handle. |
3333
| `STANDARD_SITE_DESCRIPTION` | no | Publication description. |
3434
| `STANDARD_SITE_EXPECTED_DID` | no | Abort if the logged-in DID differs (guards against a wrong account). |
35+
| `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`. |
3536
| `STANDARD_SITE_SHOW_IN_DISCOVER` | no | `false` to opt out of the discovery feed. Default `true`. |
3637
| `STANDARD_SITE_DRY_RUN` | no | `true` logs the records that would be written/pruned without touching the PDS. |
3738

standard-site/atproto.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,37 @@ export async function putRecord(
6464
});
6565
}
6666

67+
/** A blob reference as embedded in a record after uploadBlob. */
68+
export interface BlobRef {
69+
$type: 'blob';
70+
ref: { $link: string };
71+
mimeType: string;
72+
size: number;
73+
}
74+
75+
/** Upload a binary blob to the repo; returns the blob ref to embed in a record. */
76+
export async function uploadBlob(
77+
pds: string,
78+
session: AtpSession,
79+
bytes: Uint8Array,
80+
mimeType: string,
81+
): Promise<BlobRef> {
82+
const response = await fetch(`${pds}/xrpc/com.atproto.repo.uploadBlob`, {
83+
method: 'POST',
84+
headers: {
85+
'content-type': mimeType,
86+
authorization: `Bearer ${session.accessJwt}`,
87+
},
88+
// undici accepts a Uint8Array body at runtime; the DOM BodyInit type doesn't list it.
89+
body: bytes as unknown as BodyInit,
90+
});
91+
const text = await response.text();
92+
if (!response.ok) {
93+
throw new Error(`XRPC uploadBlob failed: ${response.status} ${text}`);
94+
}
95+
return (JSON.parse(text) as { blob: BlobRef }).blob;
96+
}
97+
6798
export async function deleteRecord(
6899
pds: string,
69100
session: AtpSession,

standard-site/publish.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* (see readConfig), so the same shared build serves multiple websites. When the
1111
* required config is absent the whole step is a no-op, keeping it opt-in.
1212
*/
13+
import { readFile } from 'fs/promises';
14+
1315
import { EntryBase } from '../shared/base.types';
1416
import { extractFirstBigParagraph } from '../shared/list.utils';
1517
import { stripHtmlTags } from '../shared/html.utils';
@@ -20,6 +22,7 @@ import {
2022
listRecords,
2123
putRecord,
2224
rkeyFromUri,
25+
uploadBlob,
2326
} from './atproto';
2427

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

@@ -64,10 +69,35 @@ function readConfig(): StandardSiteConfig | null {
6469
description: process.env.STANDARD_SITE_DESCRIPTION || undefined,
6570
expectedDid: process.env.STANDARD_SITE_EXPECTED_DID || undefined,
6671
showInDiscover: process.env.STANDARD_SITE_SHOW_IN_DISCOVER !== 'false',
72+
icon: process.env.STANDARD_SITE_ICON || undefined,
6773
dryRun: process.env.STANDARD_SITE_DRY_RUN === 'true',
6874
};
6975
}
7076

77+
/** Guess an image MIME type from a URL or file path. */
78+
function iconMimeType(source: string): string {
79+
const ext = source.split('?')[0].split('.').pop()?.toLowerCase();
80+
switch (ext) {
81+
case 'png': return 'image/png';
82+
case 'jpg':
83+
case 'jpeg': return 'image/jpeg';
84+
case 'webp': return 'image/webp';
85+
default: throw new Error(`standard.site: unsupported icon type ".${ext}" (${source})`);
86+
}
87+
}
88+
89+
/** Load icon bytes from an http(s) URL or a local file path. */
90+
async function loadIconBytes(source: string): Promise<Uint8Array> {
91+
if (/^https?:\/\//i.test(source)) {
92+
const response = await fetch(source);
93+
if (!response.ok) {
94+
throw new Error(`standard.site: cannot fetch icon ${source}: ${response.status}`);
95+
}
96+
return new Uint8Array(await response.arrayBuffer());
97+
}
98+
return new Uint8Array(await readFile(source));
99+
}
100+
71101
/** Normalise a YAML date (ISO string or date-only) to an ISO 8601 datetime. */
72102
function toIsoDateTime(value: string): string {
73103
return new Date(value).toISOString();
@@ -117,6 +147,15 @@ async function upsertPublication(
117147
record.description = config.description;
118148
}
119149

150+
if (config.icon) {
151+
if (config.dryRun) {
152+
console.log(` [dry-run] would upload publication icon from ${config.icon}`);
153+
} else {
154+
const bytes = await loadIconBytes(config.icon);
155+
record.icon = await uploadBlob(config.pds, session, bytes, iconMimeType(config.icon));
156+
}
157+
}
158+
120159
if (config.dryRun) {
121160
console.log(` [dry-run] would upsert publication ${PUBLICATION_RKEY} -> ${config.url} (${config.name})`);
122161
} else {

0 commit comments

Comments
 (0)