(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 272 lines 7.8 kB view raw
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/note?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/note?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/note?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}