JavaScript-optional public web frontend for Bluesky anartia.kelinci.net
sveltekit atcute bluesky typescript svelte
at trunk 277 lines 7.7 kB view raw
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};