Website of atproto.fr
at main 229 lines 7.5 kB view raw
1/** 2 * Renders Leaflet rich content (pub.leaflet.content) to HTML. 3 */ 4 5interface Facet { 6 index: { byteStart: number; byteEnd: number }; 7 features: { $type: string; uri?: string; contentPlaintext?: string }[]; 8} 9 10interface TextBlock { 11 $type: "pub.leaflet.blocks.text"; 12 plaintext: string; 13 facets?: Facet[]; 14} 15 16interface HeaderBlock { 17 $type: "pub.leaflet.blocks.header"; 18 plaintext: string; 19 facets?: Facet[]; 20 level?: number; 21} 22 23interface ImageBlock { 24 $type: "pub.leaflet.blocks.image"; 25 src?: string; 26 title?: string; 27 description?: string; 28 previewImage?: { $type: string; ref: { $link: string }; mimeType: string; size: number }; 29} 30 31interface WebsiteBlock { 32 $type: "pub.leaflet.blocks.website"; 33 src?: string; 34 title?: string; 35 description?: string; 36 previewImage?: { $type: string; ref: { $link: string }; mimeType: string; size: number }; 37} 38 39interface BskyBlock { 40 $type: "pub.leaflet.blocks.bsky"; 41 src?: string; 42 title?: string; 43 description?: string; 44} 45 46interface HorizontalRuleBlock { 47 $type: "pub.leaflet.blocks.horizontalRule"; 48} 49 50interface ListItem { 51 $type: string; 52 content: TextBlock; 53 children?: ListItem[]; 54} 55 56interface UnorderedListBlock { 57 $type: "pub.leaflet.blocks.unorderedList"; 58 children: ListItem[]; 59} 60 61function escapeHtml(text: string): string { 62 return text 63 .replace(/&/g, "&amp;") 64 .replace(/</g, "&lt;") 65 .replace(/>/g, "&gt;") 66 .replace(/"/g, "&quot;"); 67} 68 69function applyFacets(plaintext: string, facets?: Facet[]): string { 70 if (!facets || facets.length === 0) return escapeHtml(plaintext); 71 72 const bytes = new TextEncoder().encode(plaintext); 73 // Sort facets by byteStart 74 const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 75 76 let html = ""; 77 let cursor = 0; 78 79 for (const facet of sorted) { 80 const start = facet.index.byteStart; 81 const end = facet.index.byteEnd; 82 83 // Text before this facet 84 if (start > cursor) { 85 html += escapeHtml(new TextDecoder().decode(bytes.slice(cursor, start))); 86 } 87 88 const segment = escapeHtml(new TextDecoder().decode(bytes.slice(start, end))); 89 let wrapped = segment; 90 91 for (const feature of facet.features) { 92 switch (feature.$type) { 93 case "pub.leaflet.richtext.facet#link": 94 wrapped = `<a href="${escapeHtml(feature.uri ?? "#")}" target="_blank" rel="noopener noreferrer">${wrapped}</a>`; 95 break; 96 case "pub.leaflet.richtext.facet#bold": 97 wrapped = `<strong>${wrapped}</strong>`; 98 break; 99 case "pub.leaflet.richtext.facet#italic": 100 wrapped = `<em>${wrapped}</em>`; 101 break; 102 case "pub.leaflet.richtext.facet#highlight": 103 wrapped = `<mark>${wrapped}</mark>`; 104 break; 105 case "pub.leaflet.richtext.facet#footnote": 106 const footnoteContent = escapeHtml(feature.contentPlaintext ?? ""); 107 wrapped = `<span class="sidenote-anchor">${wrapped}<span class="sidenote">${footnoteContent}</span></span>`; 108 break; 109 } 110 } 111 112 html += wrapped; 113 cursor = end; 114 } 115 116 // Remaining text after last facet 117 if (cursor < bytes.length) { 118 html += escapeHtml(new TextDecoder().decode(bytes.slice(cursor))); 119 } 120 121 return html; 122} 123 124interface RenderContext { 125 pds?: string; 126 did?: string; 127} 128 129function blobUrl(ctx: RenderContext, cid: string): string | undefined { 130 if (!ctx.pds || !ctx.did) return undefined; 131 return `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(ctx.did)}&cid=${encodeURIComponent(cid)}`; 132} 133 134function renderBlock(block: any, ctx: RenderContext): string { 135 const type = block.$type as string; 136 137 if (type === "pub.leaflet.blocks.text") { 138 const text = applyFacets(block.plaintext ?? "", block.facets); 139 if (!text.trim()) return ""; 140 return `<p>${text}</p>`; 141 } 142 143 if (type === "pub.leaflet.blocks.header") { 144 const level = block.level ?? 2; 145 const tag = `h${Math.min(Math.max(level, 1), 6)}`; 146 const text = applyFacets(block.plaintext ?? "", block.facets); 147 return `<${tag}>${text}</${tag}>`; 148 } 149 150 if (type === "pub.leaflet.blocks.image") { 151 const title = block.title ? escapeHtml(block.title) : ""; 152 const alt = block.alt ? escapeHtml(block.alt) : (block.description ? escapeHtml(block.description) : ""); 153 const src = block.src ?? (block.image?.ref?.$link ? blobUrl(ctx, block.image.ref.$link) : undefined); 154 if (src) { 155 const altDropdown = alt ? `<details><summary>alt</summary><p>${alt}</p></details>` : ""; 156 return `<figure><a href="${escapeHtml(src)}" target="_blank" rel="noopener noreferrer"><img src="${escapeHtml(src)}" alt="${alt}" /></a>${altDropdown}${title ? `<figcaption>${title}</figcaption>` : ""}</figure>`; 157 } 158 return ""; 159 } 160 161 if (type === "pub.leaflet.blocks.website") { 162 const url = block.src ?? ""; 163 const title = block.title ?? url; 164 const desc = block.description ?? ""; 165 if (!url) return ""; 166 return `<div class="border border-atfr-green/20 dark:border-white/20 p-3 my-2"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="font-medium hover:underline">${escapeHtml(title)}</a>${desc ? `<p class="text-sm text-atfr-green dark:text-white mt-1">${escapeHtml(desc)}</p>` : ""}</div>`; 167 } 168 169 if (type === "pub.leaflet.blocks.bsky") { 170 const url = block.src ?? ""; 171 const title = block.title ?? "Post Bluesky"; 172 const desc = block.description ?? ""; 173 if (!url) return ""; 174 return `<div class="border border-atfr-green/20 dark:border-white/20 p-3 my-2"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="font-medium hover:underline">${escapeHtml(title)}</a>${desc ? `<p class="text-sm text-atfr-green dark:text-white mt-1">${escapeHtml(desc)}</p>` : ""}</div>`; 175 } 176 177 if (type === "pub.leaflet.blocks.bskyPost") { 178 const atUri = block.postRef?.uri ?? ""; 179 const clientHost = block.clientHost ?? "bsky.app"; 180 if (!atUri) return ""; 181 const match = atUri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/); 182 if (match) { 183 const did = match[1]; 184 const rkey = match[2]; 185 const url = `https://${clientHost}/profile/${did}/post/${rkey}`; 186 return `<div class="bsky-embed-container"><blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(atUri)}"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">Voir sur Bluesky</a></blockquote></div>`; 187 } 188 return ""; 189 } 190 191 if (type === "pub.leaflet.blocks.horizontalRule") { 192 return "<hr />"; 193 } 194 195 if (type === "pub.leaflet.blocks.unorderedList") { 196 return renderList(block.children ?? []); 197 } 198 199 return ""; 200} 201 202function renderList(items: any[]): string { 203 const lis = items 204 .map((item) => { 205 const text = applyFacets(item.content?.plaintext ?? "", item.content?.facets); 206 const children = item.children?.length ? renderList(item.children) : ""; 207 return `<li>${text}${children}</li>`; 208 }) 209 .join(""); 210 return `<ul>${lis}</ul>`; 211} 212 213export function renderLeafletContent(content: any, ctx: RenderContext = {}): string { 214 if (!content || content.$type !== "pub.leaflet.content") return ""; 215 216 const pages = content.pages ?? []; 217 const parts: string[] = []; 218 219 for (const page of pages) { 220 const blocks = page.blocks ?? []; 221 for (const wrapper of blocks) { 222 // linearDocument wraps blocks in { $type: "...#block", block: {...} } 223 const block = wrapper.block ?? wrapper; 224 parts.push(renderBlock(block, ctx)); 225 } 226 } 227 228 return parts.filter(Boolean).join("\n"); 229}