Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168// Images, and the one thing a body says about one.//// A picture in a Radial body is not a URL to somebody's PDS. It is a `com.disnetdev.radial.image`// record — the blob plus its metadata — in the author's own repo, and the body refers to it with a// **locator**: `radial-image:<at-uri of that record>`. Two reasons, both about what survives://// - **The blob outlives the paste.** atproto reclaims a blob no record references, so an upload// with nothing pointing at it is an image that quietly stops existing. The record is what// anchors it, and the record is what the body names.// - **The address is not the host.** A `…/xrpc/com.atproto.sync.getBlob?did=…` URL pins whichever// PDS the author was on the day they pasted it. A record URI names the author, and the author's// current PDS is resolved at READ time — so moving a PDS does not rewrite anybody's prose.//// Resolution is deliberately one-way and closed: a reader takes the record URI out of the locator,// reads THAT record, validates it against the lexicon, resolves its author's DID to a PDS, and// fetches the blob CID the record itself carries. A DID/CID pair lifted straight out of markdown is// never fetched — that would be an arbitrary-fetch primitive with a Radial scheme painted on it.//// This module is the pure half of that: format, parse, and the blob URL. It is isomorphic and does// no I/O, because both fold implementations (the tab and the daemon) have to agree about what a// locator means, and only one of them has a network stack worth speaking of.
import { COLLECTIONS, lexiconSchemas } from './generated/records.js'
/** The scheme a Radial image locator is written in. Not a URL scheme anyone can navigate to. */export const RADIAL_IMAGE_SCHEME = 'radial-image:'
type BlobSchema = { accept?: readonly string[]; maxSize?: number }
const imageBlobSchema = (): BlobSchema => { const lexicon = (lexiconSchemas as readonly { id: string; defs: Record<string, unknown> }[]).find( (schema) => schema.id === COLLECTIONS.image, ) const record = (lexicon?.defs.main as { record?: { properties?: Record<string, BlobSchema> } })?.record return record?.properties?.blob ?? {}}
/** * What an image record may carry, read off the lexicon rather than restated beside it. * * A writer that checked its own copy of these numbers would eventually check a different set from * the one the record is validated against, and the failure would arrive at somebody's PDS. The * upload form, the writer and the validator all read this. * * The allowlist is four raster formats and no SVG. An SVG displayed through `<img>` is inert in most * respects but is still a document — it can carry script, reference external resources, and grow * arbitrarily on decode — and none of that is worth having in a paste target. */export const IMAGE_LIMITS = { mimeTypes: (imageBlobSchema().accept ?? []) as readonly string[], maxBytes: imageBlobSchema().maxSize ?? 0,} as const
/** Whether a MIME type is one an image record may carry. Compared case-insensitively, without params. */export const isImageMimeType = (mimeType: string): boolean => IMAGE_LIMITS.mimeTypes.includes(mimeType.split(';')[0]?.trim().toLowerCase() ?? '')
/** * An image record's URI, strictly. The shape is pinned rather than merely parsed: the authority is a * DID, the collection is the image collection and nothing else, and the record key is one segment of * key-safe characters. No query, no fragment, no userinfo, no second path segment — every one of * those is a way to make a resolver fetch something other than the record the URI appears to name. */const IMAGE_URI = /^at:\/\/(did:[a-z0-9]+:[A-Za-z0-9._:%-]{1,256})\/com\.disnetdev\.radial\.image\/([A-Za-z0-9.~_-]{1,512})$/
export interface ImageLocator { /** The `at://` URI of the image record. */ uri: string /** Its author — whose PDS the blob is fetched from, after resolving the DID. */ did: string rkey: string}
/** Whether a string is an image record URI this module will resolve. */export const isImageUri = (uri: string): boolean => IMAGE_URI.test(uri)
/** * The locator a body carries for one image record, or `undefined` for a URI this would not resolve. * * Percent-encoding is applied to the handful of characters a markdown link destination cannot hold * literally. An image record URI contains none of them, so in practice the locator is the URI with a * scheme on the front and stays readable to a person — and to an agent reading the brief it was * pasted into, which is the whole reason it is not an opaque blob of `%3A`. */export function imageLocator(uri: string): string | undefined { if (!IMAGE_URI.test(uri)) return undefined return `${RADIAL_IMAGE_SCHEME}${uri.replace(/[()<>\s"'\\]/g, (char) => encodeURIComponent(char))}`}
/** * The image record a locator names, or null for anything else — including an ordinary URL, another * scheme wearing this one's prefix, a URI naming some other collection, and a URI carrying the query * string or credentials a resolver would otherwise have to decide what to do with. */export function parseImageLocator(href: string): ImageLocator | null { const text = href.trim() if (!text.toLowerCase().startsWith(RADIAL_IMAGE_SCHEME)) return null const rest = text.slice(RADIAL_IMAGE_SCHEME.length) let uri: string try { uri = decodeURIComponent(rest) } catch { // A malformed escape: not a locator, and emphatically not something to guess about. return null } const match = IMAGE_URI.exec(uri) if (!match) return null return { uri, did: match[1] as string, rkey: match[2] as string }}
/** * Every distinct image a run of prose refers to, in the order it first mentions them. * * This exists for the readers that are not a browser. A turn's bundle carries request briefs, goal * bodies and messages verbatim, so a locator somebody pasted into one arrives in front of an agent * as `` — text it can read but has no reason to recognise. Listing them * once, with what each is called, is what turns that into something an agent can go and fetch; * `bundle-writer.ts` does the listing. * * Scanning is deliberately over the whole string rather than over a token tree: this runs where * there is no markdown parser, the locator scheme is unambiguous, and a locator inside a fenced * block is still a picture somebody meant to show. */export function imageLocatorsIn(text: string): ImageLocator[] { const found: ImageLocator[] = [] const seen = new Set<string>() for (const match of text.matchAll(/radial-image:[^\s)\]<>"']+/gi)) { const locator = parseImageLocator(match[0]) if (!locator || seen.has(locator.uri)) continue seen.add(locator.uri) found.push(locator) } return found}
export interface ImageMarkdownSpan { from: number to: number}
/** * The spans of complete Markdown images whose destinations are valid Radial image locators. * * Editors use this to keep a later insertion out of an image whose alt text they selected for * correction. Keeping the recognition here means that presentation code cannot silently drift * from the locator syntax the protocol parser accepts. */export function imageMarkdownSpansIn(text: string): ImageMarkdownSpan[] { const spans: ImageMarkdownSpan[] = [] for (const match of text.matchAll(/!\[[^\r\n]*?\]\((radial-image:[^\s)]+)\)/gi)) { if (!parseImageLocator(match[1] as string)) continue spans.push({ from: match.index, to: match.index + match[0].length }) } return spans}
/** * Where a blob is actually fetched from: `com.atproto.sync.getBlob` on the PDS the author's DID * currently resolves to. Both parameters are query-encoded — a DID is not URL-safe by construction * (`did:web:example.com%3A8443`), and a CID pasted into a URL unescaped is the sort of thing that * works until one is not. */export function imageBlobUrl(pds: string | URL, did: string, cid: string): string { const query = new URLSearchParams({ did, cid }) return new URL(`/xrpc/com.atproto.sync.getBlob?${query}`, pds).href}