A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1/**
2 * The read-context module: one deep interface for the public read routes.
3 *
4 * Every read route used to hand-assemble the same spine — strip the `@` from the
5 * URL param, resolve handle → DID → PDS, match a publication by slug, join documents
6 * by `value.site === publication.uri`, and map each miss to an error scene. That
7 * orchestration (and its caller knowledge: the join key, the fetch cap, the blob-ref
8 * shape behind logo URLs) now lives here, once, behind three entry points:
9 *
10 * resolveAuthorContext( author ) — `/@handle`
11 * resolvePublicationContext( author, slug ) — `/@handle/{slug}` + rss.xml
12 * resolveArticleContext( author, slug, rkey ) — `/@handle/{slug}/{rkey}`
13 *
14 * Each returns `{ ok: true, context }` or `{ ok: false, error }` where `error` is
15 * ready-made `ErrorSceneCopy` (status + page copy), so routes keep only templates.
16 * All PDS fetches stay SSRF-guarded via `records.ts`/`identity.ts` underneath.
17 */
18import { resolveAuthor } from './identity';
19import { getRecord, listRecords } from './records';
20import {
21 listAllReaderPublications,
22 type ReaderPublication,
23 type ReaderForeignPublication,
24} from './publications';
25import { fetchActorProfile, type ActorProfile } from './profile';
26import { buildGetBlobUrl } from '../media/blob';
27import { errorScene, type ErrorSceneCopy } from './errors';
28import type { BlockNode } from '../blocks/render';
29
30const DOCUMENT_COLLECTION = 'site.standard.document';
31
32/**
33 * One `listRecords` page of the writer's documents (across all their publications).
34 * A writer with more total documents than this could have older items missing from
35 * publication pages and feeds — a known, deliberate bound (Decision 0011; the feed
36 * additionally keeps only the newest `FEED_ITEM_LIMIT` after the site-join).
37 */
38const DOCUMENT_FETCH_LIMIT = 100;
39
40export interface ReadAuthor {
41 handle: string;
42 did: string;
43 pdsUrl: string;
44}
45
46/** A publication with its logo already resolved to a fetchable URL (or null). */
47export type PublicationView = ReaderPublication & { logoUrl: string | null };
48export type ForeignPublicationView = ReaderForeignPublication & { logoUrl: string | null };
49
50/** The slice of a `site.standard.document` value the read routes consume. */
51export interface SkyDocumentValue {
52 title?: string;
53 description?: string;
54 textContent?: string;
55 publishedAt?: string;
56 updatedAt?: string;
57 site?: string;
58 content?: { blocks?: BlockNode[] };
59 /** strongRef to the companion `app.bsky.feed.post` — the target of reader actions. */
60 bskyPostRef?: { uri: string; cid: string };
61}
62
63export interface PublicationDocument {
64 uri: string;
65 cid: string;
66 rkey: string;
67 value: SkyDocumentValue;
68}
69
70export interface AuthorReadContext {
71 author: ReadAuthor;
72 profile: ActorProfile;
73 publications: PublicationView[];
74 foreign: ForeignPublicationView[];
75}
76
77export interface PublicationReadContext {
78 author: ReadAuthor;
79 profile: ActorProfile;
80 publication: PublicationView;
81 /** This publication's documents only (site-joined), in PDS order (newest first). */
82 documents: PublicationDocument[];
83}
84
85export interface ArticleReadContext {
86 author: ReadAuthor;
87 profile: ActorProfile;
88 publication: PublicationView;
89 document: PublicationDocument;
90}
91
92export type ReadContextResult< T > =
93 | { ok: true; context: T }
94 | { ok: false; error: ErrorSceneCopy };
95
96function notFound< T >( error: ErrorSceneCopy ): ReadContextResult< T > {
97 return { ok: false, error };
98}
99
100/** The shared spine: validate the `@handle` param and resolve it to a DID + PDS. */
101async function resolveReadAuthor(
102 authorParam: string | undefined
103): Promise< { author: ReadAuthor } | { error: ErrorSceneCopy } > {
104 if ( ! authorParam || ! authorParam.startsWith( '@' ) ) {
105 return { error: errorScene( 'not-found' ) };
106 }
107 const handle = authorParam.slice( 1 );
108 const resolved = await resolveAuthor( handle );
109 if ( ! resolved ) {
110 return { error: errorScene( 'writer-not-found', { handle } ) };
111 }
112 return { author: { handle, did: resolved.did, pdsUrl: resolved.pdsUrl } };
113}
114
115function withLogoUrl< T extends { icon: ReaderPublication[ 'icon' ] } >(
116 author: ReadAuthor,
117 publication: T
118): T & { logoUrl: string | null } {
119 return {
120 ...publication,
121 logoUrl: publication.icon
122 ? buildGetBlobUrl( author.pdsUrl, author.did, publication.icon.ref.$link )
123 : null,
124 };
125}
126
127function toPublicationDocument(
128 record: { uri: string; cid: string; value: SkyDocumentValue }
129): PublicationDocument {
130 return {
131 uri: record.uri,
132 cid: record.cid,
133 rkey: record.uri.split( '/' ).pop() ?? '',
134 value: record.value,
135 };
136}
137
138export async function resolveAuthorContext(
139 authorParam: string | undefined
140): Promise< ReadContextResult< AuthorReadContext > > {
141 const spine = await resolveReadAuthor( authorParam );
142 if ( 'error' in spine ) {
143 return notFound( spine.error );
144 }
145 const { author } = spine;
146
147 const [ profile, allPublications ] = await Promise.all( [
148 fetchActorProfile( author.pdsUrl, author.did ),
149 listAllReaderPublications( author.pdsUrl, author.did ),
150 ] );
151
152 return {
153 ok: true,
154 context: {
155 author,
156 profile,
157 publications: allPublications.owned.map( ( pub ) => withLogoUrl( author, pub ) ),
158 foreign: allPublications.foreign.map( ( pub ) => withLogoUrl( author, pub ) ),
159 },
160 };
161}
162
163export async function resolvePublicationContext(
164 authorParam: string | undefined,
165 slug: string | undefined
166): Promise< ReadContextResult< PublicationReadContext > > {
167 if ( ! slug ) {
168 return notFound( errorScene( 'not-found' ) );
169 }
170 const spine = await resolveReadAuthor( authorParam );
171 if ( 'error' in spine ) {
172 return notFound( spine.error );
173 }
174 const { author } = spine;
175
176 // The publication match, the profile, and the document page are independent
177 // fetches — run them together rather than serially like the old pages did.
178 const [ profile, allPublications, documentRecords ] = await Promise.all( [
179 fetchActorProfile( author.pdsUrl, author.did ),
180 listAllReaderPublications( author.pdsUrl, author.did ),
181 listRecords< SkyDocumentValue >(
182 author.pdsUrl,
183 author.did,
184 DOCUMENT_COLLECTION,
185 DOCUMENT_FETCH_LIMIT
186 ),
187 ] );
188
189 const matched = allPublications.owned.find( ( pub ) => pub.slug === slug );
190 if ( ! matched ) {
191 return notFound( errorScene( 'publication-not-found', { handle: author.handle, slug } ) );
192 }
193 const publication = withLogoUrl( author, matched );
194
195 const documents = documentRecords
196 .filter( ( record ) => record.value.site === publication.uri )
197 .map( toPublicationDocument );
198
199 return { ok: true, context: { author, profile, publication, documents } };
200}
201
202export async function resolveArticleContext(
203 authorParam: string | undefined,
204 slug: string | undefined,
205 rkey: string | undefined
206): Promise< ReadContextResult< ArticleReadContext > > {
207 if ( ! slug || ! rkey ) {
208 return notFound( errorScene( 'not-found' ) );
209 }
210 const spine = await resolveReadAuthor( authorParam );
211 if ( 'error' in spine ) {
212 return notFound( spine.error );
213 }
214 const { author } = spine;
215
216 const [ profile, allPublications, record ] = await Promise.all( [
217 fetchActorProfile( author.pdsUrl, author.did ),
218 listAllReaderPublications( author.pdsUrl, author.did ),
219 getRecord< SkyDocumentValue >( author.pdsUrl, author.did, DOCUMENT_COLLECTION, rkey ),
220 ] );
221
222 const matched = allPublications.owned.find( ( pub ) => pub.slug === slug );
223 if ( ! matched ) {
224 return notFound( errorScene( 'publication-not-found', { handle: author.handle, slug } ) );
225 }
226 const publication = withLogoUrl( author, matched );
227
228 // The site-join: a document renders under a publication only when it points back
229 // at that publication's record. A real record under another publication 404s here.
230 if ( ! record?.value || record.value.site !== publication.uri ) {
231 return notFound( errorScene( 'article-not-found', { publicationName: publication.name } ) );
232 }
233
234 return {
235 ok: true,
236 context: { author, profile, publication, document: toPublicationDocument( record ) },
237 };
238}