diff --git a/apps/web/src/lib/components/theme/SiteArticle.svelte b/apps/web/src/lib/components/theme/SiteArticle.svelte new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/components/theme/SiteArticle.svelte @@ -0,0 +1,152 @@ + + +
+
+

{page.title}

+ {#if page.publishedAt || page.updatedAt} + + {/if} +
+ + +
{@html page.html}
+
+ + diff --git a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts --- a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts +++ b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { listAllRecords, blobUrl, type AtmosphereContext } from './xrpc'; -import { pdsEndpoint } from './identity'; +import { handleFromDidDocument, pdsEndpoint } from './identity'; import { fetchBlueskyPosts, fetchBlueskyProfile } from './bluesky'; import { fetchDocuments } from './standard-site'; import { fetchSifaProfile, fetchSifaSkills } from './sifa'; @@ -80,6 +80,16 @@ ] }; expect(pdsEndpoint(doc)).toBe('https://pds.example'); expect(pdsEndpoint({ id: 'did:plc:x' })).toBeUndefined(); + }); + + it('extracts the handle from alsoKnownAs', () => { + const doc = { + id: 'did:plc:test123', + alsoKnownAs: ['https://example.com', 'at://jacob.example.com'] + }; + expect(handleFromDidDocument(doc)).toBe('jacob.example.com'); + expect(handleFromDidDocument({ id: 'did:plc:x' })).toBeUndefined(); + expect(handleFromDidDocument({ alsoKnownAs: ['https://only-web.example'] })).toBeUndefined(); }); }); diff --git a/apps/web/src/lib/server/atmosphere/identity.ts b/apps/web/src/lib/server/atmosphere/identity.ts --- a/apps/web/src/lib/server/atmosphere/identity.ts +++ b/apps/web/src/lib/server/atmosphere/identity.ts @@ -55,6 +55,18 @@ if (!res.ok) throw new Error(`DID resolution failed (HTTP ${res.status}) for ${did}`); return res.json() as Promise>; } +/** The handle a DID document declares in alsoKnownAs; undefined when absent. */ +export function handleFromDidDocument(didDocument: Record): string | undefined { + const aka = didDocument.alsoKnownAs; + if (!Array.isArray(aka)) return undefined; + for (const entry of aka) { + if (typeof entry === 'string' && entry.startsWith('at://')) { + return entry.slice('at://'.length); + } + } + return undefined; +} + /** Extract the PDS base URL from a DID document; undefined when absent. */ export function pdsEndpoint(didDocument: Record): string | undefined { const services = didDocument.service; diff --git a/apps/web/src/lib/server/render/page.ts b/apps/web/src/lib/server/render/page.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/server/render/page.ts @@ -0,0 +1,47 @@ +/** + * A single authored page as the theme renders it, looked up by path. Shared by + * every route that serves a page, whatever supplied the site's identity. + */ + +import type { AtmosphereContext } from '../atmosphere/xrpc'; +import { markdownText, normalizePath, readPages } from '../mooring'; +import { markdownExcerpt, renderMarkdown } from './markdown'; +import type { PageView } from '../../site-view'; + +export type { PageView }; + +/** + * Undefined when the path is malformed, no published page lives there, or the + * page is a draft — all indistinguishable 404s to a visitor, since the record + * being public on the PDS doesn't make it published on the site. + */ +export async function loadPageView( + ctx: AtmosphereContext, + rawPath: string +): Promise { + let wanted: string; + try { + wanted = normalizePath(rawPath); + } catch { + return undefined; + } + + const records = await readPages(ctx); + const match = records.find((record) => record.value.path === wanted); + if (!match || match.value.visibility === 'draft') return undefined; + + const body = markdownText(match.value); + return { + title: typeof match.value.title === 'string' ? match.value.title : wanted, + description: + typeof match.value.description === 'string' + ? match.value.description + : body + ? markdownExcerpt(body) + : undefined, + publishedAt: typeof match.value.publishedAt === 'string' ? match.value.publishedAt : undefined, + updatedAt: typeof match.value.updatedAt === 'string' ? match.value.updatedAt : undefined, + listed: match.value.visibility !== 'unlisted', + html: body ? renderMarkdown(body) : '' + }; +} diff --git a/apps/web/src/lib/server/render/render.test.ts b/apps/web/src/lib/server/render/render.test.ts --- a/apps/web/src/lib/server/render/render.test.ts +++ b/apps/web/src/lib/server/render/render.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { AtmosphereContext } from '../atmosphere/xrpc'; import { markdownExcerpt, renderMarkdown } from './markdown'; import { resolveSections } from './sections'; +import { resolveSite } from './resolve'; import { loadPages, themeColors } from './site'; const DID = 'did:plc:test123'; @@ -22,6 +23,55 @@ return new Response('not found', { status: 404 }); }) as typeof fetch; return { pds: 'https://pds.example', did: DID, fetch: fakeFetch }; } + +describe('resolveSite', () => { + function identityFetch(didDocument: Record): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.hostname === 'plc.directory') return Response.json(didDocument); + return new Response('not found', { status: 404 }); + }) as typeof fetch; + } + + it('takes a DID and recovers the handle from the DID document', async () => { + const resolved = await resolveSite(DID, { + fetch: identityFetch({ + id: DID, + alsoKnownAs: [`at://jacob.example.com`], + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://pds.example' + } + ] + }) + }); + expect(resolved?.did).toBe(DID); + expect(resolved?.handle).toBe('jacob.example.com'); + expect(resolved?.ctx.pds).toBe('https://pds.example'); + }); + + it('falls back to the DID when the document declares no handle', async () => { + const resolved = await resolveSite(DID, { + fetch: identityFetch({ + id: DID, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://pds.example' + } + ] + }) + }); + expect(resolved?.handle).toBe(DID); + }); + + it('is undefined when the DID document lists no PDS', async () => { + expect(await resolveSite(DID, { fetch: identityFetch({ id: DID }) })).toBeUndefined(); + }); +}); describe('renderMarkdown', () => { it('renders CommonMark', () => { diff --git a/apps/web/src/lib/server/render/resolve.ts b/apps/web/src/lib/server/render/resolve.ts --- a/apps/web/src/lib/server/render/resolve.ts +++ b/apps/web/src/lib/server/render/resolve.ts @@ -1,10 +1,15 @@ /** - * Handle → the context a rendered site needs. This is the seam the hosting - * layer swaps: a subdomain or verified custom domain supplies the handle in - * place of a path segment, and everything downstream is unchanged. + * Handle or DID → the context a rendered site needs. This is the seam the + * hosting layer swaps: a subdomain or verified custom domain supplies the DID + * in place of a path segment, and everything downstream is unchanged. */ -import { pdsEndpoint, resolveDidDocument, resolveHandle } from '../atmosphere/identity'; +import { + handleFromDidDocument, + pdsEndpoint, + resolveDidDocument, + resolveHandle +} from '../atmosphere/identity'; import { listCollections, type AtmosphereContext } from '../atmosphere/xrpc'; import { detectSources, type SourceId } from '../atmosphere/sources'; @@ -15,21 +20,26 @@ ctx: AtmosphereContext; sources: SourceId[]; } -/** Undefined when the handle doesn't resolve to a repo we can read. */ +/** Undefined when the identifier doesn't resolve to a repo we can read. */ export async function resolveSite( - handle: string, + handleOrDid: string, options?: { fetch?: typeof fetch } ): Promise { let did: string; try { - did = handle.startsWith('did:') ? handle : await resolveHandle(handle, options); + did = handleOrDid.startsWith('did:') ? handleOrDid : await resolveHandle(handleOrDid, options); } catch { return undefined; } let pds: string | undefined; + let handle: string; try { - pds = pdsEndpoint(await resolveDidDocument(did, options)); + const didDocument = await resolveDidDocument(did, options); + pds = pdsEndpoint(didDocument); + handle = handleOrDid.startsWith('did:') + ? (handleFromDidDocument(didDocument) ?? did) + : handleOrDid; } catch { return undefined; } diff --git a/apps/web/src/lib/site-view.ts b/apps/web/src/lib/site-view.ts --- a/apps/web/src/lib/site-view.ts +++ b/apps/web/src/lib/site-view.ts @@ -80,6 +80,16 @@ skills: SkillView[]; } | { kind: 'pages'; title?: string; pages: PageLink[] }; +export interface PageView { + title: string; + description?: string; + publishedAt?: string; + updatedAt?: string; + /** Unlisted pages are served but carry noindex. */ + listed: boolean; + html: string; +} + export interface SiteView { did: string; handle: string; diff --git a/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts b/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts --- a/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts +++ b/apps/web/src/routes/s/[handle]/[...path]/+page.server.ts @@ -1,47 +1,16 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { normalizePath, readPages } from '$lib/server/mooring'; -import { markdownText } from '$lib/server/mooring'; import { resolveSite } from '$lib/server/render/resolve'; import { loadSiteView } from '$lib/server/render/site'; -import { renderMarkdown, markdownExcerpt } from '$lib/server/render/markdown'; +import { loadPageView } from '$lib/server/render/page'; export const load: PageServerLoad = async ({ params }) => { const resolved = await resolveSite(params.handle); if (!resolved) error(404, `No atproto account resolves for ${params.handle}.`); - let wanted: string; - try { - wanted = normalizePath(params.path); - } catch { - error(404, 'No such page.'); - } - - const records = await readPages(resolved.ctx); - const match = records.find((record) => record.value.path === wanted); - // Drafts are not served, which is a 404 rather than a 403: the record is - // public on the PDS, but this site doesn't publish it. - if (!match || match.value.visibility === 'draft') error(404, 'No such page.'); + const page = await loadPageView(resolved.ctx, params.path); + if (!page) error(404, 'No such page.'); - const body = markdownText(match.value); const site = await loadSiteView(resolved.ctx, resolved.handle, resolved.sources); - - return { - site, - base: `/s/${params.handle}`, - page: { - title: typeof match.value.title === 'string' ? match.value.title : wanted, - description: - typeof match.value.description === 'string' - ? match.value.description - : body - ? markdownExcerpt(body) - : undefined, - publishedAt: - typeof match.value.publishedAt === 'string' ? match.value.publishedAt : undefined, - updatedAt: typeof match.value.updatedAt === 'string' ? match.value.updatedAt : undefined, - listed: match.value.visibility !== 'unlisted', - html: body ? renderMarkdown(body) : '' - } - }; + return { site, base: `/s/${params.handle}`, page }; }; diff --git a/apps/web/src/routes/s/[handle]/[...path]/+page.svelte b/apps/web/src/routes/s/[handle]/[...path]/+page.svelte --- a/apps/web/src/routes/s/[handle]/[...path]/+page.svelte +++ b/apps/web/src/routes/s/[handle]/[...path]/+page.svelte @@ -1,4 +1,5 @@