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