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 8.0 kB View raw
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}