your personal website on atproto - mirror
blento.app
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}