Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 721 lines 18 kB view raw
1import type { APIRoute } from "astro"; 2import satori from "satori"; 3import { Resvg } from "@resvg/resvg-js"; 4import { readFileSync } from "node:fs"; 5import { join } from "node:path"; 6 7export const prerender = false; 8 9function getPublicDir(): string { 10 if (import.meta.env.PROD) { 11 return join(process.cwd(), "dist", "client"); 12 } 13 return join(process.cwd(), "public"); 14} 15 16let fontsLoaded: { regular: ArrayBuffer; bold: ArrayBuffer } | null = null; 17 18function loadFonts() { 19 if (fontsLoaded) return fontsLoaded; 20 const publicDir = getPublicDir(); 21 fontsLoaded = { 22 regular: readFileSync(join(publicDir, "fonts", "Inter-Regular.ttf")) 23 .buffer as ArrayBuffer, 24 bold: readFileSync(join(publicDir, "fonts", "Inter-Bold.ttf")) 25 .buffer as ArrayBuffer, 26 }; 27 return fontsLoaded; 28} 29 30const API_URL = process.env.API_URL || "http://localhost:8081"; 31 32interface RecordData { 33 type: "annotation" | "highlight" | "bookmark" | "collection"; 34 author: string; 35 displayName: string; 36 avatarURL: string; 37 text: string; 38 quote: string; 39 source: string; 40 title: string; 41 icon: string; 42 description: string; 43 color: string; 44} 45 46async function fetchRecordData(uri: string): Promise<RecordData | null> { 47 try { 48 const res = await fetch( 49 `${API_URL}/api/annotation?uri=${encodeURIComponent(uri)}`, 50 ); 51 if (res.ok) { 52 const item = await res.json(); 53 const author = item.author || item.creator || {}; 54 const handle = author.handle || ""; 55 const displayName = author.displayName || handle || "someone"; 56 const avatarURL = author.avatar || ""; 57 const targetSource = item.target?.source || item.url || item.source || ""; 58 const domain = targetSource 59 ? (() => { 60 try { 61 return new URL(targetSource).hostname.replace(/^www\./, ""); 62 } catch { 63 return ""; 64 } 65 })() 66 : ""; 67 const selectorText = 68 item.target?.selector?.exact || item.selector?.exact || ""; 69 const bodyText = 70 extractBody(item.body) || item.bodyValue || item.text || ""; 71 const motivation = item.motivation || ""; 72 const targetTitle = item.target?.title || item.title || ""; 73 74 if ( 75 motivation === "highlighting" || 76 uri.includes("/at.margin.highlight/") 77 ) { 78 return { 79 type: "highlight", 80 author: handle ? `@${handle}` : "someone", 81 displayName, 82 avatarURL, 83 text: targetTitle, 84 quote: selectorText, 85 source: domain, 86 title: "", 87 icon: "", 88 description: "", 89 color: item.color || "", 90 }; 91 } 92 93 if (uri.includes("/at.margin.bookmark/")) { 94 return { 95 type: "bookmark", 96 author: handle ? `@${handle}` : "someone", 97 displayName, 98 avatarURL, 99 text: item.title || targetTitle || "Bookmark", 100 quote: item.description || bodyText || "", 101 source: domain, 102 title: "", 103 icon: "", 104 description: "", 105 color: "", 106 }; 107 } 108 109 return { 110 type: "annotation", 111 author: handle ? `@${handle}` : "someone", 112 displayName, 113 avatarURL, 114 text: bodyText, 115 quote: selectorText, 116 source: domain, 117 title: "", 118 icon: "", 119 description: "", 120 color: "", 121 }; 122 } 123 } catch { 124 /* fall through */ 125 } 126 127 try { 128 const res = await fetch( 129 `${API_URL}/api/collection?uri=${encodeURIComponent(uri)}`, 130 ); 131 if (res.ok) { 132 const item = await res.json(); 133 const author = item.author || item.creator || {}; 134 const handle = author.handle || ""; 135 const displayName = author.displayName || handle || "someone"; 136 const avatarURL = author.avatar || ""; 137 138 return { 139 type: "collection", 140 author: handle ? `@${handle}` : "someone", 141 displayName, 142 avatarURL, 143 text: "", 144 quote: "", 145 source: "", 146 title: item.name || "Collection", 147 icon: item.icon || "📁", 148 description: item.description || "", 149 color: "", 150 }; 151 } 152 } catch { 153 /* fall through */ 154 } 155 156 return null; 157} 158 159function truncate(str: string, max: number): string { 160 if (str.length <= max) return str; 161 return str.slice(0, max - 3) + "..."; 162} 163 164function extractBody(body: unknown): string { 165 if (!body) return ""; 166 if (typeof body === "string") return body; 167 if (typeof body === "object" && body !== null && "value" in body) { 168 return String((body as { value: unknown }).value || ""); 169 } 170 return ""; 171} 172 173const C = { 174 bg: "#f4f4f5", 175 text: "#18181b", 176 textSecondary: "#52525b", 177 textMuted: "#a1a1aa", 178 textFaint: "#d4d4d8", 179 primary: "#3b82f6", 180 primaryDark: "#2563eb", 181 border: "#e4e4e7", 182}; 183 184const namedColors: Record<string, string> = { 185 yellow: "#facc15", 186 green: "#4ade80", 187 red: "#f87171", 188 blue: "#60a5fa", 189}; 190 191function resolveHighlightColor(color: string): string { 192 if (!color) return "#facc15"; 193 if (color.startsWith("#")) return color; 194 return namedColors[color] || "#facc15"; 195} 196 197const typeColors: Record<string, string> = { 198 annotation: "#3b82f6", 199 highlight: "#facc15", 200 bookmark: "#22c55e", 201 collection: "#3b82f6", 202}; 203 204function lightTint(hex: string): string { 205 const r = parseInt(hex.slice(1, 3), 16); 206 const g = parseInt(hex.slice(3, 5), 16); 207 const b = parseInt(hex.slice(5, 7), 16); 208 const mix = (c: number) => Math.round(c * 0.12 + 255 * 0.88); 209 return `rgb(${mix(r)}, ${mix(g)}, ${mix(b)})`; 210} 211 212function avatarElement(url: string, name: string, size: number): unknown { 213 if (url) { 214 return { 215 type: "img", 216 props: { 217 src: url, 218 width: size, 219 height: size, 220 style: { borderRadius: size / 2, flexShrink: 0 }, 221 }, 222 }; 223 } 224 const letter = 225 name[0] === "@" 226 ? name[1]?.toUpperCase() || "?" 227 : name[0]?.toUpperCase() || "?"; 228 return { 229 type: "div", 230 props: { 231 style: { 232 width: size, 233 height: size, 234 borderRadius: size / 2, 235 background: "#e4e4e7", 236 display: "flex", 237 alignItems: "center", 238 justifyContent: "center", 239 color: "#71717a", 240 fontSize: Math.round(size * 0.45), 241 fontWeight: 700, 242 flexShrink: 0, 243 }, 244 children: letter, 245 }, 246 }; 247} 248 249function coloredLogoUri(color: string): string { 250 const publicDir = getPublicDir(); 251 const svg = readFileSync(join(publicDir, "logo.svg"), "utf-8"); 252 const recolored = svg.replace(/fill="[^"]*"/, `fill="${color}"`); 253 return `data:image/svg+xml,${encodeURIComponent(recolored)}`; 254} 255 256function logoElement(size: number, color: string): unknown { 257 return { 258 type: "img", 259 props: { 260 src: coloredLogoUri(color), 261 width: size, 262 height: size, 263 style: { flexShrink: 0 }, 264 }, 265 }; 266} 267 268const typeLabels: Record<string, string> = { 269 annotation: "Annotation", 270 highlight: "Highlight", 271 bookmark: "Bookmark", 272 collection: "Collection", 273}; 274 275function headerWithBadge(data: RecordData, accentColor: string): unknown { 276 return { 277 type: "div", 278 props: { 279 style: { display: "flex", alignItems: "center" }, 280 children: [ 281 avatarElement(data.avatarURL, data.displayName, 48), 282 { 283 type: "div", 284 props: { 285 style: { 286 display: "flex", 287 flexDirection: "column", 288 marginLeft: 14, 289 flex: 1, 290 }, 291 children: [ 292 { 293 type: "span", 294 props: { 295 style: { 296 color: C.text, 297 fontSize: 22, 298 fontWeight: 600, 299 }, 300 children: data.displayName, 301 }, 302 }, 303 { 304 type: "span", 305 props: { 306 style: { 307 color: C.textMuted, 308 fontSize: 17, 309 marginTop: 1, 310 }, 311 children: data.author, 312 }, 313 }, 314 ], 315 }, 316 }, 317 { 318 type: "div", 319 props: { 320 style: { 321 display: "flex", 322 alignItems: "center", 323 gap: 10, 324 }, 325 children: [ 326 logoElement(24, accentColor), 327 { 328 type: "span", 329 props: { 330 style: { 331 color: C.textFaint, 332 fontSize: 18, 333 }, 334 children: "|", 335 }, 336 }, 337 { 338 type: "span", 339 props: { 340 style: { 341 color: accentColor, 342 fontSize: 16, 343 fontWeight: 600, 344 textTransform: "uppercase" as const, 345 letterSpacing: 1, 346 }, 347 children: typeLabels[data.type] || data.type, 348 }, 349 }, 350 ], 351 }, 352 }, 353 ], 354 }, 355 }; 356} 357 358function footerSource(source?: string): unknown | null { 359 if (!source) return null; 360 return { 361 type: "div", 362 props: { 363 style: { 364 display: "flex", 365 alignItems: "center", 366 marginTop: "auto", 367 paddingTop: 16, 368 }, 369 children: [ 370 { 371 type: "span", 372 props: { 373 style: { color: C.textMuted, fontSize: 16 }, 374 children: source, 375 }, 376 }, 377 ], 378 }, 379 }; 380} 381 382function wrap(children: unknown[], bg?: string): unknown { 383 return { 384 type: "div", 385 props: { 386 style: { 387 display: "flex", 388 flexDirection: "column", 389 width: "100%", 390 height: "100%", 391 background: bg || C.bg, 392 padding: "48px 64px", 393 fontFamily: "Inter", 394 }, 395 children, 396 }, 397 }; 398} 399 400function buildAnnotationImage(data: RecordData) { 401 const accent = typeColors.annotation; 402 const children: unknown[] = [headerWithBadge(data, accent)]; 403 404 if (data.text) { 405 const len = data.text.length; 406 children.push({ 407 type: "div", 408 props: { 409 style: { 410 color: C.text, 411 fontSize: len > 200 ? 26 : len > 100 ? 30 : 36, 412 fontWeight: 500, 413 lineHeight: 1.45, 414 marginTop: 32, 415 overflow: "hidden", 416 }, 417 children: truncate(data.text, 280), 418 }, 419 }); 420 } 421 422 if (data.quote) { 423 children.push({ 424 type: "div", 425 props: { 426 style: { display: "flex", marginTop: 24 }, 427 children: [ 428 { 429 type: "div", 430 props: { 431 style: { 432 width: 4, 433 borderRadius: 2, 434 background: accent, 435 flexShrink: 0, 436 }, 437 }, 438 }, 439 { 440 type: "div", 441 props: { 442 style: { 443 color: C.textSecondary, 444 fontSize: 20, 445 lineHeight: 1.6, 446 paddingLeft: 20, 447 fontStyle: "italic", 448 overflow: "hidden", 449 }, 450 children: truncate(data.quote, 200), 451 }, 452 }, 453 ], 454 }, 455 }); 456 } 457 458 const footer = footerSource(data.source); 459 if (footer) children.push(footer); 460 461 return wrap(children, lightTint(accent)); 462} 463 464function buildHighlightImage(data: RecordData) { 465 const highlightColor = resolveHighlightColor(data.color); 466 const bgTint = lightTint(highlightColor); 467 const quoteText = data.quote || data.text || "Highlighted passage"; 468 const len = quoteText.length; 469 470 return { 471 type: "div", 472 props: { 473 style: { 474 display: "flex", 475 flexDirection: "column", 476 width: "100%", 477 height: "100%", 478 background: bgTint, 479 padding: "48px 64px", 480 fontFamily: "Inter", 481 }, 482 children: [ 483 headerWithBadge(data, highlightColor), 484 { 485 type: "div", 486 props: { 487 style: { 488 color: highlightColor, 489 fontSize: 120, 490 fontWeight: 700, 491 lineHeight: 1, 492 marginTop: 28, 493 }, 494 children: "\u201C", 495 }, 496 }, 497 { 498 type: "div", 499 props: { 500 style: { 501 color: C.text, 502 fontSize: len > 150 ? 28 : len > 80 ? 34 : 42, 503 fontWeight: 600, 504 lineHeight: 1.4, 505 marginTop: -30, 506 overflow: "hidden", 507 }, 508 children: truncate(quoteText, 240), 509 }, 510 }, 511 data.text && data.quote 512 ? { 513 type: "div", 514 props: { 515 style: { 516 color: C.textSecondary, 517 fontSize: 20, 518 marginTop: 20, 519 }, 520 children: truncate(data.text, 80), 521 }, 522 } 523 : null, 524 { 525 type: "div", 526 props: { 527 style: { 528 display: "flex", 529 alignItems: "center", 530 marginTop: "auto", 531 paddingTop: 16, 532 }, 533 children: [ 534 data.source 535 ? { 536 type: "span", 537 props: { 538 style: { color: C.textMuted, fontSize: 16 }, 539 children: data.source, 540 }, 541 } 542 : null, 543 ].filter(Boolean), 544 }, 545 }, 546 ].filter(Boolean), 547 }, 548 }; 549} 550 551function buildBookmarkImage(data: RecordData) { 552 const children: unknown[] = [headerWithBadge(data, typeColors.bookmark)]; 553 554 if (data.source) { 555 children.push({ 556 type: "div", 557 props: { 558 style: { 559 color: typeColors.bookmark, 560 fontSize: 18, 561 marginTop: 32, 562 }, 563 children: data.source, 564 }, 565 }); 566 } 567 568 const titleLen = (data.text || "").length; 569 children.push({ 570 type: "div", 571 props: { 572 style: { 573 color: C.text, 574 fontSize: titleLen > 60 ? 34 : 42, 575 fontWeight: 700, 576 lineHeight: 1.25, 577 marginTop: data.source ? 10 : 32, 578 overflow: "hidden", 579 }, 580 children: truncate(data.text || "Untitled Bookmark", 90), 581 }, 582 }); 583 584 if (data.quote) { 585 children.push({ 586 type: "div", 587 props: { 588 style: { 589 color: C.textSecondary, 590 fontSize: 22, 591 lineHeight: 1.5, 592 marginTop: 16, 593 overflow: "hidden", 594 }, 595 children: truncate(data.quote, 180), 596 }, 597 }); 598 } 599 600 return wrap(children, lightTint(typeColors.bookmark)); 601} 602 603function buildCollectionImage(data: RecordData) { 604 const children: unknown[] = [headerWithBadge(data, typeColors.collection)]; 605 606 children.push({ 607 type: "div", 608 props: { 609 style: { 610 display: "flex", 611 alignItems: "center", 612 gap: 20, 613 marginTop: 36, 614 }, 615 children: [ 616 { 617 type: "span", 618 props: { style: { fontSize: 52 }, children: data.icon }, 619 }, 620 { 621 type: "span", 622 props: { 623 style: { 624 color: C.text, 625 fontSize: 44, 626 fontWeight: 700, 627 overflow: "hidden", 628 }, 629 children: truncate(data.title, 36), 630 }, 631 }, 632 ], 633 }, 634 }); 635 636 children.push({ 637 type: "div", 638 props: { 639 style: { 640 color: data.description ? C.textSecondary : C.textMuted, 641 fontSize: 24, 642 lineHeight: 1.5, 643 marginTop: 20, 644 overflow: "hidden", 645 }, 646 children: data.description 647 ? truncate(data.description, 180) 648 : "A collection on Margin", 649 }, 650 }); 651 652 return wrap(children, lightTint(typeColors.collection)); 653} 654 655export const GET: APIRoute = async ({ url }) => { 656 const uri = url.searchParams.get("uri"); 657 if (!uri) { 658 return new Response("uri parameter required", { status: 400 }); 659 } 660 661 const data = await fetchRecordData(uri); 662 if (!data) { 663 return new Response("Record not found", { status: 404 }); 664 } 665 666 const fonts = loadFonts(); 667 668 let element: unknown; 669 switch (data.type) { 670 case "collection": 671 element = buildCollectionImage(data); 672 break; 673 case "bookmark": 674 element = buildBookmarkImage(data); 675 break; 676 case "highlight": 677 element = buildHighlightImage(data); 678 break; 679 case "annotation": 680 default: 681 element = buildAnnotationImage(data); 682 break; 683 } 684 685 const svg = await satori(element as React.ReactNode, { 686 width: 1200, 687 height: 630, 688 fonts: [ 689 { name: "Inter", data: fonts.regular, weight: 400, style: "normal" }, 690 { name: "Inter", data: fonts.bold, weight: 700, style: "normal" }, 691 ], 692 loadAdditionalAsset: async (code: string, segment: string) => { 693 if (code === "emoji") { 694 const codepoints = [...segment] 695 .map((c) => c.codePointAt(0)!.toString(16)) 696 .join("-"); 697 const emojiUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codepoints}.svg`; 698 try { 699 const res = await fetch(emojiUrl); 700 if (res.ok) 701 return `data:image/svg+xml,${encodeURIComponent(await res.text())}`; 702 } catch { 703 // ignore 704 } 705 } 706 return ""; 707 }, 708 }); 709 710 const resvg = new Resvg(svg, { 711 fitTo: { mode: "width", value: 1200 }, 712 }); 713 const png = resvg.render().asPng(); 714 715 return new Response(new Uint8Array(png), { 716 headers: { 717 "Content-Type": "image/png", 718 "Cache-Control": "public, max-age=86400", 719 }, 720 }); 721};