Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1const API_URL = process.env.API_URL || "http://localhost:8081";
2
3const CRAWLER_AGENTS = [
4 "facebookexternalhit",
5 "facebot",
6 "twitterbot",
7 "linkedinbot",
8 "whatsapp",
9 "slackbot",
10 "telegrambot",
11 "discordbot",
12 "applebot",
13 "bot",
14 "crawler",
15 "spider",
16 "preview",
17 "cardyb",
18 "bluesky",
19];
20
21export function isCrawler(userAgent: string): boolean {
22 const ua = userAgent.toLowerCase();
23 return CRAWLER_AGENTS.some((bot) => ua.includes(bot));
24}
25
26export interface OGData {
27 title: string;
28 description: string;
29 image: string;
30 author: string;
31 pageURL: string;
32}
33
34interface APIAnnotation {
35 id?: string;
36 uri?: string;
37 author?: { did: string; handle?: string };
38 creator?: { did: string; handle?: string };
39 target?: { source?: string; title?: string; selector?: { exact?: string } };
40 body?: string;
41 bodyValue?: string;
42 text?: string;
43 motivation?: string;
44 title?: string;
45 description?: string;
46 url?: string;
47 source?: string;
48 selector?: { exact?: string };
49 selectorJson?: string;
50 color?: string;
51}
52
53interface APICollection {
54 id?: string;
55 uri?: string;
56 name: string;
57 description?: string;
58 icon?: string;
59 author?: { did: string; handle?: string };
60 creator?: { did: string; handle?: string };
61}
62
63export async function resolveHandle(handle: string): Promise<string | null> {
64 if (handle.startsWith("did:")) return handle;
65 try {
66 const res = await fetch(
67 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
68 );
69 if (!res.ok) return null;
70 const data = await res.json();
71 return data.did || null;
72 } catch {
73 return null;
74 }
75}
76
77async function fetchJSON(path: string): Promise<unknown> {
78 const res = await fetch(`${API_URL}${path}`);
79 if (!res.ok) return null;
80 return res.json();
81}
82
83function getAuthorHandle(item: APIAnnotation | APICollection): string {
84 const author = item.author || item.creator;
85 if (author?.handle) return `@${author.handle}`;
86 if (author?.did) return author.did;
87 return "someone";
88}
89
90function extractDomain(urlStr: string): string {
91 try {
92 return new URL(urlStr).host;
93 } catch {
94 return "";
95 }
96}
97
98function truncate(str: string, max: number): string {
99 if (str.length <= max) return str;
100 return str.slice(0, max - 3) + "...";
101}
102
103function extractBody(body: unknown): string {
104 if (!body) return "";
105 if (typeof body === "string") return body;
106 if (typeof body === "object" && body !== null && "value" in body) {
107 return String((body as { value: unknown }).value || "");
108 }
109 return "";
110}
111
112const BASE_URL = process.env.BASE_URL || "https://margin.at";
113
114export async function fetchAnnotationOG(uri: string): Promise<OGData | null> {
115 const item = (await fetchJSON(
116 `/api/annotation?uri=${encodeURIComponent(uri)}`,
117 )) as APIAnnotation | null;
118 if (!item) return null;
119
120 const itemURI = item.id || item.uri || uri;
121 const author = getAuthorHandle(item);
122 const source = item.target?.source || item.url || item.source || "";
123 const domain = extractDomain(source);
124 const selectorText =
125 item.target?.selector?.exact || item.selector?.exact || "";
126
127 let title = "Annotation on Margin";
128 const targetTitle = item.target?.title || item.title;
129 if (targetTitle) title = truncate(`Comment on: ${targetTitle}`, 60);
130
131 let description = extractBody(item.body) || item.bodyValue || item.text || "";
132 if (selectorText && description) {
133 description = `"${truncate(selectorText, 100)}"\n\n${description}`;
134 } else if (selectorText) {
135 description = `Highlighted: "${truncate(selectorText, 150)}"`;
136 }
137 if (!description) {
138 description = `An annotation by ${author}`;
139 if (domain) description += ` on ${domain}`;
140 }
141 description = truncate(description, 200);
142
143 return {
144 title,
145 description,
146 image: `${BASE_URL}/og-image?uri=${encodeURIComponent(itemURI)}`,
147 author,
148 pageURL: `${BASE_URL}/at/${encodeURIComponent(itemURI.slice(5))}`,
149 };
150}
151
152export async function fetchHighlightOG(uri: string): Promise<OGData | null> {
153 const item = (await fetchJSON(
154 `/api/annotation?uri=${encodeURIComponent(uri)}`,
155 )) as APIAnnotation | null;
156 if (!item) return null;
157
158 const itemURI = item.id || item.uri || uri;
159 const author = getAuthorHandle(item);
160 const source = item.target?.source || item.url || item.source || "";
161 const domain = extractDomain(source);
162 const selectorText =
163 item.target?.selector?.exact || item.selector?.exact || "";
164
165 let title = "Highlight on Margin";
166 const targetTitle = item.target?.title || item.title;
167 if (targetTitle) title = truncate(`Highlight on: ${targetTitle}`, 60);
168
169 let description = "";
170 if (selectorText) {
171 description = `"${truncate(selectorText, 180)}"`;
172 }
173 if (!description) {
174 description = `A highlight by ${author}`;
175 if (domain) description += ` on ${domain}`;
176 }
177
178 return {
179 title,
180 description,
181 image: `${BASE_URL}/og-image?uri=${encodeURIComponent(itemURI)}`,
182 author,
183 pageURL: `${BASE_URL}/at/${encodeURIComponent(itemURI.slice(5))}`,
184 };
185}
186
187export async function fetchBookmarkOG(uri: string): Promise<OGData | null> {
188 const item = (await fetchJSON(
189 `/api/annotation?uri=${encodeURIComponent(uri)}`,
190 )) as APIAnnotation | null;
191 if (!item) return null;
192
193 const itemURI = item.id || item.uri || uri;
194 const author = getAuthorHandle(item);
195 const source = item.target?.source || item.url || item.source || "";
196 const domain = extractDomain(source);
197
198 const title = item.title || item.target?.title || "Bookmark on Margin";
199 let description =
200 item.description || extractBody(item.body) || item.bodyValue || "";
201 if (!description) description = "A saved bookmark on Margin";
202 if (domain) description += ` from ${domain}`;
203 description = truncate(description, 200);
204
205 return {
206 title,
207 description,
208 image: `${BASE_URL}/og-image?uri=${encodeURIComponent(itemURI)}`,
209 author,
210 pageURL: `${BASE_URL}/at/${encodeURIComponent(itemURI.slice(5))}`,
211 };
212}
213
214export async function fetchCollectionOG(uri: string): Promise<OGData | null> {
215 const item = (await fetchJSON(
216 `/api/collection?uri=${encodeURIComponent(uri)}`,
217 )) as APICollection | null;
218 if (!item) return null;
219
220 const itemURI = item.id || item.uri || uri;
221 const author = getAuthorHandle(item);
222 const icon = item.icon || "📁";
223 const title = `${icon} ${item.name}`;
224
225 let description;
226 if (item.description) {
227 description = `By ${author} · ${truncate(item.description, 170)}`;
228 } else {
229 description = `A collection by ${author}`;
230 }
231
232 return {
233 title,
234 description,
235 image: `${BASE_URL}/og-image?uri=${encodeURIComponent(itemURI)}`,
236 author,
237 pageURL: `${BASE_URL}/collection/${encodeURIComponent(itemURI)}`,
238 };
239}
240
241export async function fetchOGByURI(uri: string): Promise<OGData | null> {
242 if (uri.includes("/at.margin.annotation/")) return fetchAnnotationOG(uri);
243 if (uri.includes("/at.margin.highlight/")) return fetchHighlightOG(uri);
244 if (uri.includes("/at.margin.bookmark/")) return fetchBookmarkOG(uri);
245 if (uri.includes("/at.margin.collection/")) return fetchCollectionOG(uri);
246
247 return fetchAnnotationOG(uri);
248}
249
250export async function fetchOGForRoute(
251 did: string,
252 rkey: string,
253 collectionType?: string,
254): Promise<OGData | null> {
255 if (collectionType) {
256 const uri = `at://${did}/${collectionType}/${rkey}`;
257 return fetchOGByURI(uri);
258 }
259
260 for (const type of [
261 "at.margin.annotation",
262 "at.margin.highlight",
263 "at.margin.bookmark",
264 ]) {
265 const uri = `at://${did}/${type}/${rkey}`;
266 const data = await fetchOGByURI(uri);
267 if (data) return data;
268 }
269
270 const colUri = `at://${did}/at.margin.collection/${rkey}`;
271 return fetchCollectionOG(colUri);
272}