JavaScript-optional public web frontend for Bluesky
anartia.kelinci.net
sveltekit
atcute
bluesky
typescript
svelte
1import {
2 unwrapEmbed,
3 type AppBskyFeedDefs,
4 type AppBskyFeedPost,
5 type AppBskyRichtextFacet,
6} from '@atcute/bluesky';
7import { segmentize } from '@atcute/bluesky-richtext-segmenter';
8
9import { PUBLIC_APP_URL } from '$env/static/public';
10
11import { findLabel, FlagsBlurMedia } from './moderation';
12import { assertCanonicalResourceUri } from './types/at-uri';
13import { getQuoteEmbed } from './utils/bluesky/embeds';
14import { assertNever } from './utils/invariant';
15import type { UnwrapArray } from './utils/types';
16
17export const escapeContent = (str: string) => {
18 return str.replace(/[&<]/g, (c) => `&#${c.charCodeAt(0)};`);
19};
20
21export const escapeAttribute = (str: string) => {
22 return str.replace(/[&"]/g, (c) => `&#${c.charCodeAt(0)};`);
23};
24
25export interface FeedItem {
26 id?: string;
27 url: string;
28 title?: string;
29 description?: string | { html: string };
30 images?: Array<{
31 src: string;
32 thumbnailSrc?: string;
33 adult?: boolean;
34 alt?: string;
35 }>;
36 video?: {
37 playerUrl: string;
38 thumbnailSrc?: string;
39 adult?: boolean;
40 alt?: string;
41 };
42 date?: Date;
43}
44
45export interface FeedOptions {
46 meta: {
47 title: string;
48 description: string;
49 pageUrl: string;
50 rssUrl?: string;
51 image?: {
52 src: string;
53 };
54 };
55 items: FeedItem[];
56}
57
58export const createRssFeed = (options: FeedOptions) => {
59 const {
60 meta: { title, description, pageUrl, rssUrl, image },
61 items,
62 } = options;
63
64 let rss = `<?xml version="1.0" encoding="UTF-8" ?>`;
65 rss += `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">`;
66 rss += `<channel>`;
67
68 {
69 rss += `<title>${escapeContent(title)}</title>`;
70 rss += `<link>${escapeContent(pageUrl)}</link>`;
71 rss += `<description>${escapeContent(escapeContent(description))}</description>`;
72
73 if (rssUrl !== undefined) {
74 rss += `<atom:link href="${escapeAttribute(rssUrl)}" rel="self" type="application/rss+xml"/>`;
75 }
76
77 if (image !== undefined) {
78 rss += `<image>`;
79 rss += `<url>${escapeContent(image.src)}</url>`;
80 rss += `<title>${escapeContent(title)}</title>`;
81 rss += `<link>${escapeContent(pageUrl)}</link>`;
82
83 rss += `</image>`;
84 }
85 }
86
87 for (const { id, url, title, description, images, video, date } of items) {
88 rss += `<item>`;
89
90 if (id !== undefined) {
91 rss += `<guid isPermaLink="false">${escapeContent(id)}</guid>`;
92 }
93
94 rss += `<link>${escapeContent(url)}</link>`;
95
96 if (date !== undefined) {
97 rss += `<pubDate>${date.toUTCString()}</pubDate>`;
98 }
99
100 if (title !== undefined) {
101 rss += `<title>${escapeContent(title)}</title>`;
102 }
103
104 if (description !== undefined) {
105 const value = typeof description === 'string' ? escapeContent(description) : description.html;
106 rss += `<description>${escapeContent(value)}</description>`;
107 }
108
109 if (images !== undefined) {
110 for (const { src: url, adult, thumbnailSrc: thumbnail, alt } of images) {
111 rss += `<media:content url="${escapeAttribute(url)}" medium="image">`;
112 rss += `<media:rating scheme="urn:simple">${adult ? 'adult' : 'nonadult'}</media:rating>`;
113
114 if (alt !== undefined) {
115 rss += `<media:description type="plain">${escapeContent(alt)}</media:description>`;
116 }
117 if (thumbnail !== undefined) {
118 rss += `<media:thumbnail url="${escapeAttribute(thumbnail)}"/>`;
119 }
120
121 rss += `</media:content>`;
122 }
123 }
124
125 if (video !== undefined) {
126 const { playerUrl, thumbnailSrc, adult, alt } = video;
127 rss += `<media:content medium="video">`;
128 rss += `<media:player url="${escapeAttribute(playerUrl)}"/>`;
129 rss += `<media:rating scheme="urn:simple">${adult ? 'adult' : 'nonadult'}</media:rating>`;
130
131 if (thumbnailSrc !== undefined) {
132 rss += `<media:thumbnail url="${escapeAttribute(thumbnailSrc)}"/>`;
133 }
134
135 if (alt !== undefined) {
136 rss += `<media:description type="plain">${escapeContent(alt)}</media:description>`;
137 }
138
139 rss += `</media:content>`;
140 }
141
142 rss += `</item>`;
143 }
144
145 rss += `</channel>`;
146 rss += `</rss>`;
147 return rss;
148};
149
150export const richtextToHtml = (text: string, facets: AppBskyRichtextFacet.Main[] | undefined) => {
151 let html = '';
152
153 for (const segment of segmentize(text, facets)) {
154 const feature = grabFirstSupported(segment.features);
155 const subtext = escapeContent(segment.text).replace(/\n/g, '<br>');
156
157 switch (feature?.$type) {
158 case undefined: {
159 html += subtext;
160 break;
161 }
162 case 'app.bsky.richtext.facet#link': {
163 html += `<a class="link" href="${escapeAttribute(feature.uri)}">${subtext}</a>`;
164 break;
165 }
166 case 'app.bsky.richtext.facet#mention': {
167 const href = `${PUBLIC_APP_URL}/${feature.did}`;
168 html += `<a class="mention" href="${escapeAttribute(href)}">${subtext}</a>`;
169 break;
170 }
171 case 'app.bsky.richtext.facet#tag': {
172 const href = `${PUBLIC_APP_URL}/search/posts?q=${encodeURIComponent('#' + feature.tag)}`;
173 html += `<a class="hashtag" href="${escapeAttribute(href)}">${subtext}</a>`;
174 break;
175 }
176 default: {
177 assertNever(feature);
178 }
179 }
180 }
181
182 return html;
183};
184
185export const feedPostToFeedItem = (item: AppBskyFeedDefs.FeedViewPost): FeedItem => {
186 const post = item.post;
187 const author = post.author;
188
189 const record = post.record as AppBskyFeedPost.Main;
190
191 const { media, record: recordEmbed } = unwrapEmbed(post.embed);
192 const quote = getQuoteEmbed(recordEmbed);
193
194 const shouldBlurMedia = !!findLabel(post.labels, author.did, FlagsBlurMedia);
195
196 let html = richtextToHtml(record.text, record.facets);
197 if (quote) {
198 html += `<br><br>`;
199
200 switch (quote.$type) {
201 case 'app.bsky.embed.record#viewRecord': {
202 const author = quote.author;
203 const record = quote.value as AppBskyFeedPost.Main;
204
205 const uri = assertCanonicalResourceUri(quote.uri);
206
207 const postUrl = `${PUBLIC_APP_URL}/${author.did}/${uri.rkey}`;
208
209 html += `<blockquote>`;
210 html += `<b><a href="${escapeAttribute(postUrl)}">`;
211
212 if (author.displayName?.trim()) {
213 html += `${escapeContent(author.displayName.trim())} (@${escapeContent(author.handle)})`;
214 } else {
215 html += `@${escapeContent(author.handle)}`;
216 }
217
218 html += `</a></b><br>`;
219
220 html += richtextToHtml(record.text, record.facets);
221 html += `</blockquote>`;
222 break;
223 }
224 case 'app.bsky.embed.record#viewNotFound':
225 case 'app.bsky.embed.record#viewBlocked':
226 case 'app.bsky.embed.record#viewDetached': {
227 html += `<blockquote>Post not found</blockquote>`;
228 break;
229 }
230 }
231 }
232
233 return {
234 id: `${post.uri}|${post.cid}`,
235 url: `${PUBLIC_APP_URL}/${author.did}/${assertCanonicalResourceUri(post.uri).rkey}`,
236 date: new Date(post.indexedAt),
237 description: { html },
238 images:
239 media?.$type === 'app.bsky.embed.images#view'
240 ? media.images.map((image) => ({
241 src: image.fullsize,
242 thumbnailSrc: image.thumb,
243 adult: shouldBlurMedia,
244 alt: image.alt,
245 }))
246 : undefined,
247 video:
248 media?.$type === 'app.bsky.embed.video#view'
249 ? {
250 playerUrl: getVideoUrl(media.playlist),
251 thumbnailSrc: media.thumbnail,
252 adult: shouldBlurMedia,
253 alt: media.alt,
254 }
255 : undefined,
256 };
257};
258
259const getVideoUrl = (playlistUrl: string) => {
260 const MATCH_RE = /\/(did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])\/(bafkrei[2-7a-z]{52})\//;
261 const match = MATCH_RE.exec(decodeURIComponent(playlistUrl));
262 if (!match) {
263 return '';
264 }
265
266 return `${PUBLIC_APP_URL}/watch/${match[1]}/${match[2]}`;
267};
268
269type FacetFeature = UnwrapArray<AppBskyRichtextFacet.Main['features']>;
270const grabFirstSupported = (features: FacetFeature[] | undefined): FacetFeature | undefined => {
271 return features?.find(
272 (feature) =>
273 feature.$type === 'app.bsky.richtext.facet#link' ||
274 feature.$type === 'app.bsky.richtext.facet#mention' ||
275 feature.$type === 'app.bsky.richtext.facet#tag',
276 );
277};