forked from
atproto.fr/atproto.fr
Website of atproto.fr
1/**
2 * Renders Leaflet rich content (pub.leaflet.content) to HTML.
3 */
4
5interface Facet {
6 index: { byteStart: number; byteEnd: number };
7 features: { $type: string; uri?: string; contentPlaintext?: string }[];
8}
9
10interface TextBlock {
11 $type: "pub.leaflet.blocks.text";
12 plaintext: string;
13 facets?: Facet[];
14}
15
16interface HeaderBlock {
17 $type: "pub.leaflet.blocks.header";
18 plaintext: string;
19 facets?: Facet[];
20 level?: number;
21}
22
23interface ImageBlock {
24 $type: "pub.leaflet.blocks.image";
25 src?: string;
26 title?: string;
27 description?: string;
28 previewImage?: { $type: string; ref: { $link: string }; mimeType: string; size: number };
29}
30
31interface WebsiteBlock {
32 $type: "pub.leaflet.blocks.website";
33 src?: string;
34 title?: string;
35 description?: string;
36 previewImage?: { $type: string; ref: { $link: string }; mimeType: string; size: number };
37}
38
39interface BskyBlock {
40 $type: "pub.leaflet.blocks.bsky";
41 src?: string;
42 title?: string;
43 description?: string;
44}
45
46interface HorizontalRuleBlock {
47 $type: "pub.leaflet.blocks.horizontalRule";
48}
49
50interface ListItem {
51 $type: string;
52 content: TextBlock;
53 children?: ListItem[];
54}
55
56interface UnorderedListBlock {
57 $type: "pub.leaflet.blocks.unorderedList";
58 children: ListItem[];
59}
60
61function escapeHtml(text: string): string {
62 return text
63 .replace(/&/g, "&")
64 .replace(/</g, "<")
65 .replace(/>/g, ">")
66 .replace(/"/g, """);
67}
68
69function applyFacets(plaintext: string, facets?: Facet[]): string {
70 if (!facets || facets.length === 0) return escapeHtml(plaintext);
71
72 const bytes = new TextEncoder().encode(plaintext);
73 // Sort facets by byteStart
74 const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart);
75
76 let html = "";
77 let cursor = 0;
78
79 for (const facet of sorted) {
80 const start = facet.index.byteStart;
81 const end = facet.index.byteEnd;
82
83 // Text before this facet
84 if (start > cursor) {
85 html += escapeHtml(new TextDecoder().decode(bytes.slice(cursor, start)));
86 }
87
88 const segment = escapeHtml(new TextDecoder().decode(bytes.slice(start, end)));
89 let wrapped = segment;
90
91 for (const feature of facet.features) {
92 switch (feature.$type) {
93 case "pub.leaflet.richtext.facet#link":
94 wrapped = `<a href="${escapeHtml(feature.uri ?? "#")}" target="_blank" rel="noopener noreferrer">${wrapped}</a>`;
95 break;
96 case "pub.leaflet.richtext.facet#bold":
97 wrapped = `<strong>${wrapped}</strong>`;
98 break;
99 case "pub.leaflet.richtext.facet#italic":
100 wrapped = `<em>${wrapped}</em>`;
101 break;
102 case "pub.leaflet.richtext.facet#highlight":
103 wrapped = `<mark>${wrapped}</mark>`;
104 break;
105 case "pub.leaflet.richtext.facet#footnote":
106 const footnoteContent = escapeHtml(feature.contentPlaintext ?? "");
107 wrapped = `<span class="sidenote-anchor">${wrapped}<span class="sidenote">${footnoteContent}</span></span>`;
108 break;
109 }
110 }
111
112 html += wrapped;
113 cursor = end;
114 }
115
116 // Remaining text after last facet
117 if (cursor < bytes.length) {
118 html += escapeHtml(new TextDecoder().decode(bytes.slice(cursor)));
119 }
120
121 return html;
122}
123
124interface RenderContext {
125 pds?: string;
126 did?: string;
127}
128
129function blobUrl(ctx: RenderContext, cid: string): string | undefined {
130 if (!ctx.pds || !ctx.did) return undefined;
131 return `${ctx.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(ctx.did)}&cid=${encodeURIComponent(cid)}`;
132}
133
134function renderBlock(block: any, ctx: RenderContext): string {
135 const type = block.$type as string;
136
137 if (type === "pub.leaflet.blocks.text") {
138 const text = applyFacets(block.plaintext ?? "", block.facets);
139 if (!text.trim()) return "";
140 return `<p>${text}</p>`;
141 }
142
143 if (type === "pub.leaflet.blocks.header") {
144 const level = block.level ?? 2;
145 const tag = `h${Math.min(Math.max(level, 1), 6)}`;
146 const text = applyFacets(block.plaintext ?? "", block.facets);
147 return `<${tag}>${text}</${tag}>`;
148 }
149
150 if (type === "pub.leaflet.blocks.image") {
151 const title = block.title ? escapeHtml(block.title) : "";
152 const alt = block.alt ? escapeHtml(block.alt) : (block.description ? escapeHtml(block.description) : "");
153 const src = block.src ?? (block.image?.ref?.$link ? blobUrl(ctx, block.image.ref.$link) : undefined);
154 if (src) {
155 const altDropdown = alt ? `<details><summary>alt</summary><p>${alt}</p></details>` : "";
156 return `<figure><a href="${escapeHtml(src)}" target="_blank" rel="noopener noreferrer"><img src="${escapeHtml(src)}" alt="${alt}" /></a>${altDropdown}${title ? `<figcaption>${title}</figcaption>` : ""}</figure>`;
157 }
158 return "";
159 }
160
161 if (type === "pub.leaflet.blocks.website") {
162 const url = block.src ?? "";
163 const title = block.title ?? url;
164 const desc = block.description ?? "";
165 if (!url) return "";
166 return `<div class="border border-atfr-green/20 dark:border-white/20 p-3 my-2"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="font-medium hover:underline">${escapeHtml(title)}</a>${desc ? `<p class="text-sm text-atfr-green dark:text-white mt-1">${escapeHtml(desc)}</p>` : ""}</div>`;
167 }
168
169 if (type === "pub.leaflet.blocks.bsky") {
170 const url = block.src ?? "";
171 const title = block.title ?? "Post Bluesky";
172 const desc = block.description ?? "";
173 if (!url) return "";
174 return `<div class="border border-atfr-green/20 dark:border-white/20 p-3 my-2"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" class="font-medium hover:underline">${escapeHtml(title)}</a>${desc ? `<p class="text-sm text-atfr-green dark:text-white mt-1">${escapeHtml(desc)}</p>` : ""}</div>`;
175 }
176
177 if (type === "pub.leaflet.blocks.bskyPost") {
178 const atUri = block.postRef?.uri ?? "";
179 const clientHost = block.clientHost ?? "bsky.app";
180 if (!atUri) return "";
181 const match = atUri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
182 if (match) {
183 const did = match[1];
184 const rkey = match[2];
185 const url = `https://${clientHost}/profile/${did}/post/${rkey}`;
186 return `<div class="bsky-embed-container"><blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(atUri)}"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">Voir sur Bluesky</a></blockquote></div>`;
187 }
188 return "";
189 }
190
191 if (type === "pub.leaflet.blocks.horizontalRule") {
192 return "<hr />";
193 }
194
195 if (type === "pub.leaflet.blocks.unorderedList") {
196 return renderList(block.children ?? []);
197 }
198
199 return "";
200}
201
202function renderList(items: any[]): string {
203 const lis = items
204 .map((item) => {
205 const text = applyFacets(item.content?.plaintext ?? "", item.content?.facets);
206 const children = item.children?.length ? renderList(item.children) : "";
207 return `<li>${text}${children}</li>`;
208 })
209 .join("");
210 return `<ul>${lis}</ul>`;
211}
212
213export function renderLeafletContent(content: any, ctx: RenderContext = {}): string {
214 if (!content || content.$type !== "pub.leaflet.content") return "";
215
216 const pages = content.pages ?? [];
217 const parts: string[] = [];
218
219 for (const page of pages) {
220 const blocks = page.blocks ?? [];
221 for (const wrapper of blocks) {
222 // linearDocument wraps blocks in { $type: "...#block", block: {...} }
223 const block = wrapper.block ?? wrapper;
224 parts.push(renderBlock(block, ctx));
225 }
226 }
227
228 return parts.filter(Boolean).join("\n");
229}