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.

Reader: extract a read-context module to own the read-route spine

Every read route (author index, publication home, article, RSS)
hand-assembled the same orchestration in its frontmatter: strip the
@ from the URL param, resolve handle -> DID -> PDS, match a
publication by slug, join documents by value.site ===
publication.uri, and map each miss to an error scene. That was ~230
near-duplicate lines across four files, and the join key, the
100-document fetch bound, and the blob-ref -> logo-URL construction
were caller knowledge repeated 3-4x. Living in .astro frontmatter,
none of it was unit-testable - the colocated tests pinned the page
source with regexes instead of behaviour.

resolveAuthorContext / resolvePublicationContext /
resolveArticleContext (src/lib/reader/read-context.ts) now own that
spine behind one interface returning either a fully-shaped context
or ready-made ErrorSceneCopy; the routes keep only presentation.
Publications come back with logoUrl prebuilt so callers never touch
BlobRefJson internals, and the per-route independent fetches
(profile, publication list, documents) run in parallel where the
pages ran them serially.

The module is behaviourally tested in read-context.test.ts (mocking
only the network seam: identity.ts + records.ts); the source-pin
tests shrank to template-wiring guards. The now-unused
listReaderPublications / resolveReaderPublication wrappers are
deleted. Decision 0016 records the seam.

Side effect: RSS 404 bodies are a uniform "Not found" instead of
echoing the handle.

