tangled
alpha
login
or
join now
flo-bit.dev
/
blento
21
fork
atom
your personal website on atproto - mirror
blento.app
21
fork
atom
overview
issues
1
pulls
pipelines
add markdown support to event descriptions
Florian
2 days ago
3c0dfa34
197b58e5
+60
-31
1 changed file
expand all
collapse all
unified
split
src
routes
[[actor=actor]]
events
[rkey]
+page.svelte
+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
10
-
import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter';
10
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
116
-
function escapeHtml(str: string): string {
117
117
-
return str
118
118
-
.replace(/&/g, '&')
119
119
-
.replace(/</g, '<')
120
120
-
.replace(/>/g, '>')
121
121
-
.replace(/"/g, '"')
122
122
-
.replace(/'/g, ''');
123
123
-
}
116
116
+
const renderer = new marked.Renderer();
117
117
+
renderer.link = ({ href, text }) =>
118
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
125
-
function renderDescription(text: string, facets?: Facet[]): string {
126
126
-
const segments = segmentize(text, facets);
127
127
-
const html = segments
128
128
-
.map((segment) => {
129
129
-
const escaped = escapeHtml(segment.text);
130
130
-
const feature = segment.features?.[0] as
131
131
-
| { $type: string; did?: string; uri?: string; tag?: string }
132
132
-
| undefined;
133
133
-
if (!feature) return `<span>${escaped}</span>`;
120
120
+
function renderDescription(
121
121
+
text: string,
122
122
+
facets?: {
123
123
+
index: { byteStart: number; byteEnd: number };
124
124
+
features: { $type: string; did?: string; uri?: string; tag?: string }[];
125
125
+
}[]
126
126
+
): string {
127
127
+
let result = text;
134
128
135
135
-
const link = (href: string) =>
136
136
-
`<a target="_blank" rel="noopener noreferrer nofollow" href="${encodeURI(href)}" class="text-accent-600 dark:text-accent-400 hover:underline">${escaped}</a>`;
129
129
+
if (facets && facets.length > 0) {
130
130
+
const encoder = new TextEncoder();
131
131
+
const encoded = encoder.encode(text);
132
132
+
const decoder = new TextDecoder();
133
133
+
134
134
+
// Sort facets in reverse order by byteStart so replacements don't shift positions
135
135
+
const sorted = [...facets].sort((a, b) => b.index.byteStart - a.index.byteStart);
136
136
+
137
137
+
for (const facet of sorted) {
138
138
+
const feature = facet.features?.[0];
139
139
+
if (!feature) continue;
140
140
+
141
141
+
const segmentBytes = encoded.slice(facet.index.byteStart, facet.index.byteEnd);
142
142
+
const segmentText = decoder.decode(segmentBytes);
137
143
144
144
+
let mdLink: string | null = null;
138
145
switch (feature.$type) {
139
146
case 'app.bsky.richtext.facet#mention':
140
140
-
return link(`https://bsky.app/profile/${feature.did}`);
147
147
+
mdLink = `[${segmentText}](https://bsky.app/profile/${feature.did})`;
148
148
+
break;
141
149
case 'app.bsky.richtext.facet#link':
142
142
-
return link(feature.uri!);
150
150
+
mdLink = `[${segmentText}](${feature.uri})`;
151
151
+
break;
143
152
case 'app.bsky.richtext.facet#tag':
144
144
-
return link(`https://bsky.app/hashtag/${feature.tag}`);
145
145
-
default:
146
146
-
return `<span>${escaped}</span>`;
153
153
+
mdLink = `[${segmentText}](https://bsky.app/hashtag/${feature.tag})`;
154
154
+
break;
155
155
+
}
156
156
+
157
157
+
if (mdLink) {
158
158
+
// Convert byte offsets to character offsets for string replacement
159
159
+
const before = decoder.decode(encoded.slice(0, facet.index.byteStart));
160
160
+
const after = decoder.decode(encoded.slice(facet.index.byteEnd));
161
161
+
result = before + mdLink + after;
147
162
}
148
148
-
})
149
149
-
.join('');
150
150
-
return html.replace(/\n/g, '<br>');
163
163
+
}
164
164
+
}
165
165
+
166
166
+
return marked.parse(result, { renderer }) as string;
151
167
}
152
168
153
169
let descriptionHtml = $derived(
154
170
eventData.description
155
155
-
? sanitize(renderDescription(eventData.description, eventData.facets as Facet[] | undefined))
171
171
+
? sanitize(
172
172
+
renderDescription(
173
173
+
eventData.description,
174
174
+
eventData.facets as
175
175
+
| {
176
176
+
index: { byteStart: number; byteEnd: number };
177
177
+
features: { $type: string; did?: string; uri?: string; tag?: string }[];
178
178
+
}[]
179
179
+
| undefined
180
180
+
),
181
181
+
{ ADD_ATTR: ['target'] }
182
182
+
)
156
183
: null
157
184
);
158
185
···
337
364
>
338
365
About
339
366
</p>
340
340
-
<p class="text-base-700 dark:text-base-300 leading-relaxed wrap-break-word">
367
367
+
<div
368
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
369
+
>
341
370
{@html descriptionHtml}
342
342
-
</p>
371
371
+
</div>
343
372
</div>
344
373
{/if}
345
374
</div>