···11+import { describe, expect, it } from 'vitest';
22+import { errorScene, type ErrorKind } from './errors';
33+44+const ALL_KINDS: ErrorKind[] = [
55+ 'writer-not-found',
66+ 'publication-not-found',
77+ 'article-not-found',
88+ 'not-found',
99+ 'server-error',
1010+];
1111+1212+describe( 'errorScene', () => {
1313+ it( 'returns 404 for every not-found kind and 500 for server-error', () => {
1414+ expect( errorScene( 'writer-not-found' ).status ).toBe( 404 );
1515+ expect( errorScene( 'publication-not-found' ).status ).toBe( 404 );
1616+ expect( errorScene( 'article-not-found' ).status ).toBe( 404 );
1717+ expect( errorScene( 'not-found' ).status ).toBe( 404 );
1818+ expect( errorScene( 'server-error' ).status ).toBe( 500 );
1919+ } );
2020+2121+ it( 'interpolates the handle into the writer-not-found copy', () => {
2222+ const scene = errorScene( 'writer-not-found', { handle: 'jeherve.com' } );
2323+ expect( scene.heading ).toBe( 'No writer at @jeherve.com' );
2424+ } );
2525+2626+ it( 'interpolates handle + slug into the publication-not-found copy', () => {
2727+ const scene = errorScene( 'publication-not-found', {
2828+ handle: 'jeherve.com',
2929+ slug: 'tribulations',
3030+ } );
3131+ expect( scene.subline ).toContain( '@jeherve.com' );
3232+ expect( scene.subline ).toContain( 'tribulations' );
3333+ } );
3434+3535+ it( 'interpolates the publication name into the article-not-found copy', () => {
3636+ const scene = errorScene( 'article-not-found', {
3737+ publicationName: 'Tribulations of a Software Engineer',
3838+ } );
3939+ expect( scene.subline ).toContain( 'Tribulations of a Software Engineer' );
4040+ } );
4141+4242+ it( 'falls back gracefully when the article publication name is missing', () => {
4343+ const scene = errorScene( 'article-not-found' );
4444+ expect( scene.subline ).toContain( 'this publication' );
4545+ } );
4646+4747+ it( 'keeps the horizon metaphor for the generic not-found heading', () => {
4848+ expect( errorScene( 'not-found' ).heading ).toBe(
4949+ 'This page set below the horizon'
5050+ );
5151+ } );
5252+5353+ it( 'never emits an em dash in any copy field (house rule)', () => {
5454+ for ( const kind of ALL_KINDS ) {
5555+ const scene = errorScene( kind, {
5656+ handle: 'x',
5757+ slug: 'y',
5858+ publicationName: 'Z',
5959+ } );
6060+ const text = `${ scene.eyebrow }${ scene.heading }${ scene.subline }`;
6161+ expect( text ).not.toContain( '—' ); // em dash
6262+ }
6363+ } );
6464+} );
+72
src/lib/reader/errors.ts
···11+/**
22+ * Source of truth for reader error-page copy + HTTP status (SP12).
33+ *
44+ * Pure and dependency-free (no @wordpress, no network) so it can run on the read
55+ * path (AGENTS.md #3) and be unit-tested directly. Copy avoids em dashes by house
66+ * rule; the eyebrow uses a middle-dot kicker in SkyPress's editorial voice.
77+ */
88+export type ErrorKind =
99+ | 'writer-not-found'
1010+ | 'publication-not-found'
1111+ | 'article-not-found'
1212+ | 'not-found'
1313+ | 'server-error';
1414+1515+export interface ErrorContext {
1616+ handle?: string;
1717+ slug?: string;
1818+ publicationName?: string;
1919+}
2020+2121+export interface ErrorSceneCopy {
2222+ status: number;
2323+ eyebrow: string;
2424+ heading: string;
2525+ subline: string;
2626+}
2727+2828+export function errorScene(
2929+ kind: ErrorKind,
3030+ context: ErrorContext = {}
3131+): ErrorSceneCopy {
3232+ switch ( kind ) {
3333+ case 'writer-not-found':
3434+ return {
3535+ status: 404,
3636+ eyebrow: '404 · no one by that name',
3737+ heading: `No writer at @${ context.handle ?? '' }`,
3838+ subline:
3939+ 'Nobody on the network goes by that handle yet. It might have a typo.',
4040+ };
4141+ case 'publication-not-found':
4242+ return {
4343+ status: 404,
4444+ eyebrow: '404 · publication not found',
4545+ heading: 'No publication by that name',
4646+ subline: `@${ context.handle ?? '' } hasn't published anything under "${ context.slug ?? '' }".`,
4747+ };
4848+ case 'article-not-found':
4949+ return {
5050+ status: 404,
5151+ eyebrow: '404 · story not found',
5252+ heading: 'This story set below the horizon',
5353+ subline: `It's no longer part of ${ context.publicationName ?? 'this publication' }. It may have been unpublished or moved.`,
5454+ };
5555+ case 'server-error':
5656+ return {
5757+ status: 500,
5858+ eyebrow: '500 · overcast',
5959+ heading: "The sky's a bit cloudy right now",
6060+ subline:
6161+ 'Something went wrong on our end. Give it a moment, then try again.',
6262+ };
6363+ case 'not-found':
6464+ default:
6565+ return {
6666+ status: 404,
6767+ eyebrow: '404 · off the map',
6868+ heading: 'This page set below the horizon',
6969+ subline: "There's nothing to read at this address.",
7070+ };
7171+ }
7272+}