your personal website on atproto - mirror blento.app
at small-improvements 398 lines 10 kB view raw
1import { getDetailedProfile, listRecords, resolveHandle, parseUri, getRecord } from '$lib/atproto'; 2import { CardDefinitionsByType } from '$lib/cards'; 3import type { CacheService } from '$lib/cache'; 4import { createEmptyCard } from '$lib/helper'; 5import type { Item, PronounsRecord, WebsiteData } from '$lib/types'; 6import { error } from '@sveltejs/kit'; 7import type { ActorIdentifier, Did } from '@atcute/lexicons'; 8 9import { isDid, isHandle } from '@atcute/lexicons/syntax'; 10import { fixAllCollisions, compactItems } from '$lib/layout'; 11 12const CURRENT_CACHE_VERSION = 1; 13 14function formatPronouns( 15 record: PronounsRecord | undefined, 16 profile: WebsiteData['profile'] | undefined 17): string | undefined { 18 // nearhorizon.actor.pronouns - https://github.com/skydeval/atproto-pronouns 19 if (record?.value?.sets?.length) { 20 const sets = record.value.sets; 21 const displayMode = record.value.displayMode ?? 'all'; 22 const setsToShow = displayMode === 'firstOnly' ? sets.slice(0, 1) : sets; 23 return setsToShow.map((s) => s.forms.join('/')).join(' · '); 24 } 25 // fallback to bsky pronouns 26 const pronouns = (profile as Record<string, unknown>)?.pronouns; 27 if (pronouns && typeof pronouns === 'string') return pronouns; 28 return undefined; 29} 30 31export async function getCache(identifier: ActorIdentifier, page: string, cache?: CacheService) { 32 try { 33 const cachedResult = await cache?.getBlento(identifier); 34 35 if (!cachedResult) return; 36 const result = JSON.parse(cachedResult); 37 38 if (!result.version || result.version !== CURRENT_CACHE_VERSION) { 39 console.log('skipping cache because of version mismatch'); 40 return; 41 } 42 43 result.page = 'blento.' + page; 44 45 result.publication = (result.publications as Awaited<ReturnType<typeof listRecords>>).find( 46 (v) => parseUri(v.uri)?.rkey === result.page 47 )?.value; 48 result.publication ??= { 49 name: result.profile?.displayName || result.profile?.handle, 50 description: result.profile?.description 51 }; 52 53 delete result['publications']; 54 55 return checkData(result); 56 } catch (error) { 57 console.log('getting cached result failed', error); 58 } 59} 60 61export async function loadData( 62 handle: ActorIdentifier, 63 cache: CacheService | undefined, 64 forceUpdate: boolean = false, 65 page: string = 'self', 66 env?: Record<string, string | undefined> 67): Promise<WebsiteData> { 68 if (!handle) throw error(404); 69 if (handle === 'favicon.ico') throw error(404); 70 71 if (!forceUpdate) { 72 const cachedResult = await getCache(handle, page, cache); 73 74 if (cachedResult) return cachedResult; 75 } 76 77 let did: Did | undefined = undefined; 78 if (isHandle(handle)) { 79 did = await resolveHandle({ handle }); 80 } else if (isDid(handle)) { 81 did = handle; 82 } else { 83 throw error(404); 84 } 85 86 const [cards, mainPublication, pages, profile, pronounsRecord] = await Promise.all([ 87 listRecords({ did, collection: 'app.blento.card', limit: 0 }).catch((e) => { 88 console.error('error getting records for collection app.blento.card', e); 89 return [] as Awaited<ReturnType<typeof listRecords>>; 90 }), 91 getRecord({ 92 did, 93 collection: 'site.standard.publication', 94 rkey: 'blento.self' 95 }).catch(() => { 96 console.error('error getting record for collection site.standard.publication'); 97 return undefined; 98 }), 99 listRecords({ did, collection: 'app.blento.page' }).catch(() => { 100 console.error('error getting records for collection app.blento.page'); 101 return [] as Awaited<ReturnType<typeof listRecords>>; 102 }), 103 getDetailedProfile({ did }), 104 getRecord({ 105 did, 106 collection: 'app.nearhorizon.actor.pronouns', 107 rkey: 'self' 108 }).catch(() => undefined) 109 ]); 110 111 const additionalData = await loadAdditionalData( 112 cards.map((v) => ({ ...v.value })) as Item[], 113 { did, handle, cache }, 114 env 115 ); 116 117 const result = { 118 page: 'blento.' + page, 119 handle, 120 did, 121 cards: (cards.map((v) => { 122 return { ...v.value }; 123 }) ?? []) as Item[], 124 publications: [mainPublication, ...pages].filter((v) => v), 125 additionalData, 126 profile, 127 pronouns: formatPronouns(pronounsRecord, profile), 128 pronounsRecord: pronounsRecord as PronounsRecord | undefined, 129 updatedAt: Date.now(), 130 version: CURRENT_CACHE_VERSION 131 }; 132 133 // Only cache results that have cards to avoid caching PDS errors 134 if (result.cards.length > 0) { 135 const stringifiedResult = JSON.stringify(result); 136 await cache?.putBlento(did, handle as string, stringifiedResult); 137 } 138 139 const parsedResult = structuredClone(result) as any; 140 141 parsedResult.publication = ( 142 parsedResult.publications as Awaited<ReturnType<typeof listRecords>> 143 ).find((v) => parseUri(v.uri)?.rkey === parsedResult.page)?.value; 144 parsedResult.publication ??= { 145 name: profile?.displayName || profile?.handle, 146 description: profile?.description 147 }; 148 149 delete parsedResult['publications']; 150 151 return checkData(parsedResult); 152} 153 154export async function loadCardData( 155 handle: ActorIdentifier, 156 rkey: string, 157 cache: CacheService | undefined, 158 env?: Record<string, string | undefined> 159): Promise<WebsiteData> { 160 if (!handle) throw error(404); 161 if (handle === 'favicon.ico') throw error(404); 162 163 let did: Did | undefined = undefined; 164 if (isHandle(handle)) { 165 did = await resolveHandle({ handle }); 166 } else if (isDid(handle)) { 167 did = handle; 168 } else { 169 throw error(404); 170 } 171 172 const [cardRecord, profile, pronounsRecord] = await Promise.all([ 173 getRecord({ 174 did, 175 collection: 'app.blento.card', 176 rkey 177 }).catch(() => undefined), 178 getDetailedProfile({ did }), 179 getRecord({ 180 did, 181 collection: 'app.nearhorizon.actor.pronouns', 182 rkey: 'self' 183 }).catch(() => undefined) 184 ]); 185 186 if (!cardRecord?.value) { 187 throw error(404, 'Card not found'); 188 } 189 190 const card = migrateCard(structuredClone(cardRecord.value) as Item); 191 const page = card.page ?? 'blento.self'; 192 193 const publication = await getRecord({ 194 did, 195 collection: page === 'blento.self' ? 'site.standard.publication' : 'app.blento.page', 196 rkey: page 197 }).catch(() => undefined); 198 199 const cards = [card]; 200 const resolvedHandle = profile?.handle || (isHandle(handle) ? handle : did); 201 202 const additionalData = await loadAdditionalData( 203 cards, 204 { did, handle: resolvedHandle, cache }, 205 env 206 ); 207 208 const result = { 209 page, 210 handle: resolvedHandle, 211 did, 212 cards, 213 publication: 214 publication?.value ?? 215 ({ 216 name: profile?.displayName || profile?.handle, 217 description: profile?.description 218 } as WebsiteData['publication']), 219 additionalData, 220 profile, 221 pronouns: formatPronouns(pronounsRecord, profile), 222 pronounsRecord: pronounsRecord as PronounsRecord | undefined, 223 updatedAt: Date.now(), 224 version: CURRENT_CACHE_VERSION 225 }; 226 227 return result; 228} 229 230export async function loadCardTypeData( 231 handle: ActorIdentifier, 232 type: string, 233 cardData: Record<string, unknown>, 234 cache: CacheService | undefined, 235 env?: Record<string, string | undefined> 236): Promise<WebsiteData> { 237 if (!handle) throw error(404); 238 if (handle === 'favicon.ico') throw error(404); 239 240 const cardDef = CardDefinitionsByType[type]; 241 if (!cardDef) { 242 throw error(404, 'Card type not found'); 243 } 244 245 let did: Did | undefined = undefined; 246 if (isHandle(handle)) { 247 did = await resolveHandle({ handle }); 248 } else if (isDid(handle)) { 249 did = handle; 250 } else { 251 throw error(404); 252 } 253 254 const [publication, profile, pronounsRecord] = await Promise.all([ 255 getRecord({ 256 did, 257 collection: 'site.standard.publication', 258 rkey: 'blento.self' 259 }).catch(() => undefined), 260 getDetailedProfile({ did }), 261 getRecord({ 262 did, 263 collection: 'app.nearhorizon.actor.pronouns', 264 rkey: 'self' 265 }).catch(() => undefined) 266 ]); 267 268 const card = createEmptyCard('blento.self'); 269 card.cardType = type; 270 271 cardDef.createNew?.(card); 272 card.cardData = { 273 ...card.cardData, 274 ...cardData 275 }; 276 277 const cards = [card]; 278 const resolvedHandle = profile?.handle || (isHandle(handle) ? handle : did); 279 280 const additionalData = await loadAdditionalData( 281 cards, 282 { did, handle: resolvedHandle, cache }, 283 env 284 ); 285 286 const result = { 287 page: 'blento.self', 288 handle: resolvedHandle, 289 did, 290 cards, 291 publication: 292 publication?.value ?? 293 ({ 294 name: profile?.displayName || profile?.handle, 295 description: profile?.description 296 } as WebsiteData['publication']), 297 additionalData, 298 profile, 299 pronouns: formatPronouns(pronounsRecord, profile), 300 pronounsRecord: pronounsRecord as PronounsRecord | undefined, 301 updatedAt: Date.now(), 302 version: CURRENT_CACHE_VERSION 303 }; 304 305 return checkData(result); 306} 307 308function migrateCard(card: Item): Item { 309 if (!card.version) { 310 card.x *= 2; 311 card.y *= 2; 312 card.h *= 2; 313 card.w *= 2; 314 card.mobileX *= 2; 315 card.mobileY *= 2; 316 card.mobileH *= 2; 317 card.mobileW *= 2; 318 card.version = 1; 319 } 320 321 if (!card.version || card.version < 2) { 322 card.page = 'blento.self'; 323 card.version = 2; 324 } 325 326 const cardDef = CardDefinitionsByType[card.cardType]; 327 cardDef?.migrate?.(card); 328 329 return card; 330} 331 332async function loadAdditionalData( 333 cards: Item[], 334 { did, handle, cache }: { did: Did; handle: string; cache?: CacheService }, 335 env?: Record<string, string | undefined> 336) { 337 const cardTypes = new Set(cards.map((v) => v.cardType ?? '') as string[]); 338 const cardTypesArray = Array.from(cardTypes); 339 const additionDataPromises: Record<string, Promise<unknown>> = {}; 340 341 for (const cardType of cardTypesArray) { 342 const cardDef = CardDefinitionsByType[cardType]; 343 const items = cards.filter((v) => cardType === v.cardType); 344 345 try { 346 if (cardDef?.loadDataServer) { 347 additionDataPromises[cardType] = cardDef.loadDataServer(items, { 348 did, 349 handle, 350 cache, 351 env 352 }); 353 } else if (cardDef?.loadData) { 354 additionDataPromises[cardType] = cardDef.loadData(items, { did, handle, cache }); 355 } 356 } catch { 357 console.error('error getting additional data for', cardType); 358 } 359 } 360 361 await Promise.all(Object.values(additionDataPromises)); 362 363 const additionalData: Record<string, unknown> = {}; 364 for (const [key, value] of Object.entries(additionDataPromises)) { 365 try { 366 additionalData[key] = await value; 367 } catch (error) { 368 console.log('error loading', key, error); 369 } 370 } 371 372 return additionalData; 373} 374 375function checkData(data: WebsiteData): WebsiteData { 376 data = migrateData(data); 377 378 const cards = data.cards.filter((v) => v.page === data.page); 379 380 if (cards.length > 0) { 381 fixAllCollisions(cards, false); 382 fixAllCollisions(cards, true); 383 384 compactItems(cards, false); 385 compactItems(cards, true); 386 } 387 388 data.cards = cards; 389 390 return data; 391} 392 393function migrateData(data: WebsiteData): WebsiteData { 394 for (const card of data.cards) { 395 migrateCard(card); 396 } 397 return data; 398}