+665 -338
+46
docs/decisions/0016-read-context-module.md
··· 1 + # 0016 — Read routes delegate orchestration to the read-context module 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-10 5 + - **Scope:** the public read path (`src/pages/[author]/**`, `src/lib/reader/read-context.ts`) 6 + 7 + ## Context 8 + 9 + Every read route (author index, publication home, article, RSS) hand-assembled the same 10 + resolution spine in its frontmatter: strip the `@` from the URL param, resolve 11 + handle → DID → PDS (Decision 0007), match a publication by slug, join documents by 12 + `value.site === publication.uri` (Decision 0010), and map each miss to an error scene. 13 + That was ~230 near-duplicate lines across four files, and the join key, the 100-document 14 + fetch bound, and the blob-ref → logo-URL construction were caller knowledge repeated 15 + 3–4×. Because the orchestration lived in `.astro` frontmatter, it could only be "tested" 16 + by regex pins against the page source (see rule 8 in AGENTS.md for why container 17 + rendering is out). 18 + 19 + ## Decision 20 + 21 + One deep module, `src/lib/reader/read-context.ts`, owns the spine behind three entry 22 + points — `resolveAuthorContext`, `resolvePublicationContext`, `resolveArticleContext` — 23 + each returning `{ ok: true, context }` or `{ ok: false, error: ErrorSceneCopy }`. 24 + 25 + - **Pages keep only presentation.** A new read surface (AMP, embeds, …) must call the 26 + read-context interface, not re-assemble `resolveAuthor` + slug-match + site-join. 27 + - The module returns publications with `logoUrl` prebuilt, so callers never touch 28 + `BlobRefJson` internals. 29 + - The per-route independent fetches (profile, publication list, documents/record) run 30 + in parallel inside the module; the old pages ran them serially. 31 + - The article render pipeline (`resolveBlobImageUrls → renderBlocks → 32 + sanitizeArticleHtml`) deliberately stays in the article page / feed builder — sealing 33 + it behind its own interface is a separate, orthogonal deepening. 34 + - The shallow wrappers this obsoleted (`listReaderPublications`, 35 + `resolveReaderPublication`) were deleted; `listAllReaderPublications` remains the one 36 + reader-side publication fetch. 37 + 38 + ## Consequences 39 + 40 + - The orchestration is behaviourally unit-tested in `read-context.test.ts` (mocking only 41 + the network seam: `identity.ts` + `records.ts`); the colocated `_*.meta.test.ts` source 42 + pins shrank to template-wiring guards. 43 + - On not-found paths the module may fetch slightly more than the old serial code did 44 + (the parallel fetches start before the publication match is known) — accepted for the 45 + happy-path latency win. 46 + - RSS 404 bodies are now a uniform `Not found` instead of echoing the handle.
+11 -32
src/lib/reader/publications.test.ts
··· 1 1 import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 - import { 3 - listReaderPublications, 4 - listAllReaderPublications, 5 - resolveReaderPublication, 6 - } from './publications'; 2 + import { listAllReaderPublications } from './publications'; 7 3 import { listRecords } from './records'; 8 4 import { SITE_BASE } from '../publish/records'; 9 5 import { THEME_PRESETS } from '../publish/themes'; ··· 20 16 21 17 beforeEach( () => mockedList.mockReset() ); 22 18 23 - describe( 'listReaderPublications', () => { 24 - it( 'keeps only SkyPress-origin, slugged publications', async () => { 19 + describe( 'listAllReaderPublications', () => { 20 + it( 'keeps only SkyPress-origin, slugged publications as owned', async () => { 25 21 mockedList.mockResolvedValue( [ 26 22 rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog`, { description: ' Hi ' } ), 27 23 rec( 'b', 'https://leaflet.pub/x/y' ), 28 24 rec( 'c', `${ SITE_BASE }/@me.bsky.social` ), 29 25 ] ); 30 - const pubs = await listReaderPublications( PDS, DID ); 31 - expect( pubs ).toHaveLength( 1 ); 32 - expect( pubs[ 0 ] ).toMatchObject( { 26 + const { owned } = await listAllReaderPublications( PDS, DID ); 27 + expect( owned ).toHaveLength( 1 ); 28 + expect( owned[ 0 ] ).toMatchObject( { 33 29 uri: `at://${ DID }/site.standard.publication/a`, 34 30 slug: 'blog', 35 31 name: 'Pa', 36 32 description: 'Hi', 37 33 } ); 38 34 } ); 39 - } ); 40 35 41 - describe( 'listAllReaderPublications', () => { 42 36 it( 'partitions owned SkyPress publications from foreign ones', async () => { 43 37 const icon = { ref: { $link: 'bafyicon' }, mimeType: 'image/png', size: 1 }; 44 38 mockedList.mockResolvedValue( [ ··· 70 64 } ); 71 65 } ); 72 66 73 - describe( 'resolveReaderPublication', () => { 74 - it( 'finds the publication whose slug matches', async () => { 75 - mockedList.mockResolvedValue( [ 76 - rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog` ), 77 - rec( 'b', `${ SITE_BASE }/@me.bsky.social/notes` ), 78 - ] ); 79 - const pub = await resolveReaderPublication( PDS, DID, 'notes' ); 80 - expect( pub?.uri ).toBe( `at://${ DID }/site.standard.publication/b` ); 81 - } ); 82 - 83 - it( 'returns null when no slug matches', async () => { 84 - mockedList.mockResolvedValue( [ rec( 'a', `${ SITE_BASE }/@me.bsky.social/blog` ) ] ); 85 - expect( await resolveReaderPublication( PDS, DID, 'missing' ) ).toBeNull(); 86 - } ); 87 - } ); 88 - 89 67 describe( 'reader basicTheme', () => { 90 68 it( 'surfaces a valid stored basicTheme', async () => { 91 69 mockedList.mockResolvedValue( [ ··· 93 71 basicTheme: THEME_PRESETS[ 5 ].colors, // morning 94 72 } ), 95 73 ] ); 96 - const pub = await resolveReaderPublication( PDS, DID, 'blog' ); 97 - expect( pub?.basicTheme ).toEqual( THEME_PRESETS[ 5 ].colors ); 74 + const { owned } = await listAllReaderPublications( PDS, DID ); 75 + expect( owned[ 0 ].basicTheme ).toEqual( THEME_PRESETS[ 5 ].colors ); 98 76 } ); 99 77 100 78 it( 'sets basicTheme to null when absent or malformed', async () => { ··· 102 80 rec( 'a', `${ SITE_BASE }/@me.bsky.social/plain` ), 103 81 rec( 'b', `${ SITE_BASE }/@me.bsky.social/broken`, { basicTheme: { accent: 'red' } } ), 104 82 ] ); 105 - expect( ( await resolveReaderPublication( PDS, DID, 'plain' ) )?.basicTheme ).toBeNull(); 106 - expect( ( await resolveReaderPublication( PDS, DID, 'broken' ) )?.basicTheme ).toBeNull(); 83 + const { owned } = await listAllReaderPublications( PDS, DID ); 84 + expect( owned.find( ( p ) => p.slug === 'plain' )?.basicTheme ).toBeNull(); 85 + expect( owned.find( ( p ) => p.slug === 'broken' )?.basicTheme ).toBeNull(); 107 86 } ); 108 87 } );
-17
src/lib/reader/publications.ts
··· 116 116 return { owned, foreign }; 117 117 } 118 118 119 - /** All of a writer's SkyPress publications (newest first) — for the author index. */ 120 - export async function listReaderPublications( 121 - pdsUrl: string, 122 - did: string 123 - ): Promise< ReaderPublication[] > { 124 - return ( await listAllReaderPublications( pdsUrl, did ) ).owned; 125 - } 126 - 127 - /** Resolve one publication by its slug (the publication + document routes), or null. */ 128 - export async function resolveReaderPublication( 129 - pdsUrl: string, 130 - did: string, 131 - slug: string 132 - ): Promise< ReaderPublication | null > { 133 - const pubs = await listReaderPublications( pdsUrl, did ); 134 - return pubs.find( ( pub ) => pub.slug === slug ) ?? null; 135 - }
+229
src/lib/reader/read-context.test.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + import { 3 + resolveAuthorContext, 4 + resolvePublicationContext, 5 + resolveArticleContext, 6 + } from './read-context'; 7 + import { resolveAuthor } from './identity'; 8 + import { getRecord, listRecords } from './records'; 9 + import { buildGetBlobUrl } from '../media/blob'; 10 + import { SITE_BASE } from '../publish/records'; 11 + 12 + vi.mock( './identity', () => ( { resolveAuthor: vi.fn() } ) ); 13 + vi.mock( './records', () => ( { getRecord: vi.fn(), listRecords: vi.fn() } ) ); 14 + 15 + const mockedResolveAuthor = resolveAuthor as unknown as ReturnType< typeof vi.fn >; 16 + const mockedGetRecord = getRecord as unknown as ReturnType< typeof vi.fn >; 17 + const mockedListRecords = listRecords as unknown as ReturnType< typeof vi.fn >; 18 + 19 + const PDS = 'https://pds.example'; 20 + const DID = 'did:plc:me'; 21 + const HANDLE = 'me.bsky.social'; 22 + const ICON = { $type: 'blob', ref: { $link: 'bafyicon' }, mimeType: 'image/png', size: 1 }; 23 + 24 + const PUB_URI = `at://${ DID }/site.standard.publication/a`; 25 + 26 + function pubRecord( rkey: string, url: string, extra: Record< string, unknown > = {} ) { 27 + return { 28 + uri: `at://${ DID }/site.standard.publication/${ rkey }`, 29 + cid: 'c', 30 + value: { url, name: `P${ rkey }`, ...extra }, 31 + }; 32 + } 33 + 34 + function docRecord( rkey: string, site: string, extra: Record< string, unknown > = {} ) { 35 + return { 36 + uri: `at://${ DID }/site.standard.document/${ rkey }`, 37 + cid: 'c', 38 + value: { title: `T${ rkey }`, site, ...extra }, 39 + }; 40 + } 41 + 42 + /** Default happy-path PDS: one resolvable writer, two publications, two documents. */ 43 + function wirePds( { 44 + publications = [ 45 + pubRecord( 'a', `${ SITE_BASE }/@${ HANDLE }/blog`, { icon: ICON } ), 46 + pubRecord( 'b', `${ SITE_BASE }/@${ HANDLE }/notes` ), 47 + pubRecord( 'f', 'https://leaflet.pub/x/y', { name: 'Leafy' } ), 48 + ], 49 + documents = [ 50 + docRecord( 'd1', PUB_URI ), 51 + docRecord( 'd2', `at://${ DID }/site.standard.publication/b` ), 52 + ], 53 + profile = { displayName: ' Me ', description: 'bio' } as Record< string, unknown > | null, 54 + article = null as ReturnType< typeof docRecord > | null, 55 + } = {} ) { 56 + mockedResolveAuthor.mockResolvedValue( { did: DID, pdsUrl: PDS } ); 57 + mockedListRecords.mockImplementation( async ( _pds, _did, collection ) => 58 + collection === 'site.standard.publication' ? publications : documents 59 + ); 60 + mockedGetRecord.mockImplementation( async ( _pds, _did, collection ) => { 61 + if ( collection === 'app.bsky.actor.profile' ) { 62 + return profile ? { uri: 'at://profile', cid: 'c', value: profile } : null; 63 + } 64 + return article; 65 + } ); 66 + } 67 + 68 + beforeEach( () => { 69 + mockedResolveAuthor.mockReset(); 70 + mockedGetRecord.mockReset(); 71 + mockedListRecords.mockReset(); 72 + } ); 73 + 74 + describe( 'resolveAuthorContext', () => { 75 + it( 'maps a param without a leading @ to a generic 404', async () => { 76 + const result = await resolveAuthorContext( HANDLE ); 77 + expect( result.ok ).toBe( false ); 78 + if ( ! result.ok ) { 79 + expect( result.error.status ).toBe( 404 ); 80 + } 81 + expect( mockedResolveAuthor ).not.toHaveBeenCalled(); 82 + } ); 83 + 84 + it( 'maps a missing param to a generic 404', async () => { 85 + const result = await resolveAuthorContext( undefined ); 86 + expect( result.ok ).toBe( false ); 87 + } ); 88 + 89 + it( 'maps an unresolvable handle to writer-not-found', async () => { 90 + mockedResolveAuthor.mockResolvedValue( null ); 91 + const result = await resolveAuthorContext( `@${ HANDLE }` ); 92 + expect( result.ok ).toBe( false ); 93 + if ( ! result.ok ) { 94 + expect( result.error.status ).toBe( 404 ); 95 + expect( result.error.heading ).toContain( `@${ HANDLE }` ); 96 + } 97 + } ); 98 + 99 + it( 'degrades to an empty context when the PDS has no profile or publications', async () => { 100 + wirePds( { publications: [], documents: [], profile: null } ); 101 + const result = await resolveAuthorContext( `@${ HANDLE }` ); 102 + expect( result.ok ).toBe( true ); 103 + if ( ! result.ok ) { 104 + return; 105 + } 106 + expect( result.context.publications ).toEqual( [] ); 107 + expect( result.context.foreign ).toEqual( [] ); 108 + expect( result.context.profile.displayName ).toBeNull(); 109 + } ); 110 + 111 + it( 'returns the author, profile, and publications with prebuilt logo URLs', async () => { 112 + wirePds(); 113 + const result = await resolveAuthorContext( `@${ HANDLE }` ); 114 + expect( result.ok ).toBe( true ); 115 + if ( ! result.ok ) { 116 + return; 117 + } 118 + const { author, profile, publications, foreign } = result.context; 119 + expect( author ).toEqual( { handle: HANDLE, did: DID, pdsUrl: PDS } ); 120 + expect( profile.displayName ).toBe( 'Me' ); 121 + 122 + expect( publications ).toHaveLength( 2 ); 123 + expect( publications[ 0 ] ).toMatchObject( { 124 + slug: 'blog', 125 + logoUrl: buildGetBlobUrl( PDS, DID, 'bafyicon' ), 126 + } ); 127 + // No icon → no logo URL; the caller never touches blob internals. 128 + expect( publications[ 1 ].logoUrl ).toBeNull(); 129 + 130 + expect( foreign ).toHaveLength( 1 ); 131 + expect( foreign[ 0 ] ).toMatchObject( { name: 'Leafy', logoUrl: null } ); 132 + } ); 133 + } ); 134 + 135 + describe( 'resolvePublicationContext', () => { 136 + it( 'maps a missing slug to a generic 404', async () => { 137 + wirePds(); 138 + const result = await resolvePublicationContext( `@${ HANDLE }`, undefined ); 139 + expect( result.ok ).toBe( false ); 140 + if ( ! result.ok ) { 141 + expect( result.error.status ).toBe( 404 ); 142 + } 143 + } ); 144 + 145 + it( 'maps an unknown slug to publication-not-found', async () => { 146 + wirePds(); 147 + const result = await resolvePublicationContext( `@${ HANDLE }`, 'missing' ); 148 + expect( result.ok ).toBe( false ); 149 + if ( ! result.ok ) { 150 + expect( result.error.status ).toBe( 404 ); 151 + expect( result.error.subline ).toContain( 'missing' ); 152 + } 153 + } ); 154 + 155 + it( 'returns the publication and only its own documents, newest-first order preserved', async () => { 156 + wirePds(); 157 + const result = await resolvePublicationContext( `@${ HANDLE }`, 'blog' ); 158 + expect( result.ok ).toBe( true ); 159 + if ( ! result.ok ) { 160 + return; 161 + } 162 + const { publication, documents } = result.context; 163 + expect( publication ).toMatchObject( { 164 + uri: PUB_URI, 165 + slug: 'blog', 166 + logoUrl: buildGetBlobUrl( PDS, DID, 'bafyicon' ), 167 + } ); 168 + // The site-join (document.value.site === publication.uri) happens here, once. 169 + expect( documents ).toHaveLength( 1 ); 170 + expect( documents[ 0 ] ).toMatchObject( { rkey: 'd1', value: { title: 'Td1' } } ); 171 + } ); 172 + } ); 173 + 174 + describe( 'resolveArticleContext', () => { 175 + it( 'maps a missing rkey to a generic 404', async () => { 176 + wirePds(); 177 + const result = await resolveArticleContext( `@${ HANDLE }`, 'blog', undefined ); 178 + expect( result.ok ).toBe( false ); 179 + } ); 180 + 181 + it( 'maps a missing record to article-not-found', async () => { 182 + wirePds( { article: null } ); 183 + const result = await resolveArticleContext( `@${ HANDLE }`, 'blog', 'gone' ); 184 + expect( result.ok ).toBe( false ); 185 + if ( ! result.ok ) { 186 + expect( result.error.status ).toBe( 404 ); 187 + expect( result.error.subline ).toContain( 'Pa' ); 188 + } 189 + } ); 190 + 191 + it( 'rejects a document that belongs to another publication', async () => { 192 + // The d2 document exists but its site points at the "notes" publication. 193 + wirePds( { article: docRecord( 'd2', `at://${ DID }/site.standard.publication/b` ) } ); 194 + const result = await resolveArticleContext( `@${ HANDLE }`, 'blog', 'd2' ); 195 + expect( result.ok ).toBe( false ); 196 + if ( ! result.ok ) { 197 + expect( result.error.status ).toBe( 404 ); 198 + } 199 + } ); 200 + 201 + it( 'returns the document with its publication and author profile', async () => { 202 + wirePds( { 203 + article: docRecord( 'd1', PUB_URI, { textContent: 'Hello world', publishedAt: '2026-06-01T00:00:00Z' } ), 204 + } ); 205 + const result = await resolveArticleContext( `@${ HANDLE }`, 'blog', 'd1' ); 206 + expect( result.ok ).toBe( true ); 207 + if ( ! result.ok ) { 208 + return; 209 + } 210 + const { author, publication, document, profile } = result.context; 211 + expect( author.handle ).toBe( HANDLE ); 212 + expect( publication.slug ).toBe( 'blog' ); 213 + expect( profile.displayName ).toBe( 'Me' ); 214 + expect( document ).toMatchObject( { 215 + rkey: 'd1', 216 + uri: `at://${ DID }/site.standard.document/d1`, 217 + value: { title: 'Td1', textContent: 'Hello world' }, 218 + } ); 219 + } ); 220 + 221 + it( 'reports publication-not-found before article-not-found', async () => { 222 + wirePds( { article: docRecord( 'd1', PUB_URI ) } ); 223 + const result = await resolveArticleContext( `@${ HANDLE }`, 'nope', 'd1' ); 224 + expect( result.ok ).toBe( false ); 225 + if ( ! result.ok ) { 226 + expect( result.error.subline ).toContain( 'nope' ); 227 + } 228 + } ); 229 + } );
+238
src/lib/reader/read-context.ts
··· 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 + */ 18 + import { resolveAuthor } from './identity'; 19 + import { getRecord, listRecords } from './records'; 20 + import { 21 + listAllReaderPublications, 22 + type ReaderPublication, 23 + type ReaderForeignPublication, 24 + } from './publications'; 25 + import { fetchActorProfile, type ActorProfile } from './profile'; 26 + import { buildGetBlobUrl } from '../media/blob'; 27 + import { errorScene, type ErrorSceneCopy } from './errors'; 28 + import type { BlockNode } from '../blocks/render'; 29 + 30 + const 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 + */ 38 + const DOCUMENT_FETCH_LIMIT = 100; 39 + 40 + export 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). */ 47 + export type PublicationView = ReaderPublication & { logoUrl: string | null }; 48 + export type ForeignPublicationView = ReaderForeignPublication & { logoUrl: string | null }; 49 + 50 + /** The slice of a `site.standard.document` value the read routes consume. */ 51 + export 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 + 63 + export interface PublicationDocument { 64 + uri: string; 65 + cid: string; 66 + rkey: string; 67 + value: SkyDocumentValue; 68 + } 69 + 70 + export interface AuthorReadContext { 71 + author: ReadAuthor; 72 + profile: ActorProfile; 73 + publications: PublicationView[]; 74 + foreign: ForeignPublicationView[]; 75 + } 76 + 77 + export 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 + 85 + export interface ArticleReadContext { 86 + author: ReadAuthor; 87 + profile: ActorProfile; 88 + publication: PublicationView; 89 + document: PublicationDocument; 90 + } 91 + 92 + export type ReadContextResult< T > = 93 + | { ok: true; context: T } 94 + | { ok: false; error: ErrorSceneCopy }; 95 + 96 + function 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. */ 101 + async 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 + 115 + function 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 + 127 + function 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 + 138 + export 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 + 163 + export 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 + 202 + export 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 + }
+52 -95
src/pages/[author]/[slug]/[rkey].astro
··· 1 1 --- 2 2 import Base from '../../../layouts/Base.astro'; 3 3 import PublicationFooter from '../../../components/PublicationFooter.astro'; 4 - import { resolveAuthor } from '../../../lib/reader/identity'; 5 - import { getRecord } from '../../../lib/reader/records'; 6 - import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 - import { fetchActorProfile } from '../../../lib/reader/profile'; 4 + import { resolveArticleContext } from '../../../lib/reader/read-context'; 8 5 import { formatLongDate } from '../../../lib/reader/dates'; 9 - import { resolveBlobImageUrls, buildGetBlobUrl } from '../../../lib/media/blob'; 10 - import { renderBlocks, blocksToText, type BlockNode } from '../../../lib/blocks/render'; 6 + import { resolveBlobImageUrls } from '../../../lib/media/blob'; 7 + import { renderBlocks, blocksToText } from '../../../lib/blocks/render'; 11 8 import { sanitizeArticleHtml } from '../../../lib/reader/sanitize'; 12 9 import { canonicalArticleUrl } from '../../../lib/publish/records'; 13 10 import { deriveExcerpt } from '../../../lib/publish/excerpt'; 14 11 import { themeStyleBlock } from '../../../lib/publish/themes'; 15 12 import { buildMetaTags } from '../../../lib/seo/meta'; 16 13 import ErrorScene from '../../../components/ErrorScene.astro'; 17 - import { errorScene } from '../../../lib/reader/errors'; 18 - import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 19 14 import PostActions from '../../../components/PostActions.tsx'; 20 15 21 16 // Frontend block styles only — no editor chrome, no JS. ··· 28 23 // Read-through renderer: resolve + fetch at request time (Decision 0007). 29 24 export const prerender = false; 30 25 31 - interface SkyDocument { 32 - title?: string; 33 - description?: string; 34 - textContent?: string; 35 - publishedAt?: string; 36 - updatedAt?: string; 37 - site?: string; 38 - content?: { blocks?: BlockNode[] }; 39 - /** strongRef to the companion `app.bsky.feed.post` — the target of reader actions. */ 40 - bskyPostRef?: { uri: string; cid: string }; 41 - } 42 - 43 26 const { author, slug, rkey } = Astro.params; 44 27 45 - let error: ErrorSceneCopy | null = null; 46 - const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 28 + // Resolve author → publication → document (site-joined) behind one interface; 29 + // this page keeps the render pipeline and presentation. 30 + const result = await resolveArticleContext( author, slug, rkey ); 31 + const error = result.ok ? null : result.error; 32 + const ctx = result.ok ? result.context : null; 47 33 48 34 // Everything the happy-path template needs; populated only when there's no error. 49 - let publication: Awaited< ReturnType< typeof resolveReaderPublication > > = null; 35 + const publication = ctx?.publication ?? null; 50 36 let metaTags: ReturnType< typeof buildMetaTags > = []; 51 37 let title = ''; 52 38 let description = ''; ··· 58 44 let readingMinutes = 1; 59 45 let publishedLabel: string | null = null; 60 46 let updatedLabel: string | null = null; 47 + let handle = ''; 61 48 let authorName = ''; 62 49 let authorHandle: string | null = null; 63 50 let authorAvatar: string | null = null; ··· 67 54 let ogImage = ''; 68 55 let bskyPostRef: { uri: string; cid: string } | null = null; 69 56 70 - if ( ! author || ! author.startsWith( '@' ) || ! slug || ! rkey ) { 71 - error = errorScene( 'not-found' ); 72 - } 57 + if ( ctx && publication ) { 58 + const { author: readAuthor, profile, document } = ctx; 59 + const { did, pdsUrl } = readAuthor; 60 + handle = readAuthor.handle; 73 61 74 - const resolved = error ? null : await resolveAuthor( handle ); 75 - if ( ! error && ! resolved ) { 76 - error = errorScene( 'writer-not-found', { handle } ); 77 - } 62 + const doc = document.value; 63 + const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 64 + html = sanitizeArticleHtml( renderBlocks( blocks ) ); 65 + const textContent = doc.textContent || blocksToText( blocks ); 78 66 79 - if ( ! error && resolved ) { 80 - const { did, pdsUrl } = resolved; 67 + title = doc.title ?? 'Untitled'; 68 + description = doc.description || deriveExcerpt( textContent ); 69 + canonical = canonicalArticleUrl( handle, slug!, rkey! ); 70 + docUri = document.uri; 71 + pubUrl = `/${ author }/${ slug }`; 72 + feedHref = `${ pubUrl }/rss.xml`; 81 73 82 - publication = await resolveReaderPublication( pdsUrl, did, slug! ); 83 - if ( ! publication ) { 84 - error = errorScene( 'publication-not-found', { handle, slug } ); 85 - } else { 86 - const record = await getRecord< SkyDocument >( 87 - pdsUrl, 88 - did, 89 - 'site.standard.document', 90 - rkey! 91 - ); 92 - if ( ! record?.value || record.value.site !== publication.uri ) { 93 - error = errorScene( 'article-not-found', { 94 - publicationName: publication.name, 95 - } ); 96 - } else { 97 - const doc = record.value; 98 - const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 99 - html = sanitizeArticleHtml( renderBlocks( blocks ) ); 100 - const textContent = doc.textContent || blocksToText( blocks ); 74 + const words = textContent.split( /\s+/ ).filter( Boolean ).length; 75 + readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 76 + publishedLabel = doc.publishedAt ? formatLongDate( doc.publishedAt ) : null; 77 + updatedLabel = doc.updatedAt ? formatLongDate( doc.updatedAt ) : null; 78 + themeStyle = themeStyleBlock( publication.basicTheme ); 79 + // The companion Bluesky post is the target of every reader action; absent it 80 + // (legacy docs) there's no thread to act on, so the action bar is omitted. 81 + bskyPostRef = doc.bskyPostRef?.uri && doc.bskyPostRef?.cid ? doc.bskyPostRef : null; 101 82 102 - title = doc.title ?? 'Untitled'; 103 - description = doc.description || deriveExcerpt( textContent ); 104 - canonical = canonicalArticleUrl( handle, slug!, rkey! ); 105 - docUri = `at://${ did }/site.standard.document/${ rkey }`; 106 - pubUrl = `/${ author }/${ slug }`; 107 - feedHref = `${ pubUrl }/rss.xml`; 83 + authorName = profile.displayName ?? `@${ handle }`; 84 + // Only show a separate @handle chip when there's a distinct display name. 85 + authorHandle = profile.displayName ? `@${ handle }` : null; 86 + authorAvatar = profile.avatar; 87 + logoUrl = publication.logoUrl; 88 + initial = publication.name.charAt( 0 ).toUpperCase(); 108 89 109 - const words = textContent.split( /\s+/ ).filter( Boolean ).length; 110 - readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 111 - publishedLabel = doc.publishedAt ? formatLongDate( doc.publishedAt ) : null; 112 - updatedLabel = doc.updatedAt ? formatLongDate( doc.updatedAt ) : null; 113 - themeStyle = themeStyleBlock( publication.basicTheme ); 114 - // The companion Bluesky post is the target of every reader action; absent it 115 - // (legacy docs) there's no thread to act on, so the action bar is omitted. 116 - bskyPostRef = 117 - doc.bskyPostRef?.uri && doc.bskyPostRef?.cid ? doc.bskyPostRef : null; 118 - 119 - const profile = await fetchActorProfile( pdsUrl, did ); 120 - authorName = profile.displayName ?? `@${ handle }`; 121 - // Only show a separate @handle chip when there's a distinct display name. 122 - authorHandle = profile.displayName ? `@${ handle }` : null; 123 - authorAvatar = profile.avatar; 124 - logoUrl = publication.icon 125 - ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 126 - : null; 127 - initial = publication.name.charAt( 0 ).toUpperCase(); 128 - 129 - // Share image: the publication logo, else the shared default. Only the 130 - // default image has known 1200x630 dimensions; a square logo omits them. 131 - ogImage = logoUrl ?? new URL( '/og-default.png', Astro.site ).href; 132 - const ogDimensions = publication.icon 133 - ? {} 134 - : { imageWidth: 1200, imageHeight: 630 }; 135 - metaTags = buildMetaTags( { 136 - title, 137 - description, 138 - url: canonical, 139 - image: ogImage, 140 - siteName: 'SkyPress', 141 - type: 'article', 142 - imageAlt: publication.name, 143 - ...ogDimensions, 144 - } ); 145 - } 146 - } 90 + // Share image: the publication logo, else the shared default. Only the 91 + // default image has known 1200x630 dimensions; a square logo omits them. 92 + ogImage = logoUrl ?? new URL( '/og-default.png', Astro.site ).href; 93 + const ogDimensions = logoUrl ? {} : { imageWidth: 1200, imageHeight: 630 }; 94 + metaTags = buildMetaTags( { 95 + title, 96 + description, 97 + url: canonical, 98 + image: ogImage, 99 + siteName: 'SkyPress', 100 + type: 'article', 101 + imageAlt: publication.name, 102 + ...ogDimensions, 103 + } ); 147 104 } 148 105 149 106 if ( error ) {
+14 -12
src/pages/[author]/[slug]/_[rkey].meta.test.ts
··· 15 15 const page = readFileSync( join( here, './[rkey].astro' ), 'utf8' ); 16 16 17 17 describe( 'document page Open Graph wiring', () => { 18 - it( 'imports buildMetaTags and buildGetBlobUrl', () => { 18 + it( 'imports buildMetaTags and takes the logo from the read context', () => { 19 19 expect( page ).toMatch( /import\s*\{\s*buildMetaTags\s*\}\s*from\s*'[^']*lib\/seo\/meta'/ ); 20 - expect( page ).toMatch( /buildGetBlobUrl/ ); 20 + // The blob-ref → URL construction lives in the read-context module now. 21 + expect( page ).toMatch( /publication\.logoUrl/ ); 22 + expect( page ).not.toMatch( /buildGetBlobUrl/ ); 21 23 } ); 22 24 23 25 it( 'builds the meta tags as an article with the canonical URL', () => { ··· 44 46 45 47 it( 'renders the ErrorScene instead of plain-text 404 responses', () => { 46 48 expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 47 - expect( page ).toMatch( /import\s*\{\s*errorScene\s*\}\s*from\s*'[^']*lib\/reader\/errors'/ ); 48 49 expect( page ).toMatch( /<ErrorScene/ ); 49 50 // No more bare plain-text 404 bodies. 50 51 expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 51 52 expect( page ).not.toMatch( /new Response\(\s*'Article not found'/ ); 52 53 } ); 53 54 54 - it( 'maps each failure to the right error kind and sets the response status', () => { 55 - expect( page ).toMatch( /errorScene\(\s*'writer-not-found'/ ); 56 - expect( page ).toMatch( /errorScene\(\s*'publication-not-found'/ ); 57 - expect( page ).toMatch( /errorScene\(\s*'article-not-found'/ ); 55 + it( 'delegates failure mapping to the read-context module and sets the response status', () => { 56 + // Error-kind mapping (writer/publication/article not found + the site-join) 57 + // is behaviourally tested in src/lib/reader/read-context.test.ts. 58 + expect( page ).toMatch( 59 + /import\s*\{\s*resolveArticleContext\s*\}\s*from\s*'[^']*lib\/reader\/read-context'/ 60 + ); 61 + expect( page ).toMatch( /resolveArticleContext\(/ ); 58 62 expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 59 63 } ); 60 64 ··· 78 82 expect( page ).toMatch( /class="masthead__title">\{publication!?\.name\}/ ); 79 83 } ); 80 84 81 - it( 'fetches the author profile for the eyebrow', () => { 82 - expect( page ).toMatch( 83 - /import\s*\{\s*fetchActorProfile\s*\}\s*from\s*'[^']*lib\/reader\/profile'/ 84 - ); 85 - expect( page ).toMatch( /fetchActorProfile\(/ ); 85 + it( 'takes the author profile from the read context for the eyebrow', () => { 86 + expect( page ).toMatch( /profile\.displayName/ ); 87 + expect( page ).toMatch( /profile\.avatar/ ); 86 88 } ); 87 89 88 90 it( 'shows the author (name, avatar, handle) in the eyebrow, linked to the profile', () => {
+11 -8
src/pages/[author]/[slug]/_index.meta.test.ts
··· 25 25 } ); 26 26 27 27 describe( 'publication page error wiring', () => { 28 - it( 'renders ErrorScene instead of plain-text 404s', () => { 28 + it( 'delegates resolution to the read-context module', () => { 29 + expect( page ).toMatch( 30 + /import\s*\{\s*resolvePublicationContext\s*\}\s*from\s*'[^']*lib\/reader\/read-context'/ 31 + ); 32 + expect( page ).toMatch( /resolvePublicationContext\(/ ); 33 + } ); 34 + 35 + it( 'renders ErrorScene with the module-provided copy instead of plain-text 404s', () => { 36 + // Error mapping (writer/publication not found) is behaviourally tested in 37 + // src/lib/reader/read-context.test.ts; the page only renders what comes back. 29 38 expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 30 - expect( page ).toMatch( /import\s*\{\s*errorScene/ ); 31 39 expect( page ).toMatch( /<ErrorScene/ ); 40 + expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 32 41 expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 33 42 expect( page ).not.toMatch( /new Response\(\s*'Publication not found'/ ); 34 - } ); 35 - 36 - it( 'maps writer + publication failures and sets the response status', () => { 37 - expect( page ).toMatch( /errorScene\(\s*'writer-not-found'/ ); 38 - expect( page ).toMatch( /errorScene\(\s*'publication-not-found'/ ); 39 - expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 40 43 } ); 41 44 } ); 42 45
+20 -72
src/pages/[author]/[slug]/index.astro
··· 1 1 --- 2 2 import Base from '../../../layouts/Base.astro'; 3 3 import PublicationFooter from '../../../components/PublicationFooter.astro'; 4 - import { resolveAuthor } from '../../../lib/reader/identity'; 5 - import { listRecords } from '../../../lib/reader/records'; 6 - import { resolveReaderPublication } from '../../../lib/reader/publications'; 7 - import { fetchActorProfile } from '../../../lib/reader/profile'; 4 + import { resolvePublicationContext } from '../../../lib/reader/read-context'; 8 5 import { formatLongDate } from '../../../lib/reader/dates'; 9 - import { buildGetBlobUrl } from '../../../lib/media/blob'; 10 6 import { themeStyleBlock } from '../../../lib/publish/themes'; 11 7 import ErrorScene from '../../../components/ErrorScene.astro'; 12 - import { errorScene } from '../../../lib/reader/errors'; 13 - import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 14 8 15 9 export const prerender = false; 16 10 17 - interface DocumentValue { 18 - title?: string; 19 - description?: string; 20 - site?: string; 21 - publishedAt?: string; 22 - } 23 - 24 11 const { author, slug } = Astro.params; 25 12 26 - let error: ErrorSceneCopy | null = null; 27 - const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 28 - 29 - let publication: Awaited< ReturnType< typeof resolveReaderPublication > > = null; 30 - let profile: Awaited< ReturnType< typeof fetchActorProfile > > | null = null; 31 - let articles: Array< { 32 - rkey: string; 33 - title: string; 34 - description?: string; 35 - publishedLabel: string | null; 36 - } > = []; 37 - let logoUrl: string | null = null; 38 - let authorName = ''; 39 - let initial = ''; 40 - let feedHref = ''; 41 - let themeStyle = ''; 42 - 43 - if ( ! author || ! author.startsWith( '@' ) || ! slug ) { 44 - error = errorScene( 'not-found' ); 45 - } 46 - 47 - const resolved = error ? null : await resolveAuthor( handle ); 48 - if ( ! error && ! resolved ) { 49 - error = errorScene( 'writer-not-found', { handle } ); 50 - } 51 - 52 - if ( ! error && resolved ) { 53 - const { did, pdsUrl } = resolved; 13 + // Resolve author → publication → its documents behind one interface; 14 + // this page keeps only presentation. 15 + const result = await resolvePublicationContext( author, slug ); 16 + const error = result.ok ? null : result.error; 17 + const ctx = result.ok ? result.context : null; 54 18 55 - publication = await resolveReaderPublication( pdsUrl, did, slug! ); 56 - if ( ! publication ) { 57 - error = errorScene( 'publication-not-found', { handle, slug } ); 58 - } else { 59 - profile = await fetchActorProfile( pdsUrl, did ); 19 + const handle = ctx?.author.handle ?? ''; 20 + const publication = ctx?.publication ?? null; 21 + const profile = ctx?.profile ?? null; 60 22 61 - const allDocs = await listRecords< DocumentValue >( 62 - pdsUrl, 63 - did, 64 - 'site.standard.document', 65 - 100 66 - ); 67 - articles = allDocs 68 - .filter( ( record ) => record.value.site === publication!.uri ) 69 - .map( ( record ) => ( { 70 - rkey: record.uri.split( '/' ).pop() as string, 71 - title: record.value.title ?? 'Untitled', 72 - description: record.value.description, 73 - publishedLabel: record.value.publishedAt 74 - ? formatLongDate( record.value.publishedAt ) 75 - : null, 76 - } ) ); 23 + const articles = ( ctx?.documents ?? [] ).map( ( doc ) => ( { 24 + rkey: doc.rkey, 25 + title: doc.value.title ?? 'Untitled', 26 + description: doc.value.description, 27 + publishedLabel: doc.value.publishedAt ? formatLongDate( doc.value.publishedAt ) : null, 28 + } ) ); 77 29 78 - logoUrl = publication.icon 79 - ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 80 - : null; 81 - authorName = profile.displayName ?? `@${ handle }`; 82 - initial = publication.name.charAt( 0 ).toUpperCase(); 83 - feedHref = `/${ author }/${ slug }/rss.xml`; 84 - themeStyle = themeStyleBlock( publication.basicTheme ); 85 - } 86 - } 30 + const logoUrl = publication?.logoUrl ?? null; 31 + const authorName = profile?.displayName ?? `@${ handle }`; 32 + const initial = publication?.name.charAt( 0 ).toUpperCase() ?? ''; 33 + const feedHref = `/${ author }/${ slug }/rss.xml`; 34 + const themeStyle = publication ? themeStyleBlock( publication.basicTheme ) : ''; 87 35 88 36 if ( error ) { 89 37 Astro.response.status = error.status;
+15 -41
src/pages/[author]/[slug]/rss.xml.ts
··· 1 1 import type { APIRoute } from 'astro'; 2 - import { resolveAuthor } from '../../../lib/reader/identity'; 3 - import { listRecords } from '../../../lib/reader/records'; 4 - import { resolveReaderPublication } from '../../../lib/reader/publications'; 5 - import { 6 - buildPublicationFeedXml, 7 - type FeedDocumentValue, 8 - } from '../../../lib/feed/publication-feed'; 2 + import { resolvePublicationContext } from '../../../lib/reader/read-context'; 3 + import { buildPublicationFeedXml } from '../../../lib/feed/publication-feed'; 9 4 10 5 /** 11 6 * Full-content RSS 2.0 feed for one publication (Decision 0011): `/@<handle>/<slug>/rss.xml`. 12 7 * 13 8 * A read-through server endpoint (`prerender = false`) like the reading pages it mirrors 14 - * (Decision 0007): resolve the author + publication, list `site.standard.document` (one 15 - * SSRF-guarded PDS fetch — no per-article round-trip, since `listRecords` returns each 16 - * document's full `content.blocks`), then hand off to the pure, test-locked feed builder. 17 - * 404s on an unresolvable author or unknown publication, matching the publication page. 9 + * (Decision 0007): `resolvePublicationContext` resolves the author + publication and lists 10 + * the publication's documents (SSRF-guarded, one PDS fetch — no per-article round-trip, 11 + * since each document carries its full `content.blocks`), then hands off to the pure, 12 + * test-locked feed builder. 404s on an unresolvable author or unknown publication, with 13 + * the same error mapping as the publication page. 18 14 */ 19 15 export const prerender = false; 20 16 21 17 export const GET: APIRoute = async ( { params } ) => { 22 - const { author, slug } = params; 23 - if ( ! author || ! author.startsWith( '@' ) || ! slug ) { 24 - return new Response( 'Not found', { status: 404 } ); 25 - } 26 - const handle = author.slice( 1 ); 27 - 28 - const resolved = await resolveAuthor( handle ); 29 - if ( ! resolved ) { 30 - return new Response( `Could not resolve @${ handle }`, { status: 404 } ); 31 - } 32 - const { did, pdsUrl } = resolved; 33 - 34 - const publication = await resolveReaderPublication( pdsUrl, did, slug ); 35 - if ( ! publication ) { 36 - return new Response( 'Publication not found', { status: 404 } ); 18 + const result = await resolvePublicationContext( params.author, params.slug ); 19 + if ( ! result.ok ) { 20 + return new Response( 'Not found', { status: result.error.status } ); 37 21 } 38 - 39 - // Fetch the writer's 100 most recent documents (across all their publications), then 40 - // `buildPublicationFeedXml` filters to this one and keeps the newest 20. A writer with 41 - // >100 total documents could thus have older items in this publication omitted from the 42 - // feed — the same bound the publication page (`index.astro`) already applies. 43 - const documents = await listRecords< FeedDocumentValue >( 44 - pdsUrl, 45 - did, 46 - 'site.standard.document', 47 - 100 48 - ); 22 + const { author, publication, documents } = result.context; 49 23 50 24 const xml = buildPublicationFeedXml( { 51 - handle, 52 - slug, 53 - pdsUrl, 54 - did, 25 + handle: author.handle, 26 + slug: publication.slug, 27 + pdsUrl: author.pdsUrl, 28 + did: author.did, 55 29 publication, 56 30 documents, 57 31 } );
+11 -8
src/pages/[author]/_index.meta.test.ts
··· 7 7 const page = readFileSync( join( here, './index.astro' ), 'utf8' ); 8 8 9 9 describe( 'writer page error wiring', () => { 10 - it( 'renders ErrorScene instead of plain-text 404s', () => { 10 + it( 'delegates resolution to the read-context module', () => { 11 + expect( page ).toMatch( 12 + /import\s*\{\s*resolveAuthorContext\s*\}\s*from\s*'[^']*lib\/reader\/read-context'/ 13 + ); 14 + expect( page ).toMatch( /resolveAuthorContext\(/ ); 15 + } ); 16 + 17 + it( 'renders ErrorScene with the module-provided copy instead of plain-text 404s', () => { 18 + // Error mapping (not-found / writer-not-found) is behaviourally tested in 19 + // src/lib/reader/read-context.test.ts; the page only renders what comes back. 11 20 expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 12 - expect( page ).toMatch( /import\s*\{\s*errorScene/ ); 13 21 expect( page ).toMatch( /<ErrorScene/ ); 22 + expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 14 23 expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 15 24 expect( page ).not.toMatch( /new Response\(\s*`Could not resolve/ ); 16 - } ); 17 - 18 - it( 'maps the writer-not-found failure and sets the response status', () => { 19 - expect( page ).toMatch( /errorScene\(\s*'writer-not-found'/ ); 20 - expect( page ).toMatch( /errorScene\(\s*'not-found'/ ); 21 - expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 22 25 } ); 23 26 } );
+18 -53
src/pages/[author]/index.astro
··· 1 1 --- 2 2 import Base from '../../layouts/Base.astro'; 3 3 import Logo from '../../components/Logo.astro'; 4 - import { resolveAuthor } from '../../lib/reader/identity'; 5 - import { listAllReaderPublications } from '../../lib/reader/publications'; 6 - import { fetchActorProfile } from '../../lib/reader/profile'; 4 + import { resolveAuthorContext } from '../../lib/reader/read-context'; 7 5 import { detectBioSegments } from '../../lib/reader/rich-text'; 8 - import { buildGetBlobUrl } from '../../lib/media/blob'; 9 6 import CreatePublicationCta from '../../components/CreatePublicationCta.tsx'; 10 7 import ErrorScene from '../../components/ErrorScene.astro'; 11 - import { errorScene } from '../../lib/reader/errors'; 12 - import type { ErrorSceneCopy } from '../../lib/reader/errors'; 13 8 14 9 export const prerender = false; 15 10 16 - const { author } = Astro.params; 17 - 18 - let error: ErrorSceneCopy | null = null; 19 - const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 11 + // Resolve handle → DID → PDS → profile + publications behind one interface; 12 + // this page keeps only presentation. 13 + const result = await resolveAuthorContext( Astro.params.author ); 14 + const error = result.ok ? null : result.error; 15 + const ctx = result.ok ? result.context : null; 20 16 21 - let did = ''; 22 - let pdsUrl = ''; 23 - let profile: Awaited< ReturnType< typeof fetchActorProfile > > | null = null; 24 - let publications: Awaited< ReturnType< typeof listAllReaderPublications > >[ 'owned' ] = []; 25 - let foreign: Awaited< ReturnType< typeof listAllReaderPublications > >[ 'foreign' ] = []; 26 - let authorName = ''; 27 - let initial = ''; 28 - let bioSegments: ReturnType< typeof detectBioSegments > = []; 17 + const handle = ctx?.author.handle ?? ''; 18 + const did = ctx?.author.did ?? ''; 19 + const profile = ctx?.profile ?? null; 20 + const publications = ctx?.publications ?? []; 21 + const foreign = ctx?.foreign ?? []; 29 22 30 - if ( ! author || ! author.startsWith( '@' ) ) { 31 - error = errorScene( 'not-found' ); 32 - } 33 - 34 - const resolved = error ? null : await resolveAuthor( handle ); 35 - if ( ! error && ! resolved ) { 36 - error = errorScene( 'writer-not-found', { handle } ); 37 - } 38 - 39 - if ( ! error && resolved ) { 40 - did = resolved.did; 41 - pdsUrl = resolved.pdsUrl; 42 - 43 - const [ fetchedProfile, allPublications ] = await Promise.all( [ 44 - fetchActorProfile( pdsUrl, did ), 45 - listAllReaderPublications( pdsUrl, did ), 46 - ] ); 47 - profile = fetchedProfile; 48 - publications = allPublications.owned; 49 - foreign = allPublications.foreign; 50 - 51 - authorName = profile.displayName ?? `@${ handle }`; 52 - initial = authorName.replace( /^@/, '' ).charAt( 0 ).toUpperCase(); 53 - bioSegments = profile.description ? detectBioSegments( profile.description ) : []; 54 - } 23 + const authorName = profile?.displayName ?? `@${ handle }`; 24 + const initial = authorName.replace( /^@/, '' ).charAt( 0 ).toUpperCase(); 25 + const bioSegments = profile?.description ? detectBioSegments( profile.description ) : []; 55 26 56 27 if ( error ) { 57 28 Astro.response.status = error.status; ··· 140 111 ) : ( 141 112 <ul class="author__list"> 142 113 {publications.map( ( pub ) => { 143 - const logoUrl = pub.icon 144 - ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 145 - : null; 146 114 return ( 147 115 <li class="author__item"> 148 116 <a class="author__pub" href={`/@${ handle }/${ pub.slug }`}> 149 - {logoUrl ? ( 150 - <img class="author__publogo" src={logoUrl} alt="" width="52" height="52" /> 117 + {pub.logoUrl ? ( 118 + <img class="author__publogo" src={pub.logoUrl} alt="" width="52" height="52" /> 151 119 ) : ( 152 120 <span class="author__publogo author__publogo--fallback" aria-hidden="true"> 153 121 {pub.name.charAt( 0 ).toUpperCase()} ··· 169 137 <h2 class="author__heading">Elsewhere</h2> 170 138 <ul class="author__list"> 171 139 {foreign.map( ( pub ) => { 172 - const logoUrl = pub.icon 173 - ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 174 - : null; 175 140 return ( 176 141 <li class="author__item"> 177 142 <a ··· 180 145 target="_blank" 181 146 rel="noopener noreferrer" 182 147 > 183 - {logoUrl ? ( 184 - <img class="author__publogo" src={logoUrl} alt="" width="52" height="52" /> 148 + {pub.logoUrl ? ( 149 + <img class="author__publogo" src={pub.logoUrl} alt="" width="52" height="52" /> 185 150 ) : ( 186 151 <span class="author__publogo author__publogo--fallback" aria-hidden="true"> 187 152 {pub.name.charAt( 0 ).toUpperCase()}