From 20c612e10d413855945f2437d711f754fc6ee71e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 19 Sep 2026 18:52:12 +0100 Subject: [PATCH] feat: sync icon, theme, cover image and bluesky ref to standard.site --- modules/markdown.ts | 2 + modules/sync/providers/index.ts | 2 + modules/sync/providers/standard-site.ts | 117 +++++++++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/modules/markdown.ts b/modules/markdown.ts index 9f343926..14a88c36 100644 --- a/modules/markdown.ts +++ b/modules/markdown.ts @@ -225,6 +225,7 @@ export default defineNuxtModule({ text_content: htmlToText(post.meta.html, { wordwrap: false }), canonical_url: `https://roe.dev${post.path}/`, tags: post.data.tags.length ? post.data.tags : undefined, + bluesky: post.data.bluesky, } }) } @@ -423,6 +424,7 @@ declare module '@nuxt/schema' { text_content: string canonical_url: string tags?: string[] + bluesky?: string }>>) => void } } diff --git a/modules/sync/providers/index.ts b/modules/sync/providers/index.ts index 87a1bb6e..68c355be 100644 --- a/modules/sync/providers/index.ts +++ b/modules/sync/providers/index.ts @@ -12,6 +12,8 @@ export interface SyncItem { type: 'blog' | 'talk' | 'article' | 'event' | 'hackathon' | 'oss' | 'video' | 'forum' | 'other' date?: string tags?: string[] + /** `at://` URI of the Bluesky post announcing this item, if there is one. */ + bluesky?: string } export interface SyncOptions { diff --git a/modules/sync/providers/standard-site.ts b/modules/sync/providers/standard-site.ts index 0fd7492a..717c334e 100644 --- a/modules/sync/providers/standard-site.ts +++ b/modules/sync/providers/standard-site.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + import { Client } from '@atproto/lex' import type { LexMap } from '@atproto/lex' import { PasswordSession } from '@atproto/lex-password-session' @@ -36,13 +39,19 @@ export class StandardSiteProvider implements SyncProvider { const did = client.assertDid - await client.putRecord({ + const publication: LexMap = { $type: 'site.standard.publication', url: 'https://roe.dev', name: 'Daniel Roe', description: 'The personal website of Daniel Roe', preferences: { showInDiscover: true }, - }, publicationRkey) + basicTheme, + } + + const icon = await resolvePublicationIcon(client) + if (icon) publication.icon = icon + + await client.putRecord(publication as LexMap & { $type: 'site.standard.publication' }, publicationRkey) // Delete legacy 'self' rkey publication record if it exists try { @@ -61,6 +70,7 @@ export class StandardSiteProvider implements SyncProvider { ) // Delete any existing records that don't match a current blog post's TID + const previous = new Map() try { const existing = await client.listRecords('site.standard.document', { repo: did, limit: 100 }) for (const record of existing.body.records) { @@ -68,7 +78,9 @@ export class StandardSiteProvider implements SyncProvider { if (!expectedRkeys.has(rkey)) { console.info(`[sync:standard-site] Deleting stale record with rkey: ${rkey}`) await client.deleteRecord('site.standard.document', rkey) + continue } + previous.set(rkey, record.value as LexMap) } } catch (error) { @@ -96,6 +108,14 @@ export class StandardSiteProvider implements SyncProvider { if (item.tags?.length) record.tags = item.tags if (item.text_content) record.textContent = item.text_content + const existing = previous.get(rkey) + + const bskyPostRef = await resolveBskyPostRef(client, did, item.bluesky, existing) + if (bskyPostRef) record.bskyPostRef = bskyPostRef + + const coverImage = await resolveCoverImage(client, item.canonical_url, existing) + if (coverImage) record.coverImage = coverImage + await client.putRecord(record as LexMap & { $type: 'site.standard.document' }, rkey) updated++ @@ -104,3 +124,96 @@ export class StandardSiteProvider implements SyncProvider { console.info(`[sync:standard-site] Done: ${updated} updated`) } } + +const rgb = (r: number, g: number, b: number) => ({ $type: 'site.standard.theme.color#rgb', r, g, b }) + +/** Light-mode palette from `app/assets/main.css`. */ +const basicTheme = { + background: rgb(229, 231, 235), + foreground: rgb(31, 41, 55), + accent: rgb(31, 41, 55), + accentForeground: rgb(243, 244, 246), +} + +const iconPath = fileURLToPath(new URL('../../../public/android-chrome-512x512.png', import.meta.url)) + +/** + * Reuses the blob already referenced by the publication record where possible, + * as re-uploading produces a new CID and invalidates consumers' cached icons. + */ +async function resolvePublicationIcon (client: Client) { + try { + const existing = await client.getRecord('site.standard.publication', publicationRkey) + const icon = (existing.body.value as LexMap).icon + if (icon) return icon + } + catch { + // no publication record yet + } + + try { + const data = await readFile(iconPath) + const upload = await client.uploadBlob(new Uint8Array(data), { encoding: 'image/png' }) + return upload.body.blob + } + catch (error) { + console.warn('[sync:standard-site] Failed to upload publication icon:', error instanceof Error ? error.message : error) + } +} + +/** Max blob size accepted by `site.standard.document#coverImage`. */ +const MAX_COVER_BYTES = 1_000_000 + +/** + * A strong ref needs the post's CID as well as its URI, so the announcement + * post is read back from the repo unless the stored ref already points at it. + */ +async function resolveBskyPostRef (client: Client, did: string, uri: string | undefined, existing: LexMap | undefined) { + if (!uri) return + + const stored = existing?.bskyPostRef as { uri?: string } | undefined + if (stored?.uri === uri) return stored + + const [, , repo, collection, rkey] = uri.split('/') + if (repo !== did || collection !== 'app.bsky.feed.post' || !rkey) return + + try { + const post = await client.getRecord(collection, rkey) + if (!post.body.cid) return + return { uri: post.body.uri, cid: post.body.cid } + } + catch (error) { + console.warn(`[sync:standard-site] Failed to resolve Bluesky post ${uri}:`, error instanceof Error ? error.message : error) + } +} + +/** + * Uses the page's own Open Graph image as the cover. It is read from the + * deployed site rather than generated here, as og images are rendered during + * prerender and do not exist on disk while this runs; a post published in this + * deploy therefore picks its cover up on the next one. + */ +async function resolveCoverImage (client: Client, canonicalURL: string, existing: LexMap | undefined) { + const stored = existing?.coverImage + if (stored) return stored + + try { + const html = await fetch(canonicalURL).then(r => r.ok ? r.text() : null) + const url = html?.match(/ MAX_COVER_BYTES) return + + const upload = await client.uploadBlob(data, { encoding }) + return upload.body.blob + } + catch (error) { + console.warn(`[sync:standard-site] Failed to upload cover image for ${canonicalURL}:`, error instanceof Error ? error.message : error) + } +} -- 2.51.2