Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
13 kB · 306 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307// 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<F> = '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<string, Confirmed> = { 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<string, string> = { 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<string, string> = { 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}