WIP PWA for Grain
at main 311 lines 8.7 kB view raw
1// src/lib/richtext.js - Bluesky-compatible richtext parsing and rendering 2 3/** 4 * Parse text for Bluesky facets: mentions, links, hashtags. 5 * Returns { text, facets } with byte-indexed positions. 6 * 7 * @param {string} text - Plain text to parse 8 * @param {function} resolveHandle - Optional async function to resolve @handle to DID 9 * @returns {Promise<{ text: string, facets: Array }>} 10 */ 11export async function parseTextToFacets(text, resolveHandle = null) { 12 if (!text) return { text: '', facets: [] }; 13 14 const facets = []; 15 const encoder = new TextEncoder(); 16 17 function getByteOffset(str, charIndex) { 18 return encoder.encode(str.slice(0, charIndex)).length; 19 } 20 21 // Track claimed positions to avoid overlaps 22 const claimedPositions = new Set(); 23 24 function isRangeClaimed(start, end) { 25 for (let i = start; i < end; i++) { 26 if (claimedPositions.has(i)) return true; 27 } 28 return false; 29 } 30 31 function claimRange(start, end) { 32 for (let i = start; i < end; i++) { 33 claimedPositions.add(i); 34 } 35 } 36 37 // URLs first (highest priority) 38 const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 39 let urlMatch; 40 while ((urlMatch = urlRegex.exec(text)) !== null) { 41 const start = urlMatch.index; 42 const end = start + urlMatch[0].length; 43 44 if (!isRangeClaimed(start, end)) { 45 claimRange(start, end); 46 facets.push({ 47 index: { 48 byteStart: getByteOffset(text, start), 49 byteEnd: getByteOffset(text, end), 50 }, 51 features: [{ 52 $type: 'app.bsky.richtext.facet#link', 53 uri: urlMatch[0], 54 }], 55 }); 56 } 57 } 58 59 // Mentions: @handle or @handle.domain.tld 60 const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 61 let mentionMatch; 62 while ((mentionMatch = mentionRegex.exec(text)) !== null) { 63 const start = mentionMatch.index; 64 const end = start + mentionMatch[0].length; 65 const handle = mentionMatch[0].slice(1); // Remove @ 66 67 if (!isRangeClaimed(start, end)) { 68 // Try to resolve handle to DID 69 let did = null; 70 if (resolveHandle) { 71 try { 72 did = await resolveHandle(handle); 73 } catch (e) { 74 // Handle not found - skip this mention 75 continue; 76 } 77 } 78 79 if (did) { 80 claimRange(start, end); 81 facets.push({ 82 index: { 83 byteStart: getByteOffset(text, start), 84 byteEnd: getByteOffset(text, end), 85 }, 86 features: [{ 87 $type: 'app.bsky.richtext.facet#mention', 88 did, 89 }], 90 }); 91 } 92 } 93 } 94 95 // Hashtags: #tag (alphanumeric, no leading numbers) 96 const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 97 let hashtagMatch; 98 while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 99 const start = hashtagMatch.index; 100 const end = start + hashtagMatch[0].length; 101 const tag = hashtagMatch[1]; // Without # 102 103 if (!isRangeClaimed(start, end)) { 104 claimRange(start, end); 105 facets.push({ 106 index: { 107 byteStart: getByteOffset(text, start), 108 byteEnd: getByteOffset(text, end), 109 }, 110 features: [{ 111 $type: 'app.bsky.richtext.facet#tag', 112 tag, 113 }], 114 }); 115 } 116 } 117 118 // Sort by byte position 119 facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 120 121 return { text, facets }; 122} 123 124/** 125 * Synchronous parsing for client-side render (no DID resolution). 126 * Mentions display as-is without profile links. 127 */ 128export function parseTextToFacetsSync(text) { 129 if (!text) return { text: '', facets: [] }; 130 131 const facets = []; 132 const encoder = new TextEncoder(); 133 134 function getByteOffset(str, charIndex) { 135 return encoder.encode(str.slice(0, charIndex)).length; 136 } 137 138 const claimedPositions = new Set(); 139 140 function isRangeClaimed(start, end) { 141 for (let i = start; i < end; i++) { 142 if (claimedPositions.has(i)) return true; 143 } 144 return false; 145 } 146 147 function claimRange(start, end) { 148 for (let i = start; i < end; i++) { 149 claimedPositions.add(i); 150 } 151 } 152 153 // URLs 154 const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; 155 let urlMatch; 156 while ((urlMatch = urlRegex.exec(text)) !== null) { 157 const start = urlMatch.index; 158 const end = start + urlMatch[0].length; 159 160 if (!isRangeClaimed(start, end)) { 161 claimRange(start, end); 162 facets.push({ 163 index: { 164 byteStart: getByteOffset(text, start), 165 byteEnd: getByteOffset(text, end), 166 }, 167 features: [{ 168 $type: 'app.bsky.richtext.facet#link', 169 uri: urlMatch[0], 170 }], 171 }); 172 } 173 } 174 175 // Mentions: @handle or @handle.domain.tld (no DID resolution in sync mode) 176 const mentionRegex = /@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/g; 177 let mentionMatch; 178 while ((mentionMatch = mentionRegex.exec(text)) !== null) { 179 const start = mentionMatch.index; 180 const end = start + mentionMatch[0].length; 181 182 if (!isRangeClaimed(start, end)) { 183 claimRange(start, end); 184 facets.push({ 185 index: { 186 byteStart: getByteOffset(text, start), 187 byteEnd: getByteOffset(text, end), 188 }, 189 features: [{ 190 $type: 'app.bsky.richtext.facet#mention', 191 did: null, // No DID in sync mode 192 }], 193 }); 194 } 195 } 196 197 // Hashtags 198 const hashtagRegex = /#([a-zA-Z][a-zA-Z0-9_]*)/g; 199 let hashtagMatch; 200 while ((hashtagMatch = hashtagRegex.exec(text)) !== null) { 201 const start = hashtagMatch.index; 202 const end = start + hashtagMatch[0].length; 203 const tag = hashtagMatch[1]; 204 205 if (!isRangeClaimed(start, end)) { 206 claimRange(start, end); 207 facets.push({ 208 index: { 209 byteStart: getByteOffset(text, start), 210 byteEnd: getByteOffset(text, end), 211 }, 212 features: [{ 213 $type: 'app.bsky.richtext.facet#tag', 214 tag, 215 }], 216 }); 217 } 218 } 219 220 facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 221 return { text, facets }; 222} 223 224/** 225 * Render text with facets as HTML. 226 * 227 * @param {string} text - The text content 228 * @param {Array} facets - Array of facet objects 229 * @param {Object} options - Rendering options 230 * @returns {string} HTML string 231 */ 232export function renderFacetedText(text, facets, options = {}) { 233 if (!text) return ''; 234 235 // If no facets, just escape and return 236 if (!facets || facets.length === 0) { 237 return escapeHtml(text); 238 } 239 240 const encoder = new TextEncoder(); 241 const decoder = new TextDecoder(); 242 const bytes = encoder.encode(text); 243 244 // Sort facets by start position 245 const sortedFacets = [...facets].sort( 246 (a, b) => a.index.byteStart - b.index.byteStart 247 ); 248 249 let result = ''; 250 let lastEnd = 0; 251 252 for (const facet of sortedFacets) { 253 // Validate byte indices 254 if (facet.index.byteStart < 0 || facet.index.byteEnd > bytes.length) { 255 continue; // Skip invalid facets 256 } 257 258 // Add text before this facet 259 if (facet.index.byteStart > lastEnd) { 260 const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); 261 result += escapeHtml(decoder.decode(beforeBytes)); 262 } 263 264 // Get the faceted text 265 const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); 266 const facetText = decoder.decode(facetBytes); 267 268 // Determine facet type and render 269 const feature = facet.features?.[0]; 270 if (!feature) { 271 result += escapeHtml(facetText); 272 lastEnd = facet.index.byteEnd; 273 continue; 274 } 275 276 const type = feature.$type || feature.__typename || ''; 277 278 if (type.includes('link')) { 279 const uri = feature.uri || ''; 280 result += `<a href="${escapeHtml(uri)}" target="_blank" rel="noopener noreferrer" class="facet-link">${escapeHtml(facetText)}</a>`; 281 } else if (type.includes('mention')) { 282 // Extract handle from text (remove @) 283 const handle = facetText.startsWith('@') ? facetText.slice(1) : facetText; 284 result += `<a href="/profile/${escapeHtml(handle)}" class="facet-mention">${escapeHtml(facetText)}</a>`; 285 } else if (type.includes('tag')) { 286 // Hashtag - styled but not clickable for now 287 result += `<span class="facet-tag">${escapeHtml(facetText)}</span>`; 288 } else { 289 result += escapeHtml(facetText); 290 } 291 292 lastEnd = facet.index.byteEnd; 293 } 294 295 // Add remaining text 296 if (lastEnd < bytes.length) { 297 const remainingBytes = bytes.slice(lastEnd); 298 result += escapeHtml(decoder.decode(remainingBytes)); 299 } 300 301 return result; 302} 303 304function escapeHtml(text) { 305 return text 306 .replace(/&/g, '&amp;') 307 .replace(/</g, '&lt;') 308 .replace(/>/g, '&gt;') 309 .replace(/"/g, '&quot;') 310 .replace(/'/g, '&#039;'); 311}