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