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.

Merge branch 'playful-error-pages' into trunk

+865 -243
+98
docs/specs/sp12-error-pages.md
··· 1 + # SP12 — Playful error pages (404 / 500) 2 + 3 + - **Status:** Built 4 + - **Depends on:** SP4 (public renderer), SP6 (brand), SP10 (publication URL model) 5 + - **Decisions:** none new (reuses 0007 read-through renderer, 0009 Cloudflare deploy) 6 + 7 + ## Goal 8 + 9 + Replace the bare `new Response('Not found', { status: 404 })` plain-text responses on the 10 + reader routes with a **good-looking, playful** error page that stays on brand, and add the 11 + two error pages SkyPress is missing entirely: a global **404** for unmatched URLs and a 12 + **500** for server-side failures (e.g. a PDS fetch that throws). 13 + 14 + The visual concept is **"Below the horizon"**: SkyPress's warm paper/ink/**sun** palette with 15 + a setting sun dipping under a thin horizon line and a couple of distant birds. One scene, 16 + copy that adapts to what went wrong, and a single **Back to homepage** button. 17 + 18 + ## Scope 19 + 20 + Five situations, all rendered by the same scene: 21 + 22 + | Kind | HTTP | Triggered from | 23 + | ------------------------ | ---- | --------------------------------------------------------- | 24 + | `writer-not-found` | 404 | `resolveAuthor(handle)` returns null (all 3 reader routes)| 25 + | `publication-not-found` | 404 | `resolveReaderPublication` returns null | 26 + | `article-not-found` | 404 | record missing, or `record.value.site` ≠ this publication | 27 + | `not-found` | 404 | unmatched URL (global `404.astro`) + malformed params | 28 + | `server-error` | 500 | any route throws (auto-rendered `500.astro`) | 29 + 30 + ## Copy (the content model) 31 + 32 + No em dashes, plain natural wording. Eyebrow is a mono kicker; headline uses the display 33 + face. All five share the single **Back to homepage** button. 34 + 35 + - **`writer-not-found`** — eyebrow `404 · no one by that name`, 36 + heading `No writer at @{handle}`, 37 + sub `Nobody on the network goes by that handle yet. It might have a typo.` 38 + - **`publication-not-found`** — eyebrow `404 · publication not found`, 39 + heading `No publication by that name`, 40 + sub `@{handle} hasn't published anything under "{slug}".` 41 + - **`article-not-found`** — eyebrow `404 · story not found`, 42 + heading `This story set below the horizon`, 43 + sub `It's no longer part of {publicationName}. It may have been unpublished or moved.` 44 + - **`not-found`** — eyebrow `404 · off the map`, 45 + heading `This page set below the horizon`, 46 + sub `There's nothing to read at this address.` 47 + - **`server-error`** — eyebrow `500 · overcast`, 48 + heading `The sky's a bit cloudy right now`, 49 + sub `Something went wrong on our end. Give it a moment, then try again.` 50 + 51 + ## Modules 52 + 53 + - `src/lib/reader/errors.ts` — **pure, dependency-free** source of truth. A function 54 + `errorScene(kind, context?)` returns `{ status, eyebrow, heading, subline }`. Context is 55 + the interpolation values (`handle`, `slug`, `publicationName`). All copy lives here, so it 56 + is edited in one place and is fully unit-testable. No `@wordpress`, no network. 57 + - `src/components/ErrorScene.astro` — the visual. Props are exactly the four 58 + `errorScene()` fields. Renders inside `Base` (masthead + `Logo`). Self-contained `<style>` 59 + for the horizon scene. **No `@wordpress/*` imports** (AGENTS.md #3 — this is a read path). 60 + - `src/pages/404.astro` — `prerender = true`. Static generic 404 for unmatched URLs; 61 + renders `ErrorScene` with `errorScene('not-found')`. 62 + - `src/pages/500.astro` — `prerender = true`. Astro auto-renders this when a route throws, 63 + so no per-route try/catch is needed; renders `errorScene('server-error')`. 64 + 65 + ## Wiring the reader routes 66 + 67 + In each of `src/pages/[author]/index.astro`, `[author]/[slug]/index.astro`, and 68 + `[author]/[slug]/[rkey].astro`, replace every early `return new Response('…', { status })` 69 + with: build the matching `errorScene(...)` props, set `Astro.response.status = props.status`, 70 + and render `<ErrorScene {...props} />` from the template instead of the normal content 71 + (`{ error ? <ErrorScene {...error} /> : <…normal…> }`). The real 404 status is preserved for 72 + crawlers; the page is now a rendered HTML body, not plain text. 73 + 74 + ## Look & accessibility 75 + 76 + - Reuses brand tokens from `global.css` (`--paper`, `--ink`, `--sun`, `--sun-tint`, `.btn`, 77 + `.eyebrow`). Light + dark first-class: a dark-mode sky-gradient variant via 78 + `@media (prefers-color-scheme: dark)`. 79 + - Setting-sun has a slow glow animation, disabled under `@media (prefers-reduced-motion)`. 80 + - Decorative sun/birds/horizon are `aria-hidden`. Single `<h1>` carries the heading. The 81 + status is conveyed in visible text, not colour alone. The button is a real `<a href="/">`. 82 + - Every error page emits `<meta name="robots" content="noindex">` so the playful copy is 83 + never indexed, and opts out of social/canonical tags (`socialMeta={false}` on `Base`). 84 + 85 + ## Tests (TDD — copy is test-locked) 86 + 87 + - `errors.test.ts` — for each kind: correct `status` (404 vs 500), correct eyebrow/heading, 88 + context interpolation (`handle`, `slug`, `publicationName` land in the strings), and a 89 + guard asserting **no em dash (`—`)** appears in any produced string. 90 + - Reader-route `.meta.test.ts` files already exist; extend the relevant ones to assert an 91 + error case sets a 404 status and renders the scene rather than plain text. 92 + 93 + ## Out of scope 94 + 95 + - Per-situation CTAs beyond "Back to homepage" (deliberately one button — decided in design). 96 + - Search / "did you mean" suggestions on 404. 97 + - Illustrated/animated art beyond the CSS horizon scene (no image assets). 98 + - Localisation of error copy (English only for v1, consistent with the rest of the reader).
+133
src/components/ErrorScene.astro
··· 1 + --- 2 + import Base from '../layouts/Base.astro'; 3 + import Logo from './Logo.astro'; 4 + 5 + interface Props { 6 + eyebrow: string; 7 + heading: string; 8 + subline: string; 9 + } 10 + const { eyebrow, heading, subline } = Astro.props; 11 + --- 12 + 13 + <Base title={`${ heading } · SkyPress`} socialMeta={false}> 14 + <Fragment slot="head"> 15 + <meta name="robots" content="noindex" /> 16 + </Fragment> 17 + 18 + <header class="err-mast"> 19 + <a href="/"><Logo /></a> 20 + </header> 21 + 22 + <main class="err"> 23 + <div class="err__sky" aria-hidden="true"> 24 + <span class="err__birds">︶&nbsp;&nbsp;︶</span> 25 + <span class="err__sun"></span> 26 + <span class="err__horizon"></span> 27 + </div> 28 + <div class="err__body"> 29 + <p class="eyebrow">{eyebrow}</p> 30 + <h1 class="err__heading">{heading}</h1> 31 + <p class="err__sub">{subline}</p> 32 + <a class="btn btn--primary" href="/">Back to homepage</a> 33 + </div> 34 + </main> 35 + </Base> 36 + 37 + <style> 38 + .err-mast { 39 + padding: 1.5rem clamp(1.25rem, 5vw, 4rem); 40 + } 41 + .err-mast a { 42 + text-decoration: none; 43 + } 44 + .err { 45 + position: relative; 46 + min-height: calc(100vh - 6rem); 47 + display: flex; 48 + flex-direction: column; 49 + align-items: center; 50 + justify-content: flex-end; 51 + text-align: center; 52 + padding: 0 1.5rem clamp(3rem, 12vh, 7rem); 53 + overflow: hidden; 54 + /* Light sky: warm dawn at the top, a hard-ish stop forms the horizon band. */ 55 + background: linear-gradient(180deg, #fff4df 0%, #fcdfb8 33%, var(--paper) 33%); 56 + } 57 + .err__sky { 58 + position: absolute; 59 + inset: 0; 60 + z-index: 0; 61 + } 62 + .err__sun { 63 + position: absolute; 64 + top: clamp(4rem, 16vh, 9rem); 65 + left: 50%; 66 + width: clamp(72px, 14vw, 104px); 67 + height: clamp(72px, 14vw, 104px); 68 + transform: translateX(-50%); 69 + border-radius: 50%; 70 + background: radial-gradient(circle at 50% 38%, #f8b850, var(--sun)); 71 + box-shadow: 0 0 46px 10px rgba(232, 146, 12, 0.3); 72 + animation: err-glow 4.5s ease-in-out infinite; 73 + } 74 + @keyframes err-glow { 75 + 0%, 100% { 76 + box-shadow: 0 0 46px 10px rgba(232, 146, 12, 0.3); 77 + } 78 + 50% { 79 + box-shadow: 0 0 66px 18px rgba(232, 146, 12, 0.42); 80 + } 81 + } 82 + @media (prefers-reduced-motion: reduce) { 83 + .err__sun { 84 + animation: none; 85 + } 86 + } 87 + .err__horizon { 88 + position: absolute; 89 + top: 33%; 90 + left: 0; 91 + right: 0; 92 + height: 1px; 93 + background: linear-gradient( 94 + 90deg, 95 + transparent, 96 + var(--line-strong) 18%, 97 + var(--line-strong) 82%, 98 + transparent 99 + ); 100 + } 101 + .err__birds { 102 + position: absolute; 103 + top: clamp(3rem, 11vh, 6rem); 104 + left: 0; 105 + right: 0; 106 + text-align: center; 107 + color: var(--sun-strong); 108 + opacity: 0.5; 109 + letter-spacing: 0.6em; 110 + font-size: 0.9rem; 111 + } 112 + .err__body { 113 + position: relative; 114 + z-index: 1; 115 + max-width: 36ch; 116 + } 117 + .err__heading { 118 + font-size: clamp(2rem, 6vw, 2.9rem); 119 + line-height: 1.04; 120 + margin: 0.6rem 0 0; 121 + } 122 + .err__sub { 123 + color: var(--ink-soft); 124 + font-size: 1.05rem; 125 + line-height: 1.55; 126 + margin: 0.9rem 0 1.6rem; 127 + } 128 + @media (prefers-color-scheme: dark) { 129 + .err { 130 + background: linear-gradient(180deg, #2a1f12 0%, #3a2a14 33%, var(--paper) 33%); 131 + } 132 + } 133 + </style>
+43
src/components/ErrorScene.meta.test.ts
··· 1 + /** 2 + * Source-level guard for the shared error scene. Page/component rendering through 3 + * astro/container isn't viable in this jsdom-pinned suite (see Base.meta.test.ts), 4 + * so we pin the wiring at the source level. 5 + */ 6 + import { readFileSync } from 'node:fs'; 7 + import { dirname, join } from 'node:path'; 8 + import { fileURLToPath } from 'node:url'; 9 + import { describe, expect, it } from 'vitest'; 10 + 11 + const here = dirname( fileURLToPath( import.meta.url ) ); 12 + const component = readFileSync( join( here, './ErrorScene.astro' ), 'utf8' ); 13 + 14 + describe( 'ErrorScene component', () => { 15 + it( 'renders inside Base and opts out of social meta', () => { 16 + expect( component ).toMatch( /import Base from '[^']*layouts\/Base.astro'/ ); 17 + expect( component ).toMatch( /socialMeta=\{false\}/ ); 18 + } ); 19 + 20 + it( 'marks the page noindex so playful copy is never indexed', () => { 21 + expect( component ).toMatch( 22 + /<meta\s+name="robots"\s+content="noindex"\s*\/?>/ 23 + ); 24 + } ); 25 + 26 + it( 'renders the eyebrow, heading and subline props', () => { 27 + expect( component ).toMatch( /\{eyebrow\}/ ); 28 + expect( component ).toMatch( /\{heading\}/ ); 29 + expect( component ).toMatch( /\{subline\}/ ); 30 + } ); 31 + 32 + it( 'provides a single Back to homepage link to the site root', () => { 33 + expect( component ).toMatch( /href="\/"[^>]*>\s*Back to homepage/ ); 34 + } ); 35 + 36 + it( 'disables the sun-glow animation under reduced motion', () => { 37 + expect( component ).toMatch( /prefers-reduced-motion/ ); 38 + } ); 39 + 40 + it( 'is dependency-free (no @wordpress imports on the read path)', () => { 41 + expect( component ).not.toMatch( /@wordpress\// ); 42 + } ); 43 + } );
+64
src/lib/reader/errors.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { errorScene, type ErrorKind } from './errors'; 3 + 4 + const ALL_KINDS: ErrorKind[] = [ 5 + 'writer-not-found', 6 + 'publication-not-found', 7 + 'article-not-found', 8 + 'not-found', 9 + 'server-error', 10 + ]; 11 + 12 + describe( 'errorScene', () => { 13 + it( 'returns 404 for every not-found kind and 500 for server-error', () => { 14 + expect( errorScene( 'writer-not-found' ).status ).toBe( 404 ); 15 + expect( errorScene( 'publication-not-found' ).status ).toBe( 404 ); 16 + expect( errorScene( 'article-not-found' ).status ).toBe( 404 ); 17 + expect( errorScene( 'not-found' ).status ).toBe( 404 ); 18 + expect( errorScene( 'server-error' ).status ).toBe( 500 ); 19 + } ); 20 + 21 + it( 'interpolates the handle into the writer-not-found copy', () => { 22 + const scene = errorScene( 'writer-not-found', { handle: 'jeherve.com' } ); 23 + expect( scene.heading ).toBe( 'No writer at @jeherve.com' ); 24 + } ); 25 + 26 + it( 'interpolates handle + slug into the publication-not-found copy', () => { 27 + const scene = errorScene( 'publication-not-found', { 28 + handle: 'jeherve.com', 29 + slug: 'tribulations', 30 + } ); 31 + expect( scene.subline ).toContain( '@jeherve.com' ); 32 + expect( scene.subline ).toContain( 'tribulations' ); 33 + } ); 34 + 35 + it( 'interpolates the publication name into the article-not-found copy', () => { 36 + const scene = errorScene( 'article-not-found', { 37 + publicationName: 'Tribulations of a Software Engineer', 38 + } ); 39 + expect( scene.subline ).toContain( 'Tribulations of a Software Engineer' ); 40 + } ); 41 + 42 + it( 'falls back gracefully when the article publication name is missing', () => { 43 + const scene = errorScene( 'article-not-found' ); 44 + expect( scene.subline ).toContain( 'this publication' ); 45 + } ); 46 + 47 + it( 'keeps the horizon metaphor for the generic not-found heading', () => { 48 + expect( errorScene( 'not-found' ).heading ).toBe( 49 + 'This page set below the horizon' 50 + ); 51 + } ); 52 + 53 + it( 'never emits an em dash in any copy field (house rule)', () => { 54 + for ( const kind of ALL_KINDS ) { 55 + const scene = errorScene( kind, { 56 + handle: 'x', 57 + slug: 'y', 58 + publicationName: 'Z', 59 + } ); 60 + const text = `${ scene.eyebrow }${ scene.heading }${ scene.subline }`; 61 + expect( text ).not.toContain( '—' ); // em dash 62 + } 63 + } ); 64 + } );
+72
src/lib/reader/errors.ts
··· 1 + /** 2 + * Source of truth for reader error-page copy + HTTP status (SP12). 3 + * 4 + * Pure and dependency-free (no @wordpress, no network) so it can run on the read 5 + * path (AGENTS.md #3) and be unit-tested directly. Copy avoids em dashes by house 6 + * rule; the eyebrow uses a middle-dot kicker in SkyPress's editorial voice. 7 + */ 8 + export type ErrorKind = 9 + | 'writer-not-found' 10 + | 'publication-not-found' 11 + | 'article-not-found' 12 + | 'not-found' 13 + | 'server-error'; 14 + 15 + export interface ErrorContext { 16 + handle?: string; 17 + slug?: string; 18 + publicationName?: string; 19 + } 20 + 21 + export interface ErrorSceneCopy { 22 + status: number; 23 + eyebrow: string; 24 + heading: string; 25 + subline: string; 26 + } 27 + 28 + export function errorScene( 29 + kind: ErrorKind, 30 + context: ErrorContext = {} 31 + ): ErrorSceneCopy { 32 + switch ( kind ) { 33 + case 'writer-not-found': 34 + return { 35 + status: 404, 36 + eyebrow: '404 · no one by that name', 37 + heading: `No writer at @${ context.handle ?? '' }`, 38 + subline: 39 + 'Nobody on the network goes by that handle yet. It might have a typo.', 40 + }; 41 + case 'publication-not-found': 42 + return { 43 + status: 404, 44 + eyebrow: '404 · publication not found', 45 + heading: 'No publication by that name', 46 + subline: `@${ context.handle ?? '' } hasn't published anything under "${ context.slug ?? '' }".`, 47 + }; 48 + case 'article-not-found': 49 + return { 50 + status: 404, 51 + eyebrow: '404 · story not found', 52 + heading: 'This story set below the horizon', 53 + subline: `It's no longer part of ${ context.publicationName ?? 'this publication' }. It may have been unpublished or moved.`, 54 + }; 55 + case 'server-error': 56 + return { 57 + status: 500, 58 + eyebrow: '500 · overcast', 59 + heading: "The sky's a bit cloudy right now", 60 + subline: 61 + 'Something went wrong on our end. Give it a moment, then try again.', 62 + }; 63 + case 'not-found': 64 + default: 65 + return { 66 + status: 404, 67 + eyebrow: '404 · off the map', 68 + heading: 'This page set below the horizon', 69 + subline: "There's nothing to read at this address.", 70 + }; 71 + } 72 + }
+12
src/pages/404.astro
··· 1 + --- 2 + import ErrorScene from '../components/ErrorScene.astro'; 3 + import { errorScene } from '../lib/reader/errors'; 4 + 5 + // Generic, data-free: prerender to a static 404.html the adapter serves for 6 + // any unmatched URL. 7 + export const prerender = true; 8 + 9 + const scene = errorScene( 'not-found' ); 10 + --- 11 + 12 + <ErrorScene eyebrow={scene.eyebrow} heading={scene.heading} subline={scene.subline} />
+19
src/pages/404.meta.test.ts
··· 1 + import { readFileSync } from 'node:fs'; 2 + import { dirname, join } from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { describe, expect, it } from 'vitest'; 5 + 6 + const here = dirname( fileURLToPath( import.meta.url ) ); 7 + const page = readFileSync( join( here, './404.astro' ), 'utf8' ); 8 + 9 + describe( 'global 404 page', () => { 10 + it( 'is prerendered (static, no per-request data)', () => { 11 + expect( page ).toMatch( /export const prerender = true/ ); 12 + } ); 13 + 14 + it( 'renders ErrorScene with the generic not-found copy', () => { 15 + expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 16 + expect( page ).toMatch( /errorScene\(\s*'not-found'\s*\)/ ); 17 + expect( page ).toMatch( /<ErrorScene/ ); 18 + } ); 19 + } );
+12
src/pages/500.astro
··· 1 + --- 2 + import ErrorScene from '../components/ErrorScene.astro'; 3 + import { errorScene } from '../lib/reader/errors'; 4 + 5 + // Astro auto-renders this page when an SSR route throws (e.g. a PDS fetch fails), 6 + // so no per-route try/catch is needed. Static copy, so prerender it. 7 + export const prerender = true; 8 + 9 + const scene = errorScene( 'server-error' ); 10 + --- 11 + 12 + <ErrorScene eyebrow={scene.eyebrow} heading={scene.heading} subline={scene.subline} />
+19
src/pages/500.meta.test.ts
··· 1 + import { readFileSync } from 'node:fs'; 2 + import { dirname, join } from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { describe, expect, it } from 'vitest'; 5 + 6 + const here = dirname( fileURLToPath( import.meta.url ) ); 7 + const page = readFileSync( join( here, './500.astro' ), 'utf8' ); 8 + 9 + describe( 'global 500 page', () => { 10 + it( 'is prerendered (static, no per-request data)', () => { 11 + expect( page ).toMatch( /export const prerender = true/ ); 12 + } ); 13 + 14 + it( 'renders ErrorScene with the server-error copy', () => { 15 + expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 16 + expect( page ).toMatch( /errorScene\(\s*'server-error'\s*\)/ ); 17 + expect( page ).toMatch( /<ErrorScene/ ); 18 + } ); 19 + } );
+94 -59
src/pages/[author]/[slug]/[rkey].astro
··· 10 10 import { canonicalArticleUrl } from '../../../lib/publish/records'; 11 11 import { themeStyleBlock } from '../../../lib/publish/themes'; 12 12 import { buildMetaTags } from '../../../lib/seo/meta'; 13 + import ErrorScene from '../../../components/ErrorScene.astro'; 14 + import { errorScene } from '../../../lib/reader/errors'; 15 + import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 13 16 14 17 // Frontend block styles only — no editor chrome, no JS. 15 18 import '@wordpress/block-library/build-style/common.css'; ··· 31 34 32 35 const { author, slug, rkey } = Astro.params; 33 36 37 + let error: ErrorSceneCopy | null = null; 38 + const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 39 + 40 + // Everything the happy-path template needs; populated only when there's no error. 41 + let publication: Awaited< ReturnType< typeof resolveReaderPublication > > = null; 42 + let metaTags: ReturnType< typeof buildMetaTags > = []; 43 + let title = ''; 44 + let description = ''; 45 + let canonical = ''; 46 + let docUri = ''; 47 + let pubUrl = ''; 48 + let feedHref = ''; 49 + let html = ''; 50 + let readingMinutes = 1; 51 + let publishedLabel: string | null = null; 52 + let updatedLabel: string | null = null; 53 + let themeStyle = ''; 54 + let ogImage = ''; 55 + 34 56 if ( ! author || ! author.startsWith( '@' ) || ! slug || ! rkey ) { 35 - return new Response( 'Not found', { status: 404 } ); 57 + error = errorScene( 'not-found' ); 36 58 } 37 - const handle = author.slice( 1 ); 38 59 39 - const resolved = await resolveAuthor( handle ); 40 - if ( ! resolved ) { 41 - return new Response( `Could not resolve @${ handle }`, { status: 404 } ); 60 + const resolved = error ? null : await resolveAuthor( handle ); 61 + if ( ! error && ! resolved ) { 62 + error = errorScene( 'writer-not-found', { handle } ); 42 63 } 43 - const { did, pdsUrl } = resolved; 44 64 45 - // Resolve the publication by slug (Decision 0010), then the document by rkey. 46 - const publication = await resolveReaderPublication( pdsUrl, did, slug ); 47 - if ( ! publication ) { 48 - return new Response( 'Publication not found', { status: 404 } ); 49 - } 65 + if ( ! error && resolved ) { 66 + const { did, pdsUrl } = resolved; 50 67 51 - const record = await getRecord< SkyDocument >( pdsUrl, did, 'site.standard.document', rkey ); 52 - if ( ! record?.value ) { 53 - return new Response( 'Article not found', { status: 404 } ); 54 - } 68 + publication = await resolveReaderPublication( pdsUrl, did, slug! ); 69 + if ( ! publication ) { 70 + error = errorScene( 'publication-not-found', { handle, slug } ); 71 + } else { 72 + const record = await getRecord< SkyDocument >( 73 + pdsUrl, 74 + did, 75 + 'site.standard.document', 76 + rkey! 77 + ); 78 + if ( ! record?.value || record.value.site !== publication.uri ) { 79 + error = errorScene( 'article-not-found', { 80 + publicationName: publication.name, 81 + } ); 82 + } else { 83 + const doc = record.value; 84 + const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 85 + html = sanitizeArticleHtml( renderBlocks( blocks ) ); 86 + const textContent = doc.textContent || blocksToText( blocks ); 55 87 56 - // A document only renders under the publication it belongs to (Decision 0010, §3 step 3). 57 - if ( record.value.site !== publication.uri ) { 58 - return new Response( 'Article not found in this publication', { status: 404 } ); 59 - } 88 + title = doc.title ?? 'Untitled'; 89 + description = doc.description || textContent.slice( 0, 200 ); 90 + canonical = canonicalArticleUrl( handle, slug!, rkey! ); 91 + docUri = `at://${ did }/site.standard.document/${ rkey }`; 92 + pubUrl = `/${ author }/${ slug }`; 93 + feedHref = `${ pubUrl }/rss.xml`; 60 94 61 - const doc = record.value; 62 - const blocks = resolveBlobImageUrls( doc.content?.blocks ?? [], { pdsUrl, did } ); 63 - const html = sanitizeArticleHtml( renderBlocks( blocks ) ); 64 - const textContent = doc.textContent || blocksToText( blocks ); 95 + const words = textContent.split( /\s+/ ).filter( Boolean ).length; 96 + readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 97 + publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 98 + updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 99 + themeStyle = themeStyleBlock( publication.basicTheme ); 65 100 66 - const title = doc.title ?? 'Untitled'; 67 - const description = doc.description || textContent.slice( 0, 200 ); 68 - const canonical = canonicalArticleUrl( handle, slug, rkey ); 69 - const docUri = `at://${ did }/site.standard.document/${ rkey }`; 70 - const pubUrl = `/${ author }/${ slug }`; 71 - const feedHref = `${ pubUrl }/rss.xml`; 72 - 73 - const words = textContent.split( /\s+/ ).filter( Boolean ).length; 74 - const readingMinutes = Math.max( 1, Math.round( words / 200 ) ); 75 - const publishedLabel = doc.publishedAt ? doc.publishedAt.slice( 0, 10 ) : null; 76 - const updatedLabel = doc.updatedAt ? doc.updatedAt.slice( 0, 10 ) : null; 77 - const themeStyle = themeStyleBlock( publication.basicTheme ); 101 + // Share image: the publication logo, else the shared default. Only the 102 + // default image has known 1200x630 dimensions; a square logo omits them. 103 + ogImage = publication.icon 104 + ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 105 + : new URL( '/og-default.png', Astro.site ).href; 106 + const ogDimensions = publication.icon 107 + ? {} 108 + : { imageWidth: 1200, imageHeight: 630 }; 109 + metaTags = buildMetaTags( { 110 + title, 111 + description, 112 + url: canonical, 113 + image: ogImage, 114 + siteName: 'SkyPress', 115 + type: 'article', 116 + imageAlt: publication.name, 117 + ...ogDimensions, 118 + } ); 119 + } 120 + } 121 + } 78 122 79 - // Share image: the publication logo, else the shared default. Only the default 80 - // image has known 1200x630 dimensions; a square logo omits them. 81 - const ogImage = publication.icon 82 - ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 83 - : new URL( '/og-default.png', Astro.site ).href; 84 - const ogDimensions = publication.icon 85 - ? {} 86 - : { imageWidth: 1200, imageHeight: 630 }; 87 - const metaTags = buildMetaTags( { 88 - title, 89 - description, 90 - url: canonical, 91 - image: ogImage, 92 - siteName: 'SkyPress', 93 - type: 'article', 94 - imageAlt: publication.name, 95 - ...ogDimensions, 96 - } ); 123 + if ( error ) { 124 + Astro.response.status = error.status; 125 + } 97 126 --- 98 127 99 - <Base title={`${ title } — ${ publication.name }`} description={description} socialMeta={false}> 128 + { 129 + error ? ( 130 + <ErrorScene eyebrow={error.eyebrow} heading={error.heading} subline={error.subline} /> 131 + ) : ( 132 + <Base title={`${ title } — ${ publication!.name }`} description={description} socialMeta={false}> 100 133 <Fragment slot="head"> 101 134 {/* standard.site indexing tags (brief §3) — Bluesky cards + AppView pickup */} 102 135 <link rel="site.standard.document" href={docUri} /> 103 - <link rel="site.standard.publication" href={publication.uri} /> 104 - <link rel="alternate" type="application/rss+xml" title={`${ publication.name } — RSS`} href={feedHref} /> 136 + <link rel="site.standard.publication" href={publication!.uri} /> 137 + <link rel="alternate" type="application/rss+xml" title={`${ publication!.name } — RSS`} href={feedHref} /> 105 138 <link rel="canonical" href={canonical} /> 106 139 {metaTags.map( ( tag ) => 107 140 tag.property ? ( ··· 118 151 </header> 119 152 <main class="reader"> 120 153 <p class="reader__meta eyebrow"> 121 - <a class="reader__author" href={pubUrl}>{publication.name}</a> 154 + <a class="reader__author" href={pubUrl}>{publication!.name}</a> 122 155 {publishedLabel && <> · {publishedLabel}</>} 123 156 {updatedLabel && <> · updated {updatedLabel}</>} 124 157 · {readingMinutes} min read ··· 126 159 <h1 class="reader__title">{title}</h1> 127 160 <article class="reader__article" set:html={html} /> 128 161 <footer class="reader__foot"> 129 - <a class="reader__author" href={pubUrl}>More from {publication.name}</a> 162 + <a class="reader__author" href={pubUrl}>More from {publication!.name}</a> 130 163 </footer> 131 164 </main> 132 - </Base> 165 + </Base> 166 + ) 167 + } 133 168 134 169 <style> 135 170 .masthead {
+16
src/pages/[author]/[slug]/_[rkey].meta.test.ts
··· 41 41 expect( page ).toMatch( /socialMeta=\{false\}/ ); 42 42 expect( page ).toMatch( /<link\s+rel="canonical"\s+href=\{canonical\}/ ); 43 43 } ); 44 + 45 + it( 'renders the ErrorScene instead of plain-text 404 responses', () => { 46 + 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 + expect( page ).toMatch( /<ErrorScene/ ); 49 + // No more bare plain-text 404 bodies. 50 + expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 51 + expect( page ).not.toMatch( /new Response\(\s*'Article not found'/ ); 52 + } ); 53 + 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'/ ); 58 + expect( page ).toMatch( /Astro\.response\.status\s*=/ ); 59 + } ); 44 60 } );
+17 -1
src/pages/[author]/[slug]/_index.meta.test.ts
··· 20 20 } ); 21 21 22 22 it( 'passes the publication name as the image alt', () => { 23 - expect( page ).toMatch( /imageAlt=\{\s*publication\.name\s*\}/ ); 23 + expect( page ).toMatch( /imageAlt=\{\s*publication!?\.name\s*\}/ ); 24 + } ); 25 + } ); 26 + 27 + describe( 'publication page error wiring', () => { 28 + it( 'renders ErrorScene instead of plain-text 404s', () => { 29 + expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 30 + expect( page ).toMatch( /import\s*\{\s*errorScene/ ); 31 + expect( page ).toMatch( /<ErrorScene/ ); 32 + expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 33 + 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*=/ ); 24 40 } ); 25 41 } );
+126 -93
src/pages/[author]/[slug]/index.astro
··· 7 7 import { fetchActorProfile } from '../../../lib/reader/profile'; 8 8 import { buildGetBlobUrl } from '../../../lib/media/blob'; 9 9 import { themeStyleBlock } from '../../../lib/publish/themes'; 10 + import ErrorScene from '../../../components/ErrorScene.astro'; 11 + import { errorScene } from '../../../lib/reader/errors'; 12 + import type { ErrorSceneCopy } from '../../../lib/reader/errors'; 10 13 11 14 export const prerender = false; 12 15 ··· 18 21 } 19 22 20 23 const { author, slug } = Astro.params; 24 + 25 + let error: ErrorSceneCopy | null = null; 26 + const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 27 + 28 + let publication: Awaited< ReturnType< typeof resolveReaderPublication > > = null; 29 + let profile: Awaited< ReturnType< typeof fetchActorProfile > > | null = null; 30 + let articles: Array< { 31 + rkey: string; 32 + title: string; 33 + description?: string; 34 + publishedLabel: string | null; 35 + } > = []; 36 + let logoUrl: string | null = null; 37 + let authorName = ''; 38 + let initial = ''; 39 + let feedHref = ''; 40 + let themeStyle = ''; 41 + 21 42 if ( ! author || ! author.startsWith( '@' ) || ! slug ) { 22 - return new Response( 'Not found', { status: 404 } ); 43 + error = errorScene( 'not-found' ); 23 44 } 24 - const handle = author.slice( 1 ); 25 45 26 - const resolved = await resolveAuthor( handle ); 27 - if ( ! resolved ) { 28 - return new Response( `Could not resolve @${ handle }`, { status: 404 } ); 46 + const resolved = error ? null : await resolveAuthor( handle ); 47 + if ( ! error && ! resolved ) { 48 + error = errorScene( 'writer-not-found', { handle } ); 29 49 } 30 - const { did, pdsUrl } = resolved; 31 50 32 - const publication = await resolveReaderPublication( pdsUrl, did, slug ); 33 - if ( ! publication ) { 34 - return new Response( 'Publication not found', { status: 404 } ); 35 - } 51 + if ( ! error && resolved ) { 52 + const { did, pdsUrl } = resolved; 36 53 37 - const profile = await fetchActorProfile( pdsUrl, did ); 54 + publication = await resolveReaderPublication( pdsUrl, did, slug! ); 55 + if ( ! publication ) { 56 + error = errorScene( 'publication-not-found', { handle, slug } ); 57 + } else { 58 + profile = await fetchActorProfile( pdsUrl, did ); 38 59 39 - const allDocs = await listRecords< DocumentValue >( 40 - pdsUrl, 41 - did, 42 - 'site.standard.document', 43 - 100 44 - ); 45 - const articles = allDocs 46 - .filter( ( record ) => record.value.site === publication.uri ) 47 - .map( ( record ) => ( { 48 - rkey: record.uri.split( '/' ).pop() as string, 49 - title: record.value.title ?? 'Untitled', 50 - description: record.value.description, 51 - publishedLabel: record.value.publishedAt?.slice( 0, 10 ) ?? null, 52 - } ) ); 60 + const allDocs = await listRecords< DocumentValue >( 61 + pdsUrl, 62 + did, 63 + 'site.standard.document', 64 + 100 65 + ); 66 + articles = allDocs 67 + .filter( ( record ) => record.value.site === publication!.uri ) 68 + .map( ( record ) => ( { 69 + rkey: record.uri.split( '/' ).pop() as string, 70 + title: record.value.title ?? 'Untitled', 71 + description: record.value.description, 72 + publishedLabel: record.value.publishedAt?.slice( 0, 10 ) ?? null, 73 + } ) ); 53 74 54 - const logoUrl = publication.icon 55 - ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 56 - : null; 57 - const authorName = profile.displayName ?? `@${ handle }`; 58 - const initial = publication.name.charAt( 0 ).toUpperCase(); 59 - const feedHref = `/${ author }/${ slug }/rss.xml`; 60 - const themeStyle = themeStyleBlock( publication.basicTheme ); 75 + logoUrl = publication.icon 76 + ? buildGetBlobUrl( pdsUrl, did, publication.icon.ref.$link ) 77 + : null; 78 + authorName = profile.displayName ?? `@${ handle }`; 79 + initial = publication.name.charAt( 0 ).toUpperCase(); 80 + feedHref = `/${ author }/${ slug }/rss.xml`; 81 + themeStyle = themeStyleBlock( publication.basicTheme ); 82 + } 83 + } 84 + 85 + if ( error ) { 86 + Astro.response.status = error.status; 87 + } 61 88 --- 62 89 63 - <Base 64 - title={`${ publication.name } — SkyPress`} 65 - description={publication.description ?? undefined} 66 - image={logoUrl ?? undefined} 67 - imageAlt={publication.name} 68 - > 69 - <Fragment slot="head"> 70 - <link rel="site.standard.publication" href={publication.uri} /> 71 - <link rel="alternate" type="application/rss+xml" title={`${ publication.name } — RSS`} href={feedHref} /> 72 - {themeStyle && <Fragment set:html={themeStyle} />} 73 - </Fragment> 90 + { 91 + error ? ( 92 + <ErrorScene eyebrow={error.eyebrow} heading={error.heading} subline={error.subline} /> 93 + ) : ( 94 + <Base 95 + title={`${ publication!.name } — SkyPress`} 96 + description={publication!.description ?? undefined} 97 + image={logoUrl ?? undefined} 98 + imageAlt={publication!.name} 99 + > 100 + <Fragment slot="head"> 101 + <link rel="site.standard.publication" href={publication!.uri} /> 102 + <link rel="alternate" type="application/rss+xml" title={`${ publication!.name } — RSS`} href={feedHref} /> 103 + {themeStyle && <Fragment set:html={themeStyle} />} 104 + </Fragment> 105 + 106 + <header class="masthead"> 107 + <a href="/"><Logo /></a> 108 + </header> 74 109 75 - <header class="masthead"> 76 - <a href="/"><Logo /></a> 77 - </header> 110 + <main class="pub"> 111 + <div class="pub__hero"> 112 + {profile!.banner && ( 113 + <div class="pub__cover" style={`background-image:url("${ profile!.banner }")`} aria-hidden="true"></div> 114 + )} 115 + <div class={`pub__identity${ profile!.banner ? ' pub__identity--overlap' : '' }`}> 116 + {logoUrl ? ( 117 + <img class="pub__logo" src={logoUrl} alt="" width="88" height="88" /> 118 + ) : ( 119 + <span class="pub__logo pub__logo--fallback" aria-hidden="true">{initial}</span> 120 + )} 121 + <h1 class="pub__title">{publication!.name}</h1> 122 + <p class="pub__byline"> 123 + <a href={`/@${ handle }`}> 124 + {profile!.avatar && ( 125 + <img class="pub__byavatar" src={profile!.avatar} alt="" width="22" height="22" /> 126 + )} 127 + by {authorName} 128 + </a> 129 + </p> 130 + {publication!.description && <p class="pub__lede">{publication!.description}</p>} 131 + <p class="pub__feed"> 132 + <a href={feedHref}> 133 + <svg class="pub__feedicon" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"> 134 + <circle cx="4.5" cy="19.5" r="2.5" /> 135 + <path d="M2 10.5v3.2a7.3 7.3 0 0 1 7.3 7.3h3.2A10.5 10.5 0 0 0 2 10.5Z" /> 136 + <path d="M2 3.6v3.2A14.2 14.2 0 0 1 16.2 21h3.2A17.4 17.4 0 0 0 2 3.6Z" /> 137 + </svg> 138 + RSS 139 + </a> 140 + </p> 141 + </div> 142 + </div> 78 143 79 - <main class="pub"> 80 - <div class="pub__hero"> 81 - {profile.banner && ( 82 - <div class="pub__cover" style={`background-image:url("${ profile.banner }")`} aria-hidden="true"></div> 83 - )} 84 - <div class={`pub__identity${ profile.banner ? ' pub__identity--overlap' : '' }`}> 85 - {logoUrl ? ( 86 - <img class="pub__logo" src={logoUrl} alt="" width="88" height="88" /> 144 + {articles.length === 0 ? ( 145 + <p class="pub__empty">No articles published in this publication yet.</p> 87 146 ) : ( 88 - <span class="pub__logo pub__logo--fallback" aria-hidden="true">{initial}</span> 147 + <ul class="pub__list"> 148 + {articles.map( ( article ) => ( 149 + <li class="pub__item"> 150 + <a href={`/@${ handle }/${ slug }/${ article.rkey }`}>{article.title}</a> 151 + {article.publishedLabel && <span class="pub__date">{article.publishedLabel}</span>} 152 + {article.description && <p class="pub__desc">{article.description}</p>} 153 + </li> 154 + ) )} 155 + </ul> 89 156 )} 90 - <h1 class="pub__title">{publication.name}</h1> 91 - <p class="pub__byline"> 92 - <a href={`/@${ handle }`}> 93 - {profile.avatar && ( 94 - <img class="pub__byavatar" src={profile.avatar} alt="" width="22" height="22" /> 95 - )} 96 - by {authorName} 97 - </a> 98 - </p> 99 - {publication.description && <p class="pub__lede">{publication.description}</p>} 100 - <p class="pub__feed"> 101 - <a href={feedHref}> 102 - <svg class="pub__feedicon" width="14" height="14" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"> 103 - <circle cx="4.5" cy="19.5" r="2.5" /> 104 - <path d="M2 10.5v3.2a7.3 7.3 0 0 1 7.3 7.3h3.2A10.5 10.5 0 0 0 2 10.5Z" /> 105 - <path d="M2 3.6v3.2A14.2 14.2 0 0 1 16.2 21h3.2A17.4 17.4 0 0 0 2 3.6Z" /> 106 - </svg> 107 - RSS 108 - </a> 109 - </p> 110 - </div> 111 - </div> 112 - 113 - {articles.length === 0 ? ( 114 - <p class="pub__empty">No articles published in this publication yet.</p> 115 - ) : ( 116 - <ul class="pub__list"> 117 - {articles.map( ( article ) => ( 118 - <li class="pub__item"> 119 - <a href={`/@${ handle }/${ slug }/${ article.rkey }`}>{article.title}</a> 120 - {article.publishedLabel && <span class="pub__date">{article.publishedLabel}</span>} 121 - {article.description && <p class="pub__desc">{article.description}</p>} 122 - </li> 123 - ) )} 124 - </ul> 125 - )} 126 - </main> 127 - </Base> 157 + </main> 158 + </Base> 159 + ) 160 + } 128 161 129 162 <style> 130 163 .masthead {
+117 -90
src/pages/[author]/index.astro
··· 6 6 import { fetchActorProfile } from '../../lib/reader/profile'; 7 7 import { buildGetBlobUrl } from '../../lib/media/blob'; 8 8 import CreatePublicationCta from '../../components/CreatePublicationCta.tsx'; 9 + import ErrorScene from '../../components/ErrorScene.astro'; 10 + import { errorScene } from '../../lib/reader/errors'; 11 + import type { ErrorSceneCopy } from '../../lib/reader/errors'; 9 12 10 13 export const prerender = false; 11 14 12 15 const { author } = Astro.params; 16 + 17 + let error: ErrorSceneCopy | null = null; 18 + const handle = author?.startsWith( '@' ) ? author.slice( 1 ) : ''; 19 + 20 + let did = ''; 21 + let pdsUrl = ''; 22 + let profile: Awaited< ReturnType< typeof fetchActorProfile > > | null = null; 23 + let publications: Awaited< ReturnType< typeof listReaderPublications > > = []; 24 + let authorName = ''; 25 + let initial = ''; 26 + 13 27 if ( ! author || ! author.startsWith( '@' ) ) { 14 - return new Response( 'Not found', { status: 404 } ); 28 + error = errorScene( 'not-found' ); 15 29 } 16 - const handle = author.slice( 1 ); 17 30 18 - const resolved = await resolveAuthor( handle ); 19 - if ( ! resolved ) { 20 - return new Response( `Could not resolve @${ handle }`, { status: 404 } ); 31 + const resolved = error ? null : await resolveAuthor( handle ); 32 + if ( ! error && ! resolved ) { 33 + error = errorScene( 'writer-not-found', { handle } ); 21 34 } 22 - const { did, pdsUrl } = resolved; 23 35 24 - const [ profile, publications ] = await Promise.all( [ 25 - fetchActorProfile( pdsUrl, did ), 26 - listReaderPublications( pdsUrl, did ), 27 - ] ); 36 + if ( ! error && resolved ) { 37 + did = resolved.did; 38 + pdsUrl = resolved.pdsUrl; 39 + 40 + [ profile, publications ] = await Promise.all( [ 41 + fetchActorProfile( pdsUrl, did ), 42 + listReaderPublications( pdsUrl, did ), 43 + ] ); 28 44 29 - const authorName = profile.displayName ?? `@${ handle }`; 30 - const initial = authorName.replace( /^@/, '' ).charAt( 0 ).toUpperCase(); 45 + authorName = profile.displayName ?? `@${ handle }`; 46 + initial = authorName.replace( /^@/, '' ).charAt( 0 ).toUpperCase(); 47 + } 48 + 49 + if ( error ) { 50 + Astro.response.status = error.status; 51 + } 31 52 --- 32 53 33 - <Base title={`${ authorName } — SkyPress`} description={profile.description ?? undefined}> 34 - <header class="masthead"> 35 - <a href="/"><Logo /></a> 36 - </header> 54 + { 55 + error ? ( 56 + <ErrorScene eyebrow={error.eyebrow} heading={error.heading} subline={error.subline} /> 57 + ) : ( 58 + <Base title={`${ authorName } — SkyPress`} description={profile!.description ?? undefined}> 59 + <header class="masthead"> 60 + <a href="/"><Logo /></a> 61 + </header> 37 62 38 - <main class="author"> 39 - <div class="author__hero"> 40 - {profile.banner && ( 41 - <div class="author__cover" style={`background-image:url("${ profile.banner }")`} aria-hidden="true"></div> 42 - )} 43 - <div class={`author__identity${ profile.banner ? ' author__identity--overlap' : '' }`}> 44 - {profile.avatar ? ( 45 - <img class="author__avatar" src={profile.avatar} alt="" width="96" height="96" /> 63 + <main class="author"> 64 + <div class="author__hero"> 65 + {profile!.banner && ( 66 + <div class="author__cover" style={`background-image:url("${ profile!.banner }")`} aria-hidden="true"></div> 67 + )} 68 + <div class={`author__identity${ profile!.banner ? ' author__identity--overlap' : '' }`}> 69 + {profile!.avatar ? ( 70 + <img class="author__avatar" src={profile!.avatar} alt="" width="96" height="96" /> 71 + ) : ( 72 + <span class="author__avatar author__avatar--fallback" aria-hidden="true">{initial}</span> 73 + )} 74 + <h1 class="author__name">{authorName}</h1> 75 + <p class="author__handle"> 76 + <a 77 + class="author__handle-link" 78 + href={`https://bsky.app/profile/${ handle }`} 79 + target="_blank" 80 + rel="noopener noreferrer" 81 + aria-label={`View @${ handle } on Bluesky`} 82 + > 83 + @{handle} 84 + <svg 85 + class="author__handle-arrow" 86 + width="11" 87 + height="11" 88 + viewBox="0 0 24 24" 89 + fill="none" 90 + stroke="currentColor" 91 + stroke-width="2.5" 92 + stroke-linecap="round" 93 + stroke-linejoin="round" 94 + aria-hidden="true" 95 + > 96 + <path d="M7 17 17 7" /> 97 + <path d="M8 7h9v9" /> 98 + </svg> 99 + </a> 100 + </p> 101 + {profile!.description && <p class="author__bio">{profile!.description}</p>} 102 + </div> 103 + </div> 104 + 105 + <h2 class="author__heading">Publications</h2> 106 + {publications.length === 0 ? ( 107 + <div class="author__emptyblock"> 108 + <p class="author__empty">No SkyPress publications yet.</p> 109 + <CreatePublicationCta client:only="react" profileDid={did} /> 110 + </div> 46 111 ) : ( 47 - <span class="author__avatar author__avatar--fallback" aria-hidden="true">{initial}</span> 112 + <ul class="author__list"> 113 + {publications.map( ( pub ) => { 114 + const logoUrl = pub.icon 115 + ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 116 + : null; 117 + return ( 118 + <li class="author__item"> 119 + <a class="author__pub" href={`/@${ handle }/${ pub.slug }`}> 120 + {logoUrl ? ( 121 + <img class="author__publogo" src={logoUrl} alt="" width="52" height="52" /> 122 + ) : ( 123 + <span class="author__publogo author__publogo--fallback" aria-hidden="true"> 124 + {pub.name.charAt( 0 ).toUpperCase()} 125 + </span> 126 + )} 127 + <span class="author__pubtext"> 128 + <span class="author__pubname">{pub.name}</span> 129 + {pub.description && <span class="author__pubdesc">{pub.description}</span>} 130 + </span> 131 + </a> 132 + </li> 133 + ); 134 + } )} 135 + </ul> 48 136 )} 49 - <h1 class="author__name">{authorName}</h1> 50 - <p class="author__handle"> 51 - <a 52 - class="author__handle-link" 53 - href={`https://bsky.app/profile/${ handle }`} 54 - target="_blank" 55 - rel="noopener noreferrer" 56 - aria-label={`View @${ handle } on Bluesky`} 57 - > 58 - @{handle} 59 - <svg 60 - class="author__handle-arrow" 61 - width="11" 62 - height="11" 63 - viewBox="0 0 24 24" 64 - fill="none" 65 - stroke="currentColor" 66 - stroke-width="2.5" 67 - stroke-linecap="round" 68 - stroke-linejoin="round" 69 - aria-hidden="true" 70 - > 71 - <path d="M7 17 17 7" /> 72 - <path d="M8 7h9v9" /> 73 - </svg> 74 - </a> 75 - </p> 76 - {profile.description && <p class="author__bio">{profile.description}</p>} 77 - </div> 78 - </div> 79 - 80 - <h2 class="author__heading">Publications</h2> 81 - {publications.length === 0 ? ( 82 - <div class="author__emptyblock"> 83 - <p class="author__empty">No SkyPress publications yet.</p> 84 - <CreatePublicationCta client:only="react" profileDid={did} /> 85 - </div> 86 - ) : ( 87 - <ul class="author__list"> 88 - {publications.map( ( pub ) => { 89 - const logoUrl = pub.icon 90 - ? buildGetBlobUrl( pdsUrl, did, pub.icon.ref.$link ) 91 - : null; 92 - return ( 93 - <li class="author__item"> 94 - <a class="author__pub" href={`/@${ handle }/${ pub.slug }`}> 95 - {logoUrl ? ( 96 - <img class="author__publogo" src={logoUrl} alt="" width="52" height="52" /> 97 - ) : ( 98 - <span class="author__publogo author__publogo--fallback" aria-hidden="true"> 99 - {pub.name.charAt( 0 ).toUpperCase()} 100 - </span> 101 - )} 102 - <span class="author__pubtext"> 103 - <span class="author__pubname">{pub.name}</span> 104 - {pub.description && <span class="author__pubdesc">{pub.description}</span>} 105 - </span> 106 - </a> 107 - </li> 108 - ); 109 - } )} 110 - </ul> 111 - )} 112 - </main> 113 - </Base> 137 + </main> 138 + </Base> 139 + ) 140 + } 114 141 115 142 <style> 116 143 .masthead {
+23
src/pages/[author]/index.meta.test.ts
··· 1 + import { readFileSync } from 'node:fs'; 2 + import { dirname, join } from 'node:path'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { describe, expect, it } from 'vitest'; 5 + 6 + const here = dirname( fileURLToPath( import.meta.url ) ); 7 + const page = readFileSync( join( here, './index.astro' ), 'utf8' ); 8 + 9 + describe( 'writer page error wiring', () => { 10 + it( 'renders ErrorScene instead of plain-text 404s', () => { 11 + expect( page ).toMatch( /import ErrorScene from '[^']*components\/ErrorScene.astro'/ ); 12 + expect( page ).toMatch( /import\s*\{\s*errorScene/ ); 13 + expect( page ).toMatch( /<ErrorScene/ ); 14 + expect( page ).not.toMatch( /new Response\(\s*'Not found'/ ); 15 + 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 + } ); 23 + } );