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