A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 12 kB View raw
1import type { Agent } from '@atproto/api'; 2import { TID } from '@atproto/common-web'; 3import { 4 buildDocumentRecord, 5 buildBskyPost, 6 assertBskyPostWithinLimit, 7 canonicalArticleUrl, 8 type StrongRef, 9} from './records'; 10import { listPublications } from './publications'; 11import { firstImageBlobRef, normalizeBlobRefJson, type BlobRefJson } from '../media/blob'; 12import { blocksToText, type BlockNode } from '../blocks/render'; 13import { deriveExcerpt } from './excerpt'; 14import { collectMentions } from './mentions'; 15 16const DOCUMENT_COLLECTION = 'site.standard.document'; 17const POST_COLLECTION = 'app.bsky.feed.post'; 18 19/** createRecord types `record` as an open index signature; our records are precise. */ 20function asRecord( value: object ): Record< string, unknown > { 21 return value as Record< string, unknown >; 22} 23 24/** rkey is the last segment of an AT-URI. */ 25function rkeyFromUri( uri: string ): string { 26 return uri.split( '/' ).pop() ?? ''; 27} 28 29export interface Identity { 30 did: string; 31 /** Handle for human-readable URLs; falls back to DID if unresolved. */ 32 handle: string | null; 33} 34 35export interface PublishInput { 36 title: string; 37 blocks: BlockNode[]; 38 description?: string; 39 /** Optional explicit per-article cover; overrides the first-content-image thumb. */ 40 coverImage?: BlobRefJson; 41 /** The chosen target publication's AT-URI (Decision 0010 — no more auto-create). */ 42 publicationUri: string; 43 /** Its current cid, for the post's `associatedRefs` strongRef (Decision 0013). */ 44 publicationCid: string; 45 /** Its frozen slug, needed to build the article URL. */ 46 publicationSlug: string; 47} 48 49export interface PublishResult { 50 publicationUri: string; 51 documentUri: string; 52 postUri: string; 53 articleUrl: string; 54} 55 56/** 57 * The publish flow (Decision 0005), targeting a CHOSEN publication (Decision 0010) and embedding 58 * the standard.site link card refs in the Bluesky post (Decision 0013). 59 * 60 * Three writes, in an order that satisfies the mutual references: 61 * 1. Create the DOCUMENT (no `bskyPostRef` yet) — yields its strongRef (uri + cid). 62 * 2. Create the POST with a link facet + `associatedRefs` to the document & publication. 63 * 3. `putRecord` the document to add `bskyPostRef` (kept for unpublish/cascade-delete). 64 * Step 3 re-cids the document, so the post's document ref is one version stale — harmless, as the 65 * AppView resolves `associatedRefs` by URI (this is how standard.site itself behaves). 66 * 67 * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that unmistakable to 68 * the user first (brief §10). 69 */ 70export async function publish( 71 agent: Agent, 72 identity: Identity, 73 input: PublishInput 74): Promise< PublishResult > { 75 const { did } = identity; 76 const handle = identity.handle ?? did; 77 const now = new Date().toISOString(); 78 // Generate the document's rkey up front so the article URL (which the Bluesky post 79 // embeds) is known before either record is written — no circular dependency. 80 const rkey = TID.nextStr(); 81 const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, rkey ); 82 const textContent = blocksToText( input.blocks ); 83 // When the writer leaves the lede blank, derive a brief excerpt from the body so the 84 // document description AND the Bluesky card description are never empty (the og:image's 85 // text twin). Written into the record, not just the rendered meta. 86 const description = input.description?.trim() || deriveExcerpt( textContent ); 87 const mentions = collectMentions( input.blocks ); 88 // The post BODY uses the writer-typed lede only (never the derived excerpt). 89 const bodyLede = input.description?.trim() || undefined; 90 91 // Pre-flight the companion post's length BEFORE writing anything: if it's over the 92 // grapheme limit, fail here rather than after step 1 has already created an orphan 93 // document with no post (buildBskyPost repeats this check as a backstop). 94 assertBskyPostWithinLimit( { title: input.title, articleUrl, bodyLede, mentions } ); 95 96 // 1. Document first (no bskyPostRef yet) so the post can embed its strongRef. 97 const docRes = await agent.com.atproto.repo.createRecord( { 98 repo: did, 99 collection: DOCUMENT_COLLECTION, 100 rkey, 101 record: asRecord( 102 buildDocumentRecord( { 103 title: input.title, 104 rkey, 105 blocks: input.blocks, 106 textContent, 107 siteUri: input.publicationUri, 108 publishedAt: now, 109 coverImage: input.coverImage, 110 description, 111 mentions, 112 } ) 113 ), 114 } ); 115 const documentRef: StrongRef = { uri: docRes.data.uri, cid: docRes.data.cid }; 116 const publicationRef: StrongRef = { uri: input.publicationUri, cid: input.publicationCid }; 117 118 // 2. Post with the clickable link facet + standard.site associatedRefs (document, publication). 119 const postRes = await agent.com.atproto.repo.createRecord( { 120 repo: did, 121 collection: POST_COLLECTION, 122 record: asRecord( 123 buildBskyPost( { 124 title: input.title, 125 articleUrl, 126 description, 127 bodyLede, 128 mentions, 129 createdAt: now, 130 // Prefer the writer's explicit cover; else reuse the first uploaded image (Decision 0014). 131 thumb: input.coverImage ?? firstImageBlobRef( input.blocks ), 132 associatedRefs: [ documentRef, publicationRef ], 133 } ) 134 ), 135 } ); 136 const bskyPostRef: StrongRef = { uri: postRes.data.uri, cid: postRes.data.cid }; 137 138 // 3. Point the document back at its companion post (preserved for unpublish/cascade-delete). 139 await agent.com.atproto.repo.putRecord( { 140 repo: did, 141 collection: DOCUMENT_COLLECTION, 142 rkey, 143 record: asRecord( 144 buildDocumentRecord( { 145 title: input.title, 146 rkey, 147 blocks: input.blocks, 148 textContent, 149 siteUri: input.publicationUri, 150 publishedAt: now, 151 coverImage: input.coverImage, 152 description, 153 bskyPostRef, 154 mentions, 155 } ) 156 ), 157 } ); 158 159 return { 160 publicationUri: input.publicationUri, 161 documentUri: docRes.data.uri, 162 postUri: postRes.data.uri, 163 articleUrl, 164 }; 165} 166 167export interface UpdateInput { 168 title: string; 169 blocks: BlockNode[]; 170 description?: string; 171 /** Optional explicit per-article cover (design 2026-06-10). */ 172 coverImage?: BlobRefJson; 173 /** The existing document's record key (URL stays stable, Decision 0008). */ 174 rkey: string; 175 /** Preserved from the original publish. */ 176 siteUri: string; 177 /** The owning publication's frozen slug, for the article URL. */ 178 publicationSlug: string; 179 publishedAt: string; 180 bskyPostRef?: StrongRef; 181} 182 183/** 184 * Edit a published article in place (Decision 0008): `putRecord` on the SAME rkey, stamping 185 * `updatedAt` while preserving `publishedAt` + `bskyPostRef`. Does NOT create a new Bluesky 186 * post — the original post keeps pointing at the (now-updated) URL. 187 */ 188export async function updateDocument( 189 agent: Agent, 190 identity: Identity, 191 input: UpdateInput 192): Promise< { documentUri: string; articleUrl: string } > { 193 const { did } = identity; 194 const handle = identity.handle ?? did; 195 const now = new Date().toISOString(); 196 const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, input.rkey ); 197 const textContent = blocksToText( input.blocks ); 198 const description = input.description?.trim() || deriveExcerpt( textContent ); 199 const mentions = collectMentions( input.blocks ); 200 201 const res = await agent.com.atproto.repo.putRecord( { 202 repo: did, 203 collection: DOCUMENT_COLLECTION, 204 rkey: input.rkey, 205 record: asRecord( 206 buildDocumentRecord( { 207 title: input.title, 208 rkey: input.rkey, 209 blocks: input.blocks, 210 textContent, 211 siteUri: input.siteUri, 212 publishedAt: input.publishedAt, 213 coverImage: input.coverImage, 214 description, 215 bskyPostRef: input.bskyPostRef, 216 updatedAt: now, 217 mentions, 218 } ) 219 ), 220 } ); 221 return { documentUri: res.data.uri, articleUrl }; 222} 223 224/** 225 * Unpublish an article (Decision 0008): delete the document AND its companion Bluesky post. 226 * The publication record is left intact. 227 */ 228export async function unpublish( 229 agent: Agent, 230 did: string, 231 input: { rkey: string; bskyPostRef?: StrongRef } 232): Promise< void > { 233 await agent.com.atproto.repo.deleteRecord( { 234 repo: did, 235 collection: DOCUMENT_COLLECTION, 236 rkey: input.rkey, 237 } ); 238 if ( input.bskyPostRef?.uri ) { 239 try { 240 await agent.com.atproto.repo.deleteRecord( { 241 repo: did, 242 collection: POST_COLLECTION, 243 rkey: rkeyFromUri( input.bskyPostRef.uri ), 244 } ); 245 } catch { 246 // the post may already be gone; the document deletion is what matters 247 } 248 } 249} 250 251export interface MyArticle { 252 rkey: string; 253 title: string; 254 description?: string; 255 publishedAt?: string; 256 updatedAt?: string; 257 /** The owning publication's AT-URI (`doc.site`). */ 258 siteUri: string; 259 /** The owning publication's frozen slug (for building URLs + the update call). */ 260 siteSlug: string; 261 bskyPostRef?: StrongRef; 262 /** The stored per-article cover blob ref, normalized for re-preview on edit. */ 263 coverImage?: BlobRefJson; 264 blocks: BlockNode[]; 265} 266 267interface RawDocValue { 268 title?: string; 269 description?: string; 270 publishedAt?: string; 271 updatedAt?: string; 272 site?: string; 273 bskyPostRef?: StrongRef; 274 coverImage?: unknown; 275 content?: { blocks?: BlockNode[] }; 276} 277 278function toMyArticle( 279 record: { uri: string; value: unknown }, 280 siteUri: string, 281 siteSlug: string 282): MyArticle { 283 const value = record.value as RawDocValue; 284 return { 285 rkey: rkeyFromUri( record.uri ), 286 title: value.title ?? 'Untitled', 287 description: value.description, 288 publishedAt: value.publishedAt, 289 updatedAt: value.updatedAt, 290 siteUri, 291 siteSlug, 292 bskyPostRef: value.bskyPostRef, 293 coverImage: normalizeBlobRefJson( value.coverImage ), 294 blocks: value.content?.blocks ?? [], 295 }; 296} 297 298/** List one publication's documents, annotated with its slug (the per-publication Posts tab). */ 299export async function listPublicationArticles( 300 agent: Agent, 301 did: string, 302 publication: { uri: string; slug: string } 303): Promise< MyArticle[] > { 304 const docs = await agent.com.atproto.repo.listRecords( { 305 repo: did, 306 collection: DOCUMENT_COLLECTION, 307 limit: 100, 308 } ); 309 return docs.data.records 310 .filter( ( record ) => ( record.value as RawDocValue )?.site === publication.uri ) 311 .map( ( record ) => toMyArticle( record, publication.uri, publication.slug ) ); 312} 313 314/** 315 * List ALL the writer's SkyPress articles across their publications, each annotated with its 316 * publication slug. Documents whose `site` isn't one of the writer's known SkyPress 317 * publications (orphans, or another tool's) are dropped — the editor only edits its own. 318 */ 319export async function listAllMyArticles( agent: Agent, did: string ): Promise< MyArticle[] > { 320 const pubs = await listPublications( agent, did ); 321 const bySite = new Map( pubs.map( ( pub ) => [ pub.uri, pub ] as const ) ); 322 const docs = await agent.com.atproto.repo.listRecords( { 323 repo: did, 324 collection: DOCUMENT_COLLECTION, 325 limit: 100, 326 } ); 327 return docs.data.records 328 .map( ( record ) => { 329 const site = ( record.value as RawDocValue )?.site; 330 const pub = site ? bySite.get( site ) : undefined; 331 return pub ? toMyArticle( record, pub.uri, pub.slug ) : null; 332 } ) 333 .filter( ( article ): article is MyArticle => article !== null ); 334} 335 336/** 337 * Fetch a single SkyPress article by rkey (the editor's `?edit=` load). Reuses 338 * `listAllMyArticles` so it inherits the same slug annotation and foreign/orphan 339 * filtering; returns null when no owned document has that rkey. 340 */ 341export async function getMyArticle( 342 agent: Agent, 343 did: string, 344 rkey: string 345): Promise< MyArticle | null > { 346 const all = await listAllMyArticles( agent, did ); 347 return all.find( ( article ) => article.rkey === rkey ) ?? null; 348}