// Readers that can display standard.site publications and documents. // // The list of readers comes from Aturi's Waypoints catalog // (`@aturi.to/waypoints`), a maintained inventory of Atmosphere clients with // a URL builder for each. Taking the list from there means a reader that // joins the Atmosphere arrives on a dependency bump instead of on somebody // here noticing it. // // What the catalog cannot supply, this file still does: // // - Icons. The catalog ships none (its React companion does, but that is a // React app's worth of dependency for four favicons), so ours stay // vendored in public/vendored/. // - Proof. The catalog's builders are one URL shape per client, and a // `site.standard.publication` is not a `site.standard.document`: asking // it for a publication gets Standard Reader's article route, which // renders an empty "Article" page, and Leaflet's profile route, which // renders the account. So each waypoint is listed in CONFIRMED below // with what was actually checked against the live service, and anything // not in that table is not offered. `catalogGaps()` reports catalogued // standard.site waypoints we have not classified, and a test fails on // them, so a bump that adds a reader asks a human to go and look rather // than shipping a link nobody has opened. // // Confirmed against live services (2026-08-12, records rendered in a real // browser — these are client-rendered apps, so an HTTP 200 proves nothing): // // - standard-reader.app renders publications at /p/{did}/{pubRkey} and // documents at /a/{did}/{docRkey}. The catalog's builder is right for // documents and wrong for publications (it sends them to /a/, which // renders a blank article; its /u/{did} fallback is the account page, // not the publication), so the publication URL is ours. // It renders a document in-app only when the record carries structured // `content`; with only plain `textContent` it 307-redirects to the // document's canonical URL — publication url + document path, per its // buildCanonicalUrl (src/server/ingest/mappers.ts in // github.com/hipstersmoothie/standard-reader). From that very page the // article link would be a self-link, so it falls back to the publication. // - leaflet.pub renders external standard.site publications at // /lish/{did}/{pubRkey} and their articles one level down, at // /lish/{did}/{pubRkey}/{docRkey} (confirmed 2026-08-17 by rendering // permadeath.com's "How I got a rare GitHub t-shirt"). The catalog builds // /p/{handle} — the account's Leaflet profile — for every record type, so // both URLs are ours. // - pdsls.dev renders any record at /{at-uri}, documents included. Ours and // the catalog's builder produce the same string. // - atproto.at (Taproot) renders any record at /uri/{at-uri}, publications // and documents alike (confirmed 2026-08-17 against permadeath.com's // records). The catalog's builder is right for both. Like pdsls.dev it is // a record viewer rather than a reading experience — the page is titled // "site.standard.publication by @handle" — which is why both sit last. // // Catalogued and deliberately not offered: // // - anisota.net renders external documents at // /profile/{did}/document/{docRkey}, but the same route with a // publication rkey returns an empty page, so it has no publication view. // Every reader offered here now works on both a publication and an // article, so no entry is ever greyed out for the page you are on. // - offprint.app and pckt.blog (`publications` category) 404 on the URLs // the catalog builds for them, under DID and handle form alike, for // publications and documents. Left out until they resolve; reporting // upstream is worth doing. // - aturi.to's own two entries could not be checked: the domain does not // resolve from here, so nothing was rendered and nothing is claimed. // - Docs.surf is not in the catalog. Its client bundle has no router, so // every path renders the same global feed. // // standard.site itself hosts no reader. import { WAYPOINT_DESTINATIONS_DATA, WAYPOINT_ORDER } from '@aturi.to/waypoints' import { parseAtUri } from './atproto' import type { DocInfo, PubInfo } from './types' export interface Reader { id: string name: string /** Extension-root path to the reader's vendored favicon (public/vendored/). */ icon: string pubUrl: (pub: PubInfo) => string | null /** * Null when the reader has no useful article view for this document — * including when its article URL would only redirect back to `pageUrl`, * the page the popup is open on. Callers fall back to `pubUrl`. */ docUrl: (pub: PubInfo, doc: DocInfo, pageUrl?: string) => string | null /** * The catalog's one-line description of what this reader does with a record * of `collection` — "View raw record on pdsls.dev", "Read document on * anisota.net". It is the only place the reader's own framing survives the * menu, which has room for a name and nothing else. */ describe: (collection?: string) => string } /** * What each catalogued waypoint was confirmed to do, and where the catalog's * builder is not the right answer. * * A view is `'catalog'` (its builder was checked against the live service and * is correct), `'none'` (the reader has no such view), or a builder of our * own for the case the catalog gets wrong. */ type Source = 'catalog' | 'none' | F interface Confirmed { icon: string pub: Source<(pub: PubInfo) => string | null> doc: Source<(pub: PubInfo, doc: DocInfo) => string | null> /** True when the reader's article URL would only bounce back to `pageUrl`. */ docPointless?: (pub: PubInfo, doc: DocInfo, pageUrl?: string) => boolean } const CONFIRMED: Record = { standardReader: { icon: '/vendored/standard.svg', pub: (pub) => { const rkey = parseAtUri(pub.uri)?.rkey return rkey ? `https://standard-reader.app/p/${pub.did}/${rkey}` : null }, doc: 'catalog', docPointless: docLinksBackTo, }, leaflet: { icon: '/vendored/leaflet.png', pub: (pub) => { const rkey = parseAtUri(pub.uri)?.rkey return rkey ? `https://leaflet.pub/lish/${pub.did}/${rkey}` : null }, // The article sits under its publication, so this needs both rkeys. The // catalog builds /p/{handle} — the account's Leaflet profile — for every // record type, which is why this reader was long recorded as having no // article view at all. doc: (pub, doc) => { const pubRkey = parseAtUri(pub.uri)?.rkey const docRkey = parseAtUri(doc.uri)?.rkey return pubRkey && docRkey ? `https://leaflet.pub/lish/${pub.did}/${pubRkey}/${docRkey}` : null }, }, pdsls: { icon: '/vendored/pdsls.png', pub: 'catalog', doc: 'catalog', }, taproot: { icon: '/vendored/taproot.png', pub: 'catalog', doc: 'catalog', }, } /** * Catalogued standard.site waypoints that were looked at and are not offered, * with why. In code rather than only in the header comment so `catalogGaps()` * can tell "checked and declined" from "nobody has looked yet". */ const REJECTED: Record = { offprint: '404s on the catalog URL, DID and handle form alike (2026-08-12)', pckt: '404s on the catalog URL, DID and handle form alike (2026-08-12)', aturi: 'aturi.to does not resolve from here, so nothing could be rendered or claimed', anisota: 'builds the same anisota.net/profile/{did}/document/{rkey} URL as anisotaReader for standard.site records; one entry per destination', anisotaReader: 'renders documents but has no publication view (the same route with a publication rkey returns an empty 732-byte page, 2026-08-17), and a reader that works on only half the pages it is offered on reads as broken', } /** Reader used when the user has not stored an explicit choice. */ export const DEFAULT_READER_ID = 'standardReader' /** * Reader ids used before the catalog supplied them. Only Standard Reader's * changed; a stored choice from an older install has to keep meaning the * reader the user picked. */ const LEGACY_IDS: Record = { standard: 'standardReader' } export function migrateReaderId(id: string): string { return LEGACY_IDS[id] ?? id } /** The catalog's builder for a record, or null when it declines to build one. */ function catalogUrl(waypointId: string, did: string, handle: string | undefined, uri: string) { const parsed = parseAtUri(uri) if (!parsed) return null return ( WAYPOINT_DESTINATIONS_DATA[waypointId]?.getUrl( handle ?? did, parsed.collection, parsed.rkey, did, ) ?? null ) } function readerFor(id: string, confirmed: Confirmed): Reader | null { const waypoint = WAYPOINT_DESTINATIONS_DATA[id] // A bump that renames or drops a waypoint takes the reader with it rather // than leaving a button that builds nothing. if (!waypoint) { console.debug(`[substandard] waypoint ${id} is no longer in the catalog`) return null } return { id, name: waypoint.name, icon: confirmed.icon, describe: (collection) => { const { description } = waypoint return typeof description === 'function' ? description(collection) : description }, pubUrl: (pub) => { if (confirmed.pub === 'none') return null if (confirmed.pub === 'catalog') return catalogUrl(id, pub.did, pub.handle, pub.uri) return confirmed.pub(pub) }, docUrl: (pub, doc, pageUrl) => { if (confirmed.doc === 'none') return null if (confirmed.docPointless?.(pub, doc, pageUrl)) return null if (confirmed.doc !== 'catalog') return confirmed.doc(pub, doc) const parsed = parseAtUri(doc.uri) return parsed ? catalogUrl(id, parsed.did, pub.handle, doc.uri) : null }, } } /** * The readers offered, in the catalog's own order so the list matches what a * user sees in other Atmosphere apps. */ export const READERS: Reader[] = WAYPOINT_ORDER.flatMap((id) => { const confirmed = CONFIRMED[id] return confirmed ? (readerFor(id, confirmed) ?? []) : [] }) /** * Catalogued waypoints that claim to render standard.site records and appear * in neither CONFIRMED nor REJECTED. Empty in a release; non-empty after a * dependency bump that adds a reader, which is a prompt to go and open the * link, not a bug. */ export function catalogGaps(): string[] { return WAYPOINT_ORDER.filter( (id) => !CONFIRMED[id] && !REJECTED[id] && (WAYPOINT_DESTINATIONS_DATA[id]?.redirectCompat as readonly string[] | undefined)?.includes( 'standard-site', ), ) } /** * The publication's own site, from its record. The record is third-party * data, so only http(s) URLs come back as linkable; anything else is null. */ export function pubSiteUrl(pub: PubInfo): string | null { try { const url = new URL(pub.record.url) return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null } catch { return null } } /** * True when Standard Reader's article URL for this document would only * 307-redirect back to `pageUrl`: the record has no structured `content` * for the reader to render, and its canonical URL (publication url + * document path, mirroring the reader's buildCanonicalUrl) is the page * itself. */ export function docLinksBackTo(pub: PubInfo, doc: DocInfo, pageUrl?: string): boolean { if (!pageUrl) return false if (doc.record.content !== undefined || !doc.record.path) return false const base = pubSiteUrl(pub) if (!base) return false const path = doc.record.path.startsWith('/') ? doc.record.path : `/${doc.record.path}` return samePage(base.replace(/\/+$/, '') + path, pageUrl) } /** Same origin and path, ignoring trailing slashes, query, and fragment. */ function samePage(a: string, b: string): boolean { try { const ua = new URL(a) const ub = new URL(b) const trim = (p: string) => p.replace(/\/+$/, '') || '/' return ua.origin === ub.origin && trim(ua.pathname) === trim(ub.pathname) } catch { return false } } export function pubUrl(readerId: string, pub: PubInfo): string | null { return READERS.find((r) => r.id === readerId)?.pubUrl(pub) ?? null } export function docUrl( readerId: string, pub: PubInfo, doc: DocInfo, pageUrl?: string, ): string | null { return READERS.find((r) => r.id === readerId)?.docUrl(pub, doc, pageUrl) ?? null }