Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
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/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}