your personal website on atproto - mirror blento.app

add markdown support to event descriptions

+60 -31
+60 -31
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 7 7 import EventRsvp from './EventRsvp.svelte'; 8 8 import EventAttendees from './EventAttendees.svelte'; 9 9 import { page } from '$app/state'; 10 - import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter'; 10 + import { marked } from 'marked'; 11 11 import { sanitize } from '$lib/sanitize'; 12 12 import { generateICalEvent } from '$lib/ical'; 13 13 ··· 113 113 startDate.getDate() === endDate.getDate() 114 114 ); 115 115 116 - function escapeHtml(str: string): string { 117 - return str 118 - .replace(/&/g, '&amp;') 119 - .replace(/</g, '&lt;') 120 - .replace(/>/g, '&gt;') 121 - .replace(/"/g, '&quot;') 122 - .replace(/'/g, '&#39;'); 123 - } 116 + const renderer = new marked.Renderer(); 117 + renderer.link = ({ href, text }) => 118 + `<a target="_blank" rel="noopener noreferrer nofollow" href="${href}" class="text-accent-600 dark:text-accent-400 hover:underline">${text}</a>`; 124 119 125 - function renderDescription(text: string, facets?: Facet[]): string { 126 - const segments = segmentize(text, facets); 127 - const html = segments 128 - .map((segment) => { 129 - const escaped = escapeHtml(segment.text); 130 - const feature = segment.features?.[0] as 131 - | { $type: string; did?: string; uri?: string; tag?: string } 132 - | undefined; 133 - if (!feature) return `<span>${escaped}</span>`; 120 + function renderDescription( 121 + text: string, 122 + facets?: { 123 + index: { byteStart: number; byteEnd: number }; 124 + features: { $type: string; did?: string; uri?: string; tag?: string }[]; 125 + }[] 126 + ): string { 127 + let result = text; 134 128 135 - const link = (href: string) => 136 - `<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}" class="text-accent-600 dark:text-accent-400 hover:underline">${escaped}</a>`; 129 + if (facets && facets.length > 0) { 130 + const encoder = new TextEncoder(); 131 + const encoded = encoder.encode(text); 132 + const decoder = new TextDecoder(); 133 + 134 + // Sort facets in reverse order by byteStart so replacements don't shift positions 135 + const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart); 136 + 137 + for (const facet of sorted) { 138 + const feature = facet.features?.[0]; 139 + if (!feature) continue; 140 + 141 + const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd); 142 + const segmentText = decoder.decode(segmentBytes); 137 143 144 + let mdLink: string | null = null; 138 145 switch (feature.$type) { 139 146 case 'app.bsky.richtext.facet#mention': 140 - return link(`https://bsky.app/profile/${feature.did}`); 147 + mdLink = `[${segmentText}](https://bsky.app/profile/${feature.did})`; 148 + break; 141 149 case 'app.bsky.richtext.facet#link': 142 - return link(feature.uri!); 150 + mdLink = `[${segmentText}](${feature.uri})`; 151 + break; 143 152 case 'app.bsky.richtext.facet#tag': 144 - return link(`https://bsky.app/hashtag/${feature.tag}`); 145 - default: 146 - return `<span>${escaped}</span>`; 153 + mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`; 154 + break; 155 + } 156 + 157 + if (mdLink) { 158 + // Convert byte offsets to character offsets for string replacement 159 + const before = decoder.decode(encoded.slice(0, facet.index.byteStart)); 160 + const after = decoder.decode(encoded.slice(facet.index.byteEnd)); 161 + result = before + mdLink + after; 147 162 } 148 - }) 149 - .join(''); 150 - return html.replace(/\n/g, '<br>'); 163 + } 164 + } 165 + 166 + return marked.parse(result, { renderer }) as string; 151 167 } 152 168 153 169 let descriptionHtml = $derived( 154 170 eventData.description 155 - ? sanitize(renderDescription(eventData.description, eventData.facets as Facet[] | undefined)) 171 + ? sanitize( 172 + renderDescription( 173 + eventData.description, 174 + eventData.facets as 175 + | { 176 + index: { byteStart: number; byteEnd: number }; 177 + features: { $type: string; did?: string; uri?: string; tag?: string }[]; 178 + }[] 179 + | undefined 180 + ), 181 + { ADD_ATTR: ['target'] } 182 + ) 156 183 : null 157 184 ); 158 185 ··· 337 364 > 338 365 About 339 366 </p> 340 - <p class="text-base-700 dark:text-base-300 leading-relaxed wrap-break-word"> 367 + <div 368 + class="text-base-700 dark:text-base-300 prose dark:prose-invert prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-a:hover:underline prose-a:no-underline max-w-none leading-relaxed wrap-break-word" 369 + > 341 370 {@html descriptionHtml} 342 - </p> 371 + </div> 343 372 </div> 344 373 {/if} 345 374 </div>