Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

better og images

+340 -312
backend/internal/api/fonts/DroidSansFallback.ttf

This is a binary file and will not be displayed.

backend/internal/api/fonts/Inter-Bold.ttf

This is a binary file and will not be displayed.

backend/internal/api/fonts/Inter-Regular.ttf

This is a binary file and will not be displayed.

+2 -2
web/public/logo.svg
··· 1 <svg width="265" height="231" viewBox="0 0 265 231" fill="#027bff" xmlns="http://www.w3.org/2000/svg"> 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 - <path d="M215 214.224 V230 H264.5 V0 H215.07 V16.2242 H248.5 V214.224 H215 Z"/> 4 - </svg>
··· 1 <svg width="265" height="231" viewBox="0 0 265 231" fill="#027bff" xmlns="http://www.w3.org/2000/svg"> 2 <path d="M0 230 V0 H199 V65.7156 H149.5 V115.216 H182.5 L199 131.716 V230 Z"/> 3 + <path d="M215 214.224 V230 H264.5 V0 H215 V16.2242 H248.5 V214.224 H215 Z"/> 4 + </svg>
+338 -310
web/src/pages/og-image.ts
··· 27 return fontsLoaded; 28 } 29 30 - let logoDataURI: string | null = null; 31 - 32 - function getLogoDataURI(): string { 33 - if (logoDataURI) return logoDataURI; 34 - try { 35 - const publicDir = getPublicDir(); 36 - const buf = readFileSync(join(publicDir, "logo.svg")); 37 - logoDataURI = `data:image/svg+xml;base64,${buf.toString("base64")}`; 38 - } catch { 39 - logoDataURI = ""; 40 - } 41 - return logoDataURI; 42 - } 43 - 44 const API_URL = process.env.API_URL || "http://localhost:8081"; 45 46 interface RecordData { 47 type: "annotation" | "highlight" | "bookmark" | "collection"; 48 author: string; 49 avatarURL: string; 50 text: string; 51 quote: string; ··· 53 title: string; 54 icon: string; 55 description: string; 56 } 57 58 async function fetchRecordData(uri: string): Promise<RecordData | null> { ··· 63 if (res.ok) { 64 const item = await res.json(); 65 const author = item.author || item.creator || {}; 66 - const handle = author.handle 67 - ? `@${author.handle}` 68 - : author.did || "someone"; 69 const avatarURL = author.avatar || ""; 70 const targetSource = item.target?.source || item.url || item.source || ""; 71 const domain = targetSource 72 ? (() => { 73 try { 74 - return new URL(targetSource).host; 75 } catch { 76 return ""; 77 } ··· 90 ) { 91 return { 92 type: "highlight", 93 - author: handle, 94 avatarURL, 95 text: targetTitle, 96 quote: selectorText, ··· 98 title: "", 99 icon: "", 100 description: "", 101 }; 102 } 103 104 if (uri.includes("/at.margin.bookmark/")) { 105 return { 106 type: "bookmark", 107 - author: handle, 108 avatarURL, 109 text: item.title || targetTitle || "Bookmark", 110 quote: item.description || bodyText || "", ··· 112 title: "", 113 icon: "", 114 description: "", 115 }; 116 } 117 118 return { 119 type: "annotation", 120 - author: handle, 121 avatarURL, 122 text: bodyText, 123 quote: selectorText, ··· 125 title: "", 126 icon: "", 127 description: "", 128 }; 129 } 130 } catch { ··· 138 if (res.ok) { 139 const item = await res.json(); 140 const author = item.author || item.creator || {}; 141 - const handle = author.handle 142 - ? `@${author.handle}` 143 - : author.did || "someone"; 144 const avatarURL = author.avatar || ""; 145 146 return { 147 type: "collection", 148 - author: handle, 149 avatarURL, 150 text: "", 151 quote: "", ··· 153 title: item.name || "Collection", 154 icon: item.icon || "📁", 155 description: item.description || "", 156 }; 157 } 158 } catch { ··· 176 return ""; 177 } 178 179 - function avatarElement(url: string, author: string, size: number): unknown { 180 if (url) { 181 return { 182 type: "img", ··· 184 src: url, 185 width: size, 186 height: size, 187 - style: { borderRadius: size / 2 }, 188 }, 189 }; 190 } 191 const letter = 192 - author[0] === "@" 193 - ? author[1]?.toUpperCase() || "?" 194 - : author[0]?.toUpperCase() || "?"; 195 return { 196 type: "div", 197 props: { ··· 199 width: size, 200 height: size, 201 borderRadius: size / 2, 202 - background: "#3b82f6", 203 display: "flex", 204 alignItems: "center", 205 justifyContent: "center", 206 - color: "white", 207 fontSize: Math.round(size * 0.45), 208 fontWeight: 700, 209 }, 210 children: letter, 211 }, 212 }; 213 } 214 215 - const typeColors: Record< 216 - string, 217 - { accent: string; badge: string; badgeText: string; bar: string } 218 - > = { 219 - annotation: { 220 - accent: "#3b82f6", 221 - badge: "#1e3a8a", 222 - badgeText: "#60a5fa", 223 - bar: "#60a5fa", 224 - }, 225 - highlight: { 226 - accent: "#eab308", 227 - badge: "#422006", 228 - badgeText: "#facc15", 229 - bar: "#facc15", 230 - }, 231 - bookmark: { 232 - accent: "#22c55e", 233 - badge: "#052e16", 234 - badgeText: "#4ade80", 235 - bar: "#4ade80", 236 - }, 237 - }; 238 - 239 - function getTypeColor(type: string) { 240 - return typeColors[type] || typeColors.annotation; 241 } 242 243 - function typeBadge(type: string): unknown { 244 - const labels: Record<string, string> = { 245 - annotation: "Annotation", 246 - highlight: "Highlight", 247 - bookmark: "Bookmark", 248 - }; 249 - const c = getTypeColor(type); 250 return { 251 - type: "div", 252 props: { 253 - style: { 254 - padding: "6px 16px", 255 - borderRadius: 99, 256 - background: c.badge, 257 - color: c.badgeText, 258 - fontSize: 16, 259 - fontWeight: 600, 260 - }, 261 - children: labels[type] || type, 262 }, 263 }; 264 } 265 266 - function marginBrand(logo: string): unknown { 267 - if (!logo) return null; 268 return { 269 type: "div", 270 props: { 271 - style: { 272 - display: "flex", 273 - alignItems: "center", 274 - marginLeft: "auto", 275 - }, 276 children: [ 277 { 278 - type: "img", 279 - props: { src: logo, width: 28, height: 24 }, 280 }, 281 ], 282 }, 283 }; 284 } 285 286 - function buildAnnotationImage(data: RecordData, logo: string) { 287 - const children: unknown[] = []; 288 - const tc = getTypeColor(data.type); 289 - 290 - children.push({ 291 type: "div", 292 props: { 293 - style: { display: "flex", alignItems: "center", width: "100%" }, 294 children: [ 295 - avatarElement(data.avatarURL, data.author, 48), 296 { 297 type: "span", 298 props: { 299 - style: { color: "#a1a1aa", fontSize: 22, marginLeft: 14 }, 300 - children: data.author, 301 - }, 302 - }, 303 - { 304 - type: "div", 305 - props: { 306 - style: { marginLeft: "auto", display: "flex" }, 307 - children: [typeBadge(data.type)], 308 }, 309 }, 310 ], 311 }, 312 - }); 313 314 if (data.text) { 315 children.push({ 316 type: "div", 317 props: { 318 style: { 319 - color: "#fafafa", 320 - fontSize: data.text.length > 200 ? 26 : 32, 321 lineHeight: 1.45, 322 marginTop: 32, 323 overflow: "hidden", 324 }, 325 - children: truncate(data.text, 300), 326 }, 327 }); 328 } ··· 339 style: { 340 width: 4, 341 borderRadius: 2, 342 - background: tc.bar, 343 flexShrink: 0, 344 }, 345 }, ··· 348 type: "div", 349 props: { 350 style: { 351 - color: "#a1a1aa", 352 - fontSize: data.quote.length > 150 ? 22 : 26, 353 - lineHeight: 1.5, 354 - paddingLeft: 18, 355 fontStyle: "italic", 356 overflow: "hidden", 357 }, 358 - children: `"${truncate(data.quote, 250)}"`, 359 }, 360 }, 361 ], ··· 363 }); 364 } 365 366 - const footerChildren: unknown[] = []; 367 - if (data.source) { 368 - footerChildren.push({ 369 - type: "span", 370 - props: { 371 - style: { color: "#71717a", fontSize: 20 }, 372 - children: data.source, 373 - }, 374 - }); 375 - } 376 - footerChildren.push(marginBrand(logo)); 377 - 378 - children.push({ 379 - type: "div", 380 - props: { 381 - style: { 382 - display: "flex", 383 - alignItems: "center", 384 - marginTop: "auto", 385 - paddingTop: 28, 386 - borderTop: "1px solid #27272a", 387 - }, 388 - children: footerChildren, 389 - }, 390 - }); 391 392 - return wrapCard(children, tc.accent); 393 } 394 395 - function buildBookmarkImage(data: RecordData, logo: string) { 396 - const children: unknown[] = []; 397 - const tc = getTypeColor("bookmark"); 398 399 - children.push({ 400 type: "div", 401 props: { 402 - style: { display: "flex", alignItems: "center", width: "100%" }, 403 children: [ 404 { 405 type: "div", 406 props: { 407 - style: { display: "flex", alignItems: "center", gap: 10 }, 408 - children: [ 409 - { 410 - type: "div", 411 - props: { 412 - style: { 413 - width: 36, 414 - height: 36, 415 - borderRadius: 10, 416 - background: "#052e16", 417 - display: "flex", 418 - alignItems: "center", 419 - justifyContent: "center", 420 - }, 421 - children: { 422 - type: "div", 423 - props: { 424 - style: { 425 - fontSize: 18, 426 - color: "#4ade80", 427 - fontWeight: 700, 428 - }, 429 - children: "🔗", 430 - }, 431 - }, 432 - }, 433 - }, 434 - { 435 - type: "span", 436 - props: { 437 - style: { color: "#71717a", fontSize: 20 }, 438 - children: data.source || "Saved page", 439 - }, 440 - }, 441 - ], 442 }, 443 }, 444 { 445 type: "div", 446 props: { 447 - style: { marginLeft: "auto", display: "flex" }, 448 - children: [typeBadge("bookmark")], 449 }, 450 }, 451 - ], 452 }, 453 - }); 454 455 children.push({ 456 type: "div", 457 props: { 458 style: { 459 - color: "#fafafa", 460 - fontSize: (data.text?.length || 0) > 60 ? 36 : 44, 461 fontWeight: 700, 462 - lineHeight: 1.3, 463 - marginTop: 36, 464 overflow: "hidden", 465 }, 466 - children: truncate(data.text || "Untitled Bookmark", 100), 467 }, 468 }); 469 ··· 472 type: "div", 473 props: { 474 style: { 475 - color: "#a1a1aa", 476 - fontSize: 24, 477 lineHeight: 1.5, 478 - marginTop: 20, 479 overflow: "hidden", 480 }, 481 - children: truncate(data.quote, 200), 482 }, 483 }); 484 } 485 486 children.push({ 487 type: "div", 488 props: { 489 style: { 490 display: "flex", 491 alignItems: "center", 492 - marginTop: "auto", 493 - paddingTop: 28, 494 - borderTop: "1px solid #27272a", 495 }, 496 children: [ 497 { 498 - type: "div", 499 - props: { 500 - style: { display: "flex", alignItems: "center" }, 501 - children: [ 502 - avatarElement(data.avatarURL, data.author, 36), 503 - { 504 - type: "span", 505 - props: { 506 - style: { color: "#71717a", fontSize: 20, marginLeft: 12 }, 507 - children: data.author, 508 - }, 509 - }, 510 - ], 511 - }, 512 - }, 513 - marginBrand(logo), 514 - ], 515 - }, 516 - }); 517 - 518 - return wrapCard(children, tc.accent); 519 - } 520 - 521 - function buildCollectionImage(data: RecordData, logo: string) { 522 - const children: unknown[] = []; 523 - 524 - children.push({ 525 - type: "div", 526 - props: { 527 - style: { display: "flex", alignItems: "center", gap: 18 }, 528 - children: [ 529 - { 530 type: "span", 531 - props: { style: { fontSize: 64 }, children: data.icon }, 532 }, 533 { 534 type: "span", 535 props: { 536 style: { 537 - color: "#fafafa", 538 - fontSize: 48, 539 fontWeight: 700, 540 overflow: "hidden", 541 }, 542 - children: truncate(data.title, 40), 543 }, 544 }, 545 ], ··· 550 type: "div", 551 props: { 552 style: { 553 - color: data.description ? "#a1a1aa" : "#71717a", 554 - fontSize: 26, 555 lineHeight: 1.5, 556 - marginTop: 24, 557 overflow: "hidden", 558 }, 559 children: data.description 560 - ? truncate(data.description, 200) 561 : "A collection on Margin", 562 }, 563 }); 564 565 - children.push({ 566 - type: "div", 567 - props: { 568 - style: { 569 - display: "flex", 570 - alignItems: "center", 571 - marginTop: "auto", 572 - paddingTop: 28, 573 - borderTop: "1px solid #27272a", 574 - }, 575 - children: [ 576 - { 577 - type: "div", 578 - props: { 579 - style: { display: "flex", alignItems: "center" }, 580 - children: [ 581 - avatarElement(data.avatarURL, data.author, 36), 582 - { 583 - type: "span", 584 - props: { 585 - style: { color: "#71717a", fontSize: 20, marginLeft: 12 }, 586 - children: data.author, 587 - }, 588 - }, 589 - ], 590 - }, 591 - }, 592 - marginBrand(logo), 593 - ], 594 - }, 595 - }); 596 - 597 - return wrapCard(children); 598 - } 599 - 600 - function wrapCard(children: unknown[], accent: string = "#3b82f6") { 601 - return { 602 - type: "div", 603 - props: { 604 - style: { 605 - display: "flex", 606 - width: "100%", 607 - height: "100%", 608 - background: "#09090b", 609 - padding: 40, 610 - fontFamily: "Inter", 611 - }, 612 - children: [ 613 - { 614 - type: "div", 615 - props: { 616 - style: { 617 - display: "flex", 618 - flexDirection: "column", 619 - width: "100%", 620 - height: "100%", 621 - padding: "52px 56px", 622 - border: "1px solid #27272a", 623 - borderRadius: 24, 624 - borderTop: `3px solid ${accent}`, 625 - background: "#18181b", 626 - overflow: "hidden", 627 - }, 628 - children, 629 - }, 630 - }, 631 - ], 632 - }, 633 - }; 634 } 635 636 export const GET: APIRoute = async ({ url }) => { ··· 645 } 646 647 const fonts = loadFonts(); 648 - const logo = getLogoDataURI(); 649 650 - const element = 651 - data.type === "collection" 652 - ? buildCollectionImage(data, logo) 653 - : data.type === "bookmark" 654 - ? buildBookmarkImage(data, logo) 655 - : buildAnnotationImage(data, logo); 656 657 const svg = await satori(element as React.ReactNode, { 658 width: 1200, ··· 666 const codepoints = [...segment] 667 .map((c) => c.codePointAt(0)!.toString(16)) 668 .join("-"); 669 - const url = `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codepoints}.svg`; 670 try { 671 - const res = await fetch(url); 672 if (res.ok) 673 return `data:image/svg+xml,${encodeURIComponent(await res.text())}`; 674 } catch {
··· 27 return fontsLoaded; 28 } 29 30 const API_URL = process.env.API_URL || "http://localhost:8081"; 31 32 interface RecordData { 33 type: "annotation" | "highlight" | "bookmark" | "collection"; 34 author: string; 35 + displayName: string; 36 avatarURL: string; 37 text: string; 38 quote: string; ··· 40 title: string; 41 icon: string; 42 description: string; 43 + color: string; 44 } 45 46 async function fetchRecordData(uri: string): Promise<RecordData | null> { ··· 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 } ··· 77 ) { 78 return { 79 type: "highlight", 80 + author: handle ? `@${handle}` : "someone", 81 + displayName, 82 avatarURL, 83 text: targetTitle, 84 quote: selectorText, ··· 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 || "", ··· 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, ··· 117 title: "", 118 icon: "", 119 description: "", 120 + color: "", 121 }; 122 } 123 } catch { ··· 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: "", ··· 146 title: item.name || "Collection", 147 icon: item.icon || "📁", 148 description: item.description || "", 149 + color: "", 150 }; 151 } 152 } catch { ··· 170 return ""; 171 } 172 173 + const 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 + 184 + const namedColors: Record<string, string> = { 185 + yellow: "#facc15", 186 + green: "#4ade80", 187 + red: "#f87171", 188 + blue: "#60a5fa", 189 + }; 190 + 191 + function resolveHighlightColor(color: string): string { 192 + if (!color) return "#facc15"; 193 + if (color.startsWith("#")) return color; 194 + return namedColors[color] || "#facc15"; 195 + } 196 + 197 + const typeColors: Record<string, string> = { 198 + annotation: "#3b82f6", 199 + highlight: "#facc15", 200 + bookmark: "#22c55e", 201 + collection: "#3b82f6", 202 + }; 203 + 204 + function 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 + 212 + function avatarElement(url: string, name: string, size: number): unknown { 213 if (url) { 214 return { 215 type: "img", ··· 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: { ··· 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 249 + function 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 256 + function 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 268 + const typeLabels: Record<string, string> = { 269 + annotation: "Annotation", 270 + highlight: "Highlight", 271 + bookmark: "Bookmark", 272 + collection: "Collection", 273 + }; 274 + 275 + function 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 358 + function 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 + 382 + function 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 + 400 + function 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 } ··· 431 style: { 432 width: 4, 433 borderRadius: 2, 434 + background: accent, 435 flexShrink: 0, 436 }, 437 }, ··· 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 ], ··· 455 }); 456 } 457 458 + const footer = footerSource(data.source); 459 + if (footer) children.push(footer); 460 461 + return wrap(children, lightTint(accent)); 462 } 463 464 + function 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 551 + function 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 ··· 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 + 603 + function 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 ], ··· 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 655 export const GET: APIRoute = async ({ url }) => { ··· 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, ··· 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 {