1import { is } from "@atcute/lexicons"; 2import { AtUri, UnicodeString } from "@atproto/api"; 3import sanitizeHTML from "sanitize-html"; 4import { 5 PubLeafletBlocksHeader, 6 PubLeafletBlocksText, 7 PubLeafletPagesLinearDocument, 8} from "./lexicons/index.js"; 9import type { 10 Facet, 11 GetLeafletDocumentsParams, 12 GetSingleLeafletDocumentParams, 13 LeafletDocumentRecord, 14 LeafletDocumentView, 15 MiniDoc, 16 RichTextSegment, 17} from "./types.js"; 18 19export class LiveLoaderError extends Error { 20 constructor( 21 message: string, 22 public code?: string, 23 ) { 24 super(message); 25 this.name = "LiveLoaderError"; 26 } 27} 28 29export function uriToRkey(uri: string): string { 30 const u = AtUri.make(uri); 31 if (!u.rkey) { 32 throw new Error("Failed to get rkey from uri."); 33 } 34 return u.rkey; 35} 36 37export async function resolveMiniDoc(handleOrDid: string) { 38 try { 39 const response = await fetch( 40 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${handleOrDid}`, 41 ); 42 43 if (!response.ok || response.status >= 300) { 44 throw new Error( 45 `could not resolve did doc due to invalid handle or did ${handleOrDid}`, 46 ); 47 } 48 const data = (await response.json()) as MiniDoc; 49 50 return data.pds; 51 } catch { 52 throw new Error(`failed to resolve handle: ${handleOrDid}`); 53 } 54} 55 56export async function getLeafletDocuments({ 57 repo, 58 reverse, 59 cursor, 60 agent, 61 limit, 62}: GetLeafletDocumentsParams) { 63 const response = await agent.com.atproto.repo.listRecords({ 64 repo, 65 collection: "pub.leaflet.document", 66 cursor, 67 reverse, 68 limit, 69 }); 70 71 if (response.success === false) { 72 throw new LiveLoaderError( 73 "error fetching leaflet documents", 74 "DOCUMENT_FETCH_ERROR", 75 ); 76 } 77 78 return { 79 documents: response?.data?.records, 80 cursor: response?.data?.cursor, 81 }; 82} 83 84export async function getSingleLeafletDocument({ 85 agent, 86 repo, 87 id, 88}: GetSingleLeafletDocumentParams) { 89 const response = await agent.com.atproto.repo.getRecord({ 90 repo, 91 collection: "pub.leaflet.document", 92 rkey: id, 93 }); 94 95 if (response.success === false) { 96 throw new LiveLoaderError( 97 "error fetching single document", 98 "DOCUMENT_FETCH_ERROR", 99 ); 100 } 101 102 return response?.data; 103} 104 105export function leafletDocumentRecordToView({ 106 uri, 107 cid, 108 value, 109}: { 110 uri: string; 111 cid: string; 112 value: LeafletDocumentRecord; 113}): LeafletDocumentView { 114 return { 115 rkey: uriToRkey(uri), 116 cid, 117 title: value.title, 118 description: value.description, 119 author: value.author, 120 publication: value.publication, 121 publishedAt: value.publishedAt, 122 }; 123} 124 125export function leafletBlocksToHTML(record: { 126 id: string; 127 uri: string; 128 cid: string; 129 value: LeafletDocumentRecord; 130}) { 131 let html = ""; 132 const firstPage = record.value.pages[0]; 133 let blocks: PubLeafletPagesLinearDocument.Block[] = []; 134 if (is(PubLeafletPagesLinearDocument.mainSchema, firstPage)) { 135 blocks = firstPage.blocks || []; 136 } 137 138 for (const block of blocks) { 139 if (is(PubLeafletBlocksText.mainSchema, block.block)) { 140 const rt = new RichText({ 141 text: block.block.plaintext, 142 facets: block.block.facets || [], 143 }); 144 const children = []; 145 for (const segment of rt.segments()) { 146 const link = segment.facet?.find( 147 (segment) => segment.$type === "pub.leaflet.richtext.facet#link", 148 ); 149 const isBold = segment.facet?.find( 150 (segment) => segment.$type === "pub.leaflet.richtext.facet#bold", 151 ); 152 const isCode = segment.facet?.find( 153 (segment) => segment.$type === "pub.leaflet.richtext.facet#code", 154 ); 155 const isStrikethrough = segment.facet?.find( 156 (segment) => 157 segment.$type === "pub.leaflet.richtext.facet#strikethrough", 158 ); 159 const isUnderline = segment.facet?.find( 160 (segment) => segment.$type === "pub.leaflet.richtext.facet#underline", 161 ); 162 const isItalic = segment.facet?.find( 163 (segment) => segment.$type === "pub.leaflet.richtext.facet#italic", 164 ); 165 if (isCode) { 166 children.push(` <code> 167 ${segment.text} 168 </code>`); 169 } else if (link) { 170 children.push( 171 ` <a 172 href="${link.uri}" 173 target="_blank" 174 > 175 ${segment.text} 176 </a>`, 177 ); 178 } else if (isBold) { 179 children.push(`<b>${segment.text}</b>`); 180 } else if (isStrikethrough) { 181 children.push(`<s>${segment.text}</s>`); 182 } else if (isUnderline) { 183 children.push( 184 `<span style="text-decoration:underline;">${segment.text}</span>`, 185 ); 186 } else if (isItalic) { 187 children.push(`<i>${segment.text}</i>`); 188 } else { 189 children.push( 190 ` 191 ${segment.text} 192 `, 193 ); 194 } 195 } 196 html += `<p>${children.join("\n")}</p>`; 197 } 198 199 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 200 if (block.block.level === 1) { 201 html += `<h2>${block.block.plaintext}</h2>`; 202 } 203 } 204 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 205 if (block.block.level === 2) { 206 html += `<h3>${block.block.plaintext}</h3>`; 207 } 208 } 209 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 210 if (block.block.level === 3) { 211 html += `<h4>${block.block.plaintext}</h4>`; 212 } 213 } 214 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { 215 if (!block.block.level) { 216 html += `<h6>${block.block.plaintext}</h6>`; 217 } 218 } 219 } 220 221 return sanitizeHTML(html); 222} 223 224export class RichText { 225 unicodeText: UnicodeString; 226 facets?: Facet[]; 227 228 constructor(props: { text: string; facets: Facet[] }) { 229 this.unicodeText = new UnicodeString(props.text); 230 this.facets = props.facets; 231 if (this.facets) { 232 this.facets = this.facets 233 .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 234 .sort((a, b) => a.index.byteStart - b.index.byteStart); 235 } 236 } 237 238 *segments(): Generator<RichTextSegment, void, void> { 239 const facets = this.facets || []; 240 if (!facets.length) { 241 yield { text: this.unicodeText.utf16 }; 242 return; 243 } 244 245 let textCursor = 0; 246 let facetCursor = 0; 247 do { 248 const currFacet = facets[facetCursor]; 249 if (currFacet) { 250 if (textCursor < currFacet.index.byteStart) { 251 yield { 252 text: this.unicodeText.slice(textCursor, currFacet.index.byteStart), 253 }; 254 } else if (textCursor > currFacet.index.byteStart) { 255 facetCursor++; 256 continue; 257 } 258 if (currFacet.index.byteStart < currFacet.index.byteEnd) { 259 const subtext = this.unicodeText.slice( 260 currFacet.index.byteStart, 261 currFacet.index.byteEnd, 262 ); 263 if (!subtext.trim()) { 264 // dont empty string entities 265 yield { text: subtext }; 266 } else { 267 yield { text: subtext, facet: currFacet.features }; 268 } 269 } 270 textCursor = currFacet.index.byteEnd; 271 facetCursor++; 272 } 273 } while (facetCursor < facets.length); 274 if (textCursor < this.unicodeText.length) { 275 yield { 276 text: this.unicodeText.slice(textCursor, this.unicodeText.length), 277 }; 278 } 279 } 280}