A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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}