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