tangled
alpha
login
or
join now
flo-bit.dev
/
blento
22
fork
atom
your personal website on atproto - mirror
blento.app
22
fork
atom
overview
issues
1
pulls
pipelines
add markdown support to event descriptions
Florian
1 week 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
import EventRsvp from './EventRsvp.svelte';
8
import EventAttendees from './EventAttendees.svelte';
9
import { page } from '$app/state';
10
-
import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter';
11
import { sanitize } from '$lib/sanitize';
12
import { generateICalEvent } from '$lib/ical';
13
···
113
startDate.getDate() === endDate.getDate()
114
);
115
116
-
function escapeHtml(str: string): string {
117
-
return str
118
-
.replace(/&/g, '&')
119
-
.replace(/</g, '<')
120
-
.replace(/>/g, '>')
121
-
.replace(/"/g, '"')
122
-
.replace(/'/g, ''');
123
-
}
124
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>`;
134
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>`;
0
0
0
0
0
0
0
0
0
0
0
0
137
0
138
switch (feature.$type) {
139
case 'app.bsky.richtext.facet#mention':
140
-
return link(`https://bsky.app/profile/${feature.did}`);
0
141
case 'app.bsky.richtext.facet#link':
142
-
return link(feature.uri!);
0
143
case 'app.bsky.richtext.facet#tag':
144
-
return link(`https://bsky.app/hashtag/${feature.tag}`);
145
-
default:
146
-
return `<span>${escaped}</span>`;
0
0
0
0
0
0
147
}
148
-
})
149
-
.join('');
150
-
return html.replace(/\n/g, '<br>');
0
151
}
152
153
let descriptionHtml = $derived(
154
eventData.description
155
-
? sanitize(renderDescription(eventData.description, eventData.facets as Facet[] | undefined))
0
0
0
0
0
0
0
0
0
0
0
156
: null
157
);
158
···
337
>
338
About
339
</p>
340
-
<p class="text-base-700 dark:text-base-300 leading-relaxed wrap-break-word">
0
0
341
{@html descriptionHtml}
342
-
</p>
343
</div>
344
{/if}
345
</div>
···
7
import EventRsvp from './EventRsvp.svelte';
8
import EventAttendees from './EventAttendees.svelte';
9
import { page } from '$app/state';
10
+
import { marked } from 'marked';
11
import { sanitize } from '$lib/sanitize';
12
import { generateICalEvent } from '$lib/ical';
13
···
113
startDate.getDate() === endDate.getDate()
114
);
115
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>`;
0
0
0
0
0
119
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;
0
128
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);
143
144
+
let mdLink: string | null = null;
145
switch (feature.$type) {
146
case 'app.bsky.richtext.facet#mention':
147
+
mdLink = `[${segmentText}](https://bsky.app/profile/${feature.did})`;
148
+
break;
149
case 'app.bsky.richtext.facet#link':
150
+
mdLink = `[${segmentText}](${feature.uri})`;
151
+
break;
152
case 'app.bsky.richtext.facet#tag':
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;
162
}
163
+
}
164
+
}
165
+
166
+
return marked.parse(result, { renderer }) as string;
167
}
168
169
let descriptionHtml = $derived(
170
eventData.description
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
+
)
183
: null
184
);
185
···
364
>
365
About
366
</p>
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
+
>
370
{@html descriptionHtml}
371
+
</div>
372
</div>
373
{/if}
374
</div>