forked from
grain.social/grain-pwa
WIP PWA for Grain
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, '&')
307 .replace(/</g, '<')
308 .replace(/>/g, '>')
309 .replace(/"/g, '"')
310 .replace(/'/g, ''');
311}