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