data endpoint for entity 90008 (aka. a website)
1import { get, writable } from 'svelte/store';
2import { parseFeed } from '@rowanmanning/feed-parser';
3
4export const ACTIVITY_PREVIEW_LIMIT = 7;
5
6const lastCommits = writable<Activity[]>([]);
7
8export const updateCommits = async () => {
9 try {
10 const githubFeed = await parseFeedToActivity('https://github.com/90-008.atom');
11 const codebergFeed = await parseFeedToActivity('https://codeberg.org/90-008.atom');
12 const tangledFeed = await fetchTangledActivity();
13 const mergedFeed = sortActivities(githubFeed.concat(codebergFeed).concat(tangledFeed));
14 lastCommits.set(mergedFeed);
15 } catch (why) {
16 console.log('could not fetch git activity: ', why);
17 }
18};
19
20export const getLastActivity = () => {
21 return get(lastCommits).slice(0, ACTIVITY_PREVIEW_LIMIT);
22};
23
24export const getCurrentActivity = () => {
25 return get(lastCommits);
26};
27
28export const activityToJson = (activity: Activity): ActivityJson => {
29 return {
30 ...activity,
31 date: activity.date?.toISOString() ?? null
32 };
33};
34
35export const currentActivityToJson = (activities: Activity[]) => {
36 return activities.map(activityToJson);
37};
38
39export type Activity = {
40 source: string;
41 description: string;
42 link: string | null;
43 date: Date | null;
44 id?: string;
45};
46
47export type ActivityJson = Omit<Activity, 'date'> & {
48 date: string | null;
49};
50
51const toHex = (bytes: number[]): string => {
52 return bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
53};
54
55const fetchTangledActivity = async (): Promise<Activity[]> => {
56 // todo: auto resolve pds and knots
57 const did = 'did:plc:dfl62fgb7wtjj3fcbb72naae';
58 const pds = 'https://zwsp.xyz';
59 const knot = 'https://knot.gaze.systems';
60 const activities: Activity[] = [];
61
62 try {
63 // todo: fetch until we exhaust
64 const listRes = await fetch(
65 `${pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=sh.tangled.repo`
66 );
67 if (!listRes.ok) return [];
68 const listData = await listRes.json();
69
70 for (const record of listData.records || []) {
71 const repoName = record.value.name;
72 if (!repoName) continue;
73
74 try {
75 const logRes = await fetch(`${knot}/xrpc/sh.tangled.repo.log?repo=${did}/${repoName}`);
76 if (!logRes.ok) continue;
77 const logData = await logRes.json();
78
79 const commits = logData.commits || [];
80
81 for (const commit of commits) {
82 if (!commit.hash) continue;
83
84 const hash = commit.hash ? toHex(commit.hash) : '';
85 if (activities.some((a) => a.id === hash)) continue;
86
87 const message = commit.message || '';
88 const dateStr = commit.author?.When;
89
90 activities.push({
91 source: 'tangled',
92 description: `${repoName}: ${message}`,
93 link: `https://tangled.sh/${did}/${repoName}/commit/${hash}`,
94 date: dateStr ? new Date(dateStr) : null,
95 id: hash
96 });
97 }
98 } catch (err) {
99 console.log(`could not fetch tangled log for ${repoName}:`, err);
100 }
101 }
102 } catch (err) {
103 console.log('could not fetch tangled repos:', err);
104 }
105 return activities;
106};
107
108const parseFeedToActivity = async (url: string) => {
109 const response = await fetch(url);
110 const feed = parseFeed(await response.text());
111
112 const source = new URL(url).host.split('.')[0];
113 const results: Activity[] = [];
114 for (const item of feed.items) {
115 const description: string | null = item.description || item.title;
116 if (description === null) continue;
117 // dont count mirrored repos
118 // TODO: probably can implement a deduplication algorithm
119 if (
120 source === 'github' &&
121 [
122 '90-008/ark',
123 '90-008/website',
124 'ark',
125 'website',
126 'trill',
127 'faunu',
128 'nucleus',
129 'hydrant'
130 ].some((repo) => description.includes(repo))
131 )
132 continue;
133 // dont show activity that is just chore
134 if (item.content?.includes('chore')) continue;
135
136 let repoName = '';
137 let message = '';
138 let link = item.url;
139
140 if (source === 'github') {
141 // try to extract repo from url
142 // url format: https://github.com/user/repo/...
143 try {
144 const url = new URL(item.url || '');
145 const parts = url.pathname.split('/').filter(Boolean);
146 if (parts.length >= 2) {
147 repoName = parts[1]; // just the repo name, e.g. "endpoint"
148 }
149 } catch {
150 /* empty */
151 }
152
153 // try to extract commit message from content blockquote
154 if (item.content) {
155 const match = item.content.match(/<blockquote>(.*?)<\/blockquote>/s);
156 if (match && match[1]) {
157 message = match[1].trim();
158 }
159 }
160 }
161
162 // fallback or original logic for non-github or failed parsing
163 if (!message || !repoName) {
164 const desc = description.split('</a>').at(1) || description.split('</a>').pop() || '';
165 if (!message) message = desc.replace(/^90-008 /, '');
166 // If we couldn't get a clean repo name, we might leave it empty or try to parse from description if needed
167 // But for now let's stick to what we found or the original description cleanup
168 }
169
170 results.push({
171 source,
172 description: repoName && message ? `${repoName}: ${message}` : message,
173 link,
174 date: item.published || item.updated
175 });
176 }
177
178 return results;
179};
180
181const sortActivities = (activities: Array<Activity>) => {
182 return activities.sort((a, b) => {
183 if (a.date === null && b.date === null) return 0;
184 if (a.date === null) return 1;
185 if (b.date === null) return -1;
186 return b.date.getTime() - a.date.getTime();
187 });
188};