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