1import type {} from "@atcute/atproto"; 2import { type Handle, is } from "@atcute/lexicons"; 3import { AtUri, UnicodeString } from "@atproto/api"; 4import katex from "katex"; 5import sanitizeHTML from "sanitize-html"; 6import { 7 PubLeafletBlocksCode, 8 PubLeafletBlocksHeader, 9 PubLeafletBlocksHorizontalRule, 10 PubLeafletBlocksImage, 11 PubLeafletBlocksMath, 12 PubLeafletBlocksText, 13 PubLeafletBlocksUnorderedList, 14 PubLeafletPagesLinearDocument, 15} from "./lexicons/index.js"; 16import type { 17 Did, 18 Facet, 19 GetLeafletDocumentsParams, 20 GetSingleLeafletDocumentParams, 21 LeafletDocumentRecord, 22 LeafletDocumentView, 23 MiniDoc, 24 RichTextSegment, 25} from "./types.js"; 26 27export class LiveLoaderError extends Error { 28 constructor( 29 message: string, 30 public code?: string, 31 ) { 32 super(message); 33 this.name = "LiveLoaderError"; 34 } 35} 36 37export function uriToRkey(uri: string): string { 38 const u = AtUri.make(uri); 39 if (!u.rkey) { 40 throw new Error("failed to get rkey"); 41 } 42 return u.rkey; 43} 44 45export async function resolveMiniDoc(handleOrDid: string) { 46 try { 47 const response = await fetch( 48 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${handleOrDid}`, 49 ); 50 51 if (!response.ok || response.status >= 300) { 52 throw new Error( 53 `could not resolve did doc due to invalid handle or did ${handleOrDid}`, 54 ); 55 } 56 const data = (await response.json()) as MiniDoc; 57 58 return { 59 pds: data.pds, 60 did: data.did, 61 }; 62 } catch { 63 throw new Error(`failed to resolve handle: ${handleOrDid}`); 64 } 65} 66 67export async function getLeafletDocuments({ 68 repo, 69 reverse, 70 cursor, 71 rpc, 72 limit, 73}: GetLeafletDocumentsParams) { 74 const { ok, data } = await rpc.get("com.atproto.repo.listRecords", { 75 params: { 76 collection: "pub.leaflet.document", 77 cursor, 78 reverse, 79 limit, 80 repo, 81 }, 82 }); 83 84 if (!ok) { 85 throw new LiveLoaderError( 86 "error fetching leaflet documents", 87 "DOCUMENT_FETCH_ERROR", 88 ); 89 } 90 91 return { 92 documents: data?.records, 93 cursor: data?.cursor, 94 }; 95} 96 97export async function getSingleLeafletDocument({ 98 rpc, 99 repo, 100 id, 101}: GetSingleLeafletDocumentParams) { 102 const { ok, data } = await rpc.get("com.atproto.repo.getRecord", { 103 params: { 104 collection: "pub.leaflet.document", 105 repo, 106 rkey: id, 107 }, 108 }); 109 110 if (!ok) { 111 throw new LiveLoaderError( 112 "error fetching single document", 113 "DOCUMENT_FETCH_ERROR", 114 ); 115 } 116 117 return data; 118} 119 120export function leafletDocumentRecordToView({ 121 uri, 122 cid, 123 value, 124}: { 125 uri: string; 126 cid: string; 127 value: LeafletDocumentRecord; 128}): LeafletDocumentView { 129 return { 130 rkey: uriToRkey(uri), 131 cid, 132 title: value.title, 133 description: value.description, 134 author: value.author, 135 publication: value.publication, 136 publishedAt: value.publishedAt, 137 }; 138} 139 140export function leafletBlocksToHTML({ 141 record, 142 did, 143}: { 144 record: LeafletDocumentRecord; 145 did: string; 146}) { 147 let html = ""; 148 const firstPage = record.pages[0]; 149 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 150 151 if (is(PubLeafletPagesLinearDocument.mainSchema, firstPage)) { 152 blocks = firstPage.blocks || []; 153 } 154 155 for (const block of blocks) { 156 html += parseBlocks({ block, did }); 157 } 158 159 return sanitizeHTML(html, { 160 allowedAttributes: { 161 "*": ["class", "style"], 162 img: ["src", "height", "width", "alt"], 163 a: ["href", "target", "rel"], 164 }, 165 allowedTags: [ 166 "img", 167 "pre", 168 "code", 169 "p", 170 "a", 171 "b", 172 "s", 173 "ul", 174 "li", 175 "i", 176 "h1", 177 "h2", 178 "h3", 179 "h4", 180 "h5", 181 "h6", 182 "hr", 183 "div", 184 "span", 185 ], 186 selfClosing: ["img"], 187 }); 188} 189 190export class RichText { 191 unicodeText: UnicodeString; 192 facets?: Facet[]; 193 constructor(props: { text: string; facets: Facet[] }) { 194 this.unicodeText = new UnicodeString(props.text); 195 this.facets = props.facets; 196 if (this.facets) { 197 this.facets = this.facets 198 .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 199 .sort((a, b) => a.index.byteStart - b.index.byteStart); 200 } 201 } 202 203 *segments(): Generator<RichTextSegment, void, void> { 204 const facets = this.facets || []; 205 if (!facets.length) { 206 yield { text: this.unicodeText.utf16 }; 207 return; 208 } 209 210 let textCursor = 0; 211 let facetCursor = 0; 212 do { 213 const currFacet = facets[facetCursor]; 214 if (currFacet) { 215 if (textCursor < currFacet.index.byteStart) { 216 yield { 217 text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 218 }; 219 } else if (textCursor > currFacet.index.byteStart) { 220 facetCursor++; 221 continue; 222 } 223 if (currFacet.index.byteStart < currFacet.index.byteEnd) { 224 const subtext = this.unicodeText.slice( 225 currFacet.index.byteStart, 226 currFacet.index.byteEnd, 227 ); 228 if (!subtext.trim()) { 229 // dont empty string entities 230 yield { text: subtext }; 231 } else { 232 yield { text: subtext, facet: currFacet.features }; 233 } 234 } 235 textCursor = currFacet.index.byteEnd; 236 facetCursor++; 237 } 238 } while (facetCursor < facets.length); 239 if (textCursor < this.unicodeText.length) { 240 yield { 241 text: this.unicodeText.slice(textCursor, this.unicodeText.length), 242 }; 243 } 244 } 245} 246 247function parseBlocks({ 248 block, 249 did, 250}: { 251 block: PubLeafletPagesLinearDocument.Block; 252 did: string; 253}): string { 254 let html = ""; 255 256 if (is(PubLeafletBlocksText.mainSchema, block.block)) { 257 const rt = new RichText({ 258 text: block.block.plaintext, 259 facets: block.block.facets || [], 260 }); 261 const children = []; 262 for (const segment of rt.segments()) { 263 const link = segment.facet?.find( 264 (segment) => segment.$type === "pub.leaflet.richtext.facet#link", 265 ); 266 const isBold = segment.facet?.find( 267 (segment) => segment.$type === "pub.leaflet.richtext.facet#bold", 268 ); 269 const isCode = segment.facet?.find( 270 (segment) => segment.$type === "pub.leaflet.richtext.facet#code", 271 ); 272 const isStrikethrough = segment.facet?.find( 273 (segment) => 274 segment.$type === "pub.leaflet.richtext.facet#strikethrough", 275 ); 276 const isUnderline = segment.facet?.find( 277 (segment) => segment.$type === "pub.leaflet.richtext.facet#underline", 278 ); 279 const isItalic = segment.facet?.find( 280 (segment) => segment.$type === "pub.leaflet.richtext.facet#italic", 281 ); 282 if (isCode) { 283 children.push(`<pre><code>${segment.text}</code></pre>`); 284 } else if (link) { 285 children.push( 286 `<a href="${link.uri}" target="_blank" rel="noopener noreferrer">${segment.text}</a>`, 287 ); 288 } else if (isBold) { 289 children.push(`<b>${segment.text}</b>`); 290 } else if (isStrikethrough) { 291 children.push(`<s>${segment.text}</s>`); 292 } else if (isUnderline) { 293 children.push( 294 `<span style="text-decoration:underline;">${segment.text}</span>`, 295 ); 296 } else if (isItalic) { 297 children.push(`<i>${segment.text}</i>`); 298 } else { 299 children.push(`${segment.text}`); 300 } 301 } 302 html += `<p>${children.join("")}</p>`; 303 } 304 305 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 306 if (block.block.level === 1) { 307 html += `<h2>${block.block.plaintext}</h2>`; 308 } 309 } 310 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 311 if (block.block.level === 2) { 312 html += `<h3>${block.block.plaintext}</h3>`; 313 } 314 } 315 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 316 if (block.block.level === 3) { 317 html += `<h4>${block.block.plaintext}</h4>`; 318 } 319 } 320 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 321 if (!block.block.level) { 322 html += `<h6>${block.block.plaintext}</h6>`; 323 } 324 } 325 326 if (is(PubLeafletBlocksHorizontalRule.mainSchema, block.block)) { 327 html += `<hr />`; 328 } 329 if (is(PubLeafletBlocksUnorderedList.mainSchema, block.block)) { 330 html += `<ul>${block.block.children.map((child) => renderListItem({ item: child, did })).join("")}</ul>`; 331 } 332 333 if (is(PubLeafletBlocksMath.mainSchema, block.block)) { 334 html += `<div>${katex.renderToString(block.block.tex, { displayMode: true, output: "html", throwOnError: false })}</div>`; 335 } 336 337 if (is(PubLeafletBlocksCode.mainSchema, block.block)) { 338 html += `<pre><code data-language=${block.block.language}>${block.block.plaintext}</code></pre>`; 339 } 340 341 if (is(PubLeafletBlocksImage.mainSchema, block.block)) { 342 // @ts-ignore 343 html += `<div><img src="https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${block.block.image.ref.$link}@jpeg" height="${block.block.aspectRatio.height}" width="${block.block.aspectRatio.width}" alt="${block.block.alt}" /></div>`; 344 } 345 346 return html.trim(); 347} 348 349function renderListItem({ 350 item, 351 did, 352}: { 353 item: PubLeafletBlocksUnorderedList.ListItem; 354 did: string; 355}): string { 356 const children: string | null = item.children?.length 357 ? `<ul>${item.children.map((child) => renderListItem({ item: child, did }))}</ul>` 358 : ""; 359 360 return `<li>${parseBlocks({ block: { block: item.content }, did })}${children}</li>`; 361} 362 363// yoinked from: https://github.com/mary-ext/atcute/blob/trunk/packages/lexicons/lexicons/lib/syntax/handle.ts 364const PLC_DID_RE = /^did:plc:([a-z2-7]{24})$/; 365 366export const isPlcDid = (input: string): input is Did<"plc"> => { 367 return input.length === 32 && PLC_DID_RE.test(input); 368};