(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 685 lines 17 kB view raw
1#!/usr/bin/env node 2 3/** 4 * OG Image Preview Generator 5 * 6 * Usage: 7 * node tools/preview-og.mjs # generates all sample types 8 * node tools/preview-og.mjs --uri at://did/col/rkey # fetches real data from running backend 9 */ 10 11import satori from "satori"; 12import { Resvg } from "@resvg/resvg-js"; 13import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; 14import { join, dirname } from "node:path"; 15import { fileURLToPath } from "node:url"; 16import { execSync } from "node:child_process"; 17 18const __dirname = dirname(fileURLToPath(import.meta.url)); 19const ROOT = join(__dirname, ".."); 20 21const fontsDir = join(ROOT, "public", "fonts"); 22const regular = readFileSync(join(fontsDir, "Inter-Regular.ttf")); 23const bold = readFileSync(join(fontsDir, "Inter-Bold.ttf")); 24 25let logoDataURI = ""; 26try { 27 const buf = readFileSync(join(ROOT, "public", "logo.svg")); 28 logoDataURI = `data:image/svg+xml;base64,${buf.toString("base64")}`; 29} catch {} 30 31const outDir = join(ROOT, "tools", "og-preview"); 32mkdirSync(outDir, { recursive: true }); 33 34function truncate(str, max) { 35 if (str.length <= max) return str; 36 return str.slice(0, max - 3) + "..."; 37} 38 39function wrapCard(children) { 40 return { 41 type: "div", 42 props: { 43 style: { 44 display: "flex", 45 width: "100%", 46 height: "100%", 47 background: "#09090b", 48 padding: 40, 49 fontFamily: "Inter", 50 }, 51 children: [ 52 { 53 type: "div", 54 props: { 55 style: { 56 display: "flex", 57 flexDirection: "column", 58 width: "100%", 59 height: "100%", 60 padding: "52px 56px", 61 border: "1px solid #27272a", 62 borderRadius: 24, 63 borderTop: `3px solid ${children.__accent || "#3b82f6"}`, 64 background: "#18181b", 65 overflow: "hidden", 66 }, 67 children, 68 }, 69 }, 70 ], 71 }, 72 }; 73} 74 75function avatarCircle(author, size = 48) { 76 const letter = 77 author[0] === "@" 78 ? (author[1] || "?").toUpperCase() 79 : (author[0] || "?").toUpperCase(); 80 return { 81 type: "div", 82 props: { 83 style: { 84 width: size, 85 height: size, 86 borderRadius: size / 2, 87 background: "#3b82f6", 88 display: "flex", 89 alignItems: "center", 90 justifyContent: "center", 91 color: "white", 92 fontSize: Math.round(size * 0.45), 93 fontWeight: 700, 94 }, 95 children: letter, 96 }, 97 }; 98} 99 100const typeColors = { 101 annotation: { 102 accent: "#3b82f6", 103 badge: "#1e3a8a", 104 badgeText: "#60a5fa", 105 bar: "#60a5fa", 106 }, 107 highlight: { 108 accent: "#eab308", 109 badge: "#422006", 110 badgeText: "#facc15", 111 bar: "#facc15", 112 }, 113 bookmark: { 114 accent: "#22c55e", 115 badge: "#052e16", 116 badgeText: "#4ade80", 117 bar: "#4ade80", 118 }, 119}; 120 121function getTypeColor(type) { 122 return typeColors[type] || typeColors.annotation; 123} 124 125function typeBadge(type) { 126 const labels = { 127 annotation: "Annotation", 128 highlight: "Highlight", 129 bookmark: "Bookmark", 130 }; 131 const c = getTypeColor(type); 132 return { 133 type: "div", 134 props: { 135 style: { 136 padding: "6px 16px", 137 borderRadius: 99, 138 background: c.badge, 139 color: c.badgeText, 140 fontSize: 16, 141 fontWeight: 600, 142 }, 143 children: labels[type] || type, 144 }, 145 }; 146} 147 148function marginBrand() { 149 if (!logoDataURI) return null; 150 return { 151 type: "div", 152 props: { 153 style: { 154 display: "flex", 155 alignItems: "center", 156 marginLeft: "auto", 157 }, 158 children: [ 159 { 160 type: "img", 161 props: { src: logoDataURI, width: 28, height: 24 }, 162 }, 163 ], 164 }, 165 }; 166} 167 168function buildAnnotationImage(data) { 169 const children = []; 170 const tc = getTypeColor(data.type || "annotation"); 171 172 children.push({ 173 type: "div", 174 props: { 175 style: { display: "flex", alignItems: "center", width: "100%" }, 176 children: [ 177 data.avatarURL 178 ? { 179 type: "img", 180 props: { 181 src: data.avatarURL, 182 width: 48, 183 height: 48, 184 style: { borderRadius: 24 }, 185 }, 186 } 187 : avatarCircle(data.author, 48), 188 { 189 type: "span", 190 props: { 191 style: { color: "#a1a1aa", fontSize: 22, marginLeft: 14 }, 192 children: data.author, 193 }, 194 }, 195 { 196 type: "div", 197 props: { 198 style: { marginLeft: "auto", display: "flex" }, 199 children: [typeBadge(data.type || "annotation")], 200 }, 201 }, 202 ], 203 }, 204 }); 205 206 if (data.text) { 207 children.push({ 208 type: "div", 209 props: { 210 style: { 211 color: "#fafafa", 212 fontSize: data.text.length > 200 ? 26 : 32, 213 lineHeight: 1.45, 214 marginTop: 32, 215 overflow: "hidden", 216 }, 217 children: truncate(data.text, 300), 218 }, 219 }); 220 } 221 222 if (data.quote) { 223 children.push({ 224 type: "div", 225 props: { 226 style: { display: "flex", marginTop: 24 }, 227 children: [ 228 { 229 type: "div", 230 props: { 231 style: { 232 width: 4, 233 borderRadius: 2, 234 background: tc.bar, 235 flexShrink: 0, 236 }, 237 }, 238 }, 239 { 240 type: "div", 241 props: { 242 style: { 243 color: "#a1a1aa", 244 fontSize: data.quote.length > 150 ? 22 : 26, 245 lineHeight: 1.5, 246 paddingLeft: 18, 247 fontStyle: "italic", 248 overflow: "hidden", 249 }, 250 children: `"${truncate(data.quote, 250)}"`, 251 }, 252 }, 253 ], 254 }, 255 }); 256 } 257 258 const footerChildren = []; 259 if (data.source) 260 footerChildren.push({ 261 type: "span", 262 props: { 263 style: { color: "#71717a", fontSize: 20 }, 264 children: data.source, 265 }, 266 }); 267 footerChildren.push(marginBrand()); 268 children.push({ 269 type: "div", 270 props: { 271 style: { 272 display: "flex", 273 alignItems: "center", 274 marginTop: "auto", 275 paddingTop: 28, 276 borderTop: "1px solid #27272a", 277 }, 278 children: footerChildren, 279 }, 280 }); 281 282 children.__accent = tc.accent; 283 return wrapCard(children); 284} 285 286function buildBookmarkImage(data) { 287 const children = []; 288 const tc = getTypeColor("bookmark"); 289 290 children.push({ 291 type: "div", 292 props: { 293 style: { display: "flex", alignItems: "center", width: "100%" }, 294 children: [ 295 { 296 type: "div", 297 props: { 298 style: { display: "flex", alignItems: "center", gap: 10 }, 299 children: [ 300 { 301 type: "div", 302 props: { 303 style: { 304 width: 36, 305 height: 36, 306 borderRadius: 10, 307 background: "#052e16", 308 display: "flex", 309 alignItems: "center", 310 justifyContent: "center", 311 }, 312 children: { 313 type: "div", 314 props: { 315 style: { 316 fontSize: 18, 317 color: "#4ade80", 318 fontWeight: 700, 319 }, 320 children: "🔗", 321 }, 322 }, 323 }, 324 }, 325 { 326 type: "span", 327 props: { 328 style: { color: "#71717a", fontSize: 20 }, 329 children: data.source || "Saved page", 330 }, 331 }, 332 ], 333 }, 334 }, 335 { 336 type: "div", 337 props: { 338 style: { marginLeft: "auto", display: "flex" }, 339 children: [typeBadge("bookmark")], 340 }, 341 }, 342 ], 343 }, 344 }); 345 346 children.push({ 347 type: "div", 348 props: { 349 style: { 350 color: "#fafafa", 351 fontSize: (data.text?.length || 0) > 60 ? 36 : 44, 352 fontWeight: 700, 353 lineHeight: 1.3, 354 marginTop: 36, 355 overflow: "hidden", 356 }, 357 children: truncate(data.text || "Untitled Bookmark", 100), 358 }, 359 }); 360 361 if (data.quote) { 362 children.push({ 363 type: "div", 364 props: { 365 style: { 366 color: "#a1a1aa", 367 fontSize: 24, 368 lineHeight: 1.5, 369 marginTop: 20, 370 overflow: "hidden", 371 }, 372 children: truncate(data.quote, 200), 373 }, 374 }); 375 } 376 377 const authorChildren = [ 378 avatarCircle(data.author, 36), 379 { 380 type: "span", 381 props: { 382 style: { color: "#71717a", fontSize: 20, marginLeft: 12 }, 383 children: data.author, 384 }, 385 }, 386 ]; 387 children.push({ 388 type: "div", 389 props: { 390 style: { 391 display: "flex", 392 alignItems: "center", 393 marginTop: "auto", 394 paddingTop: 28, 395 borderTop: "1px solid #27272a", 396 }, 397 children: [ 398 { 399 type: "div", 400 props: { 401 style: { display: "flex", alignItems: "center" }, 402 children: authorChildren, 403 }, 404 }, 405 marginBrand(), 406 ], 407 }, 408 }); 409 410 children.__accent = tc.accent; 411 return wrapCard(children); 412} 413 414function buildCollectionImage(data) { 415 const children = []; 416 children.push({ 417 type: "div", 418 props: { 419 style: { display: "flex", alignItems: "center", gap: 18 }, 420 children: [ 421 { 422 type: "span", 423 props: { style: { fontSize: 64 }, children: data.icon }, 424 }, 425 { 426 type: "span", 427 props: { 428 style: { 429 color: "#fafafa", 430 fontSize: 48, 431 fontWeight: 700, 432 overflow: "hidden", 433 }, 434 children: truncate(data.title, 40), 435 }, 436 }, 437 ], 438 }, 439 }); 440 441 children.push({ 442 type: "div", 443 props: { 444 style: { 445 color: data.description ? "#a1a1aa" : "#71717a", 446 fontSize: 26, 447 lineHeight: 1.5, 448 marginTop: 24, 449 overflow: "hidden", 450 }, 451 children: data.description 452 ? truncate(data.description, 200) 453 : "A collection on Margin", 454 }, 455 }); 456 457 const authorChildren = [ 458 avatarCircle(data.author, 36), 459 { 460 type: "span", 461 props: { 462 style: { color: "#71717a", fontSize: 20, marginLeft: 12 }, 463 children: data.author, 464 }, 465 }, 466 ]; 467 const footerChildren = [ 468 { 469 type: "div", 470 props: { 471 style: { display: "flex", alignItems: "center" }, 472 children: authorChildren, 473 }, 474 }, 475 ]; 476 footerChildren.push(marginBrand()); 477 478 children.push({ 479 type: "div", 480 props: { 481 style: { 482 display: "flex", 483 alignItems: "center", 484 marginTop: "auto", 485 paddingTop: 28, 486 borderTop: "1px solid #27272a", 487 }, 488 children: footerChildren, 489 }, 490 }); 491 492 return wrapCard(children); 493} 494 495async function renderPNG(element, filename) { 496 const svg = await satori(element, { 497 width: 1200, 498 height: 630, 499 fonts: [ 500 { name: "Inter", data: regular.buffer, weight: 400, style: "normal" }, 501 { name: "Inter", data: bold.buffer, weight: 700, style: "normal" }, 502 ], 503 loadAdditionalAsset: async (code, segment) => { 504 if (code === "emoji") { 505 const codepoints = [...segment] 506 .map((c) => c.codePointAt(0).toString(16)) 507 .join("-"); 508 const url = `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codepoints}.svg`; 509 try { 510 const res = await fetch(url); 511 if (res.ok) 512 return `data:image/svg+xml,${encodeURIComponent(await res.text())}`; 513 } catch {} 514 } 515 return ""; 516 }, 517 }); 518 519 const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); 520 const png = resvg.render().asPng(); 521 const out = join(outDir, filename); 522 writeFileSync(out, png); 523 console.log(`${out}`); 524 return out; 525} 526 527const samples = [ 528 { 529 name: "annotation.png", 530 builder: buildAnnotationImage, 531 data: { 532 type: "annotation", 533 author: "@alice.bsky.social", 534 avatarURL: "", 535 text: "This is a really insightful point about decentralized identity. The AT Protocol's approach to portable accounts changes everything.", 536 quote: 537 "Users should own their data and be able to move between services without losing their identity or social graph.", 538 source: "atproto.com", 539 }, 540 }, 541 { 542 name: "highlight.png", 543 builder: buildAnnotationImage, 544 data: { 545 type: "highlight", 546 author: "@bob.bsky.social", 547 avatarURL: "", 548 text: "", 549 quote: 550 "The web annotation data model provides a framework for sharing annotations across different platforms, creating an interoperable layer of user-generated metadata on top of existing web content.", 551 source: "w3.org", 552 }, 553 }, 554 { 555 name: "bookmark.png", 556 builder: buildBookmarkImage, 557 data: { 558 type: "bookmark", 559 author: "@carol.bsky.social", 560 avatarURL: "", 561 text: "How to Build a Chrome Extension with React and TypeScript", 562 quote: 563 "A comprehensive guide covering manifest v3, content scripts, popup pages, and background workers.", 564 source: "dev.to", 565 }, 566 }, 567 { 568 name: "collection.png", 569 builder: buildCollectionImage, 570 data: { 571 author: "@dave.bsky.social", 572 avatarURL: "", 573 title: "Web Standards", 574 icon: "🌍", 575 description: 576 "Articles and specs about W3C web standards, accessibility, and the open web platform.", 577 }, 578 }, 579 { 580 name: "collection-minimal.png", 581 builder: buildCollectionImage, 582 data: { 583 author: "@eve.bsky.social", 584 avatarURL: "", 585 title: "Reading List", 586 icon: "📚", 587 description: "", 588 }, 589 }, 590]; 591 592const args = process.argv.slice(2); 593const uriArg = args.find( 594 (a) => a.startsWith("--uri=") || args[args.indexOf("--uri") + 1], 595); 596const uri = uriArg?.startsWith("--uri=") 597 ? uriArg.slice(6) 598 : args[args.indexOf("--uri") + 1]; 599 600if (uri) { 601 const apiURL = process.env.API_URL || "http://localhost:8081"; 602 console.log(`Fetching ${uri} from ${apiURL}...`); 603 604 let data = null; 605 606 try { 607 const res = await fetch( 608 `${apiURL}/api/annotation?uri=${encodeURIComponent(uri)}`, 609 ); 610 if (res.ok) { 611 const item = await res.json(); 612 const author = item.author || item.creator || {}; 613 const handle = author.handle 614 ? `@${author.handle}` 615 : author.did || "someone"; 616 const targetSource = item.target?.source || item.url || item.source || ""; 617 const domain = targetSource 618 ? (() => { 619 try { 620 return new URL(targetSource).host; 621 } catch { 622 return ""; 623 } 624 })() 625 : ""; 626 data = { 627 author: handle, 628 avatarURL: author.avatar || "", 629 text: item.body || item.bodyValue || item.text || item.title || "", 630 quote: 631 item.target?.selector?.exact || 632 item.selector?.exact || 633 item.description || 634 "", 635 source: domain, 636 }; 637 } 638 } catch {} 639 640 if (!data) { 641 try { 642 const res = await fetch( 643 `${apiURL}/api/collection?uri=${encodeURIComponent(uri)}`, 644 ); 645 if (res.ok) { 646 const item = await res.json(); 647 const author = item.author || item.creator || {}; 648 data = { 649 type: "collection", 650 author: author.handle ? `@${author.handle}` : author.did || "someone", 651 avatarURL: author.avatar || "", 652 title: item.name || "Collection", 653 icon: item.icon || "📁", 654 description: item.description || "", 655 }; 656 } 657 } catch {} 658 } 659 660 if (!data) { 661 console.error("Could not fetch record for URI:", uri); 662 process.exit(1); 663 } 664 665 const element = 666 data.type === "collection" 667 ? buildCollectionImage(data) 668 : buildAnnotationImage(data); 669 const file = await renderPNG(element, "live-preview.png"); 670 tryOpen(file); 671} else { 672 console.log("Generating OG image previews...\n"); 673 let lastFile; 674 for (const s of samples) { 675 lastFile = await renderPNG(s.builder(s.data), s.name); 676 } 677 console.log(`\nDone! Files in ${outDir}`); 678 tryOpen(outDir); 679} 680 681function tryOpen(path) { 682 try { 683 execSync(`open "${path}"`, { stdio: "ignore" }); 684 } catch {} 685}