leaflet.pub astro loader
1import type {} from "@atcute/atproto";
2import { is } from "@atcute/lexicons";
3import { AtUri, UnicodeString } from "@atproto/api";
4import katex from "katex";
5import sanitizeHTML from "sanitize-html";
6import {
7 PubLeafletBlocksBlockquote,
8 PubLeafletBlocksCode,
9 PubLeafletBlocksHeader,
10 PubLeafletBlocksHorizontalRule,
11 PubLeafletBlocksIframe,
12 PubLeafletBlocksImage,
13 PubLeafletBlocksMath,
14 PubLeafletBlocksText,
15 PubLeafletBlocksUnorderedList,
16 PubLeafletPagesLinearDocument,
17} from "./lexicons/index.js";
18import type {
19 Did,
20 Facet,
21 GetLeafletDocumentsParams,
22 GetSingleLeafletDocumentParams,
23 LeafletDocumentRecord,
24 LeafletDocumentView,
25 MiniDoc,
26 RichTextSegment,
27} from "./types.js";
28
29export class LiveLoaderError extends Error {
30 constructor(
31 message: string,
32 public code?: string,
33 ) {
34 super(message);
35 this.name = "LiveLoaderError";
36 }
37}
38
39export function uriToRkey(uri: string): string {
40 const u = AtUri.make(uri);
41 if (!u.rkey) {
42 throw new Error("failed to get rkey");
43 }
44 return u.rkey;
45}
46
47export async function resolveMiniDoc(handleOrDid: string) {
48 try {
49 const response = await fetch(
50 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${handleOrDid}`,
51 );
52
53 if (!response.ok || response.status >= 300) {
54 throw new Error(
55 `could not resolve did doc due to invalid handle or did ${handleOrDid}`,
56 );
57 }
58 const data = (await response.json()) as MiniDoc;
59
60 return {
61 pds: data.pds,
62 did: data.did,
63 };
64 } catch {
65 throw new Error(`failed to resolve handle: ${handleOrDid}`);
66 }
67}
68
69export async function getLeafletDocuments({
70 repo,
71 reverse,
72 cursor,
73 rpc,
74 limit,
75}: GetLeafletDocumentsParams) {
76 const { ok, data } = await rpc.get("com.atproto.repo.listRecords", {
77 params: {
78 collection: "pub.leaflet.document",
79 cursor,
80 reverse,
81 limit,
82 repo,
83 },
84 });
85
86 if (!ok) {
87 throw new LiveLoaderError(
88 "error fetching leaflet documents",
89 "DOCUMENT_FETCH_ERROR",
90 );
91 }
92
93 return {
94 documents: data?.records,
95 cursor: data?.cursor,
96 };
97}
98
99export async function getSingleLeafletDocument({
100 rpc,
101 repo,
102 id,
103}: GetSingleLeafletDocumentParams) {
104 const { ok, data } = await rpc.get("com.atproto.repo.getRecord", {
105 params: {
106 collection: "pub.leaflet.document",
107 repo,
108 rkey: id,
109 },
110 });
111
112 if (!ok) {
113 throw new LiveLoaderError(
114 "error fetching single document",
115 "DOCUMENT_FETCH_ERROR",
116 );
117 }
118
119 return data;
120}
121
122export function leafletDocumentRecordToView({
123 uri,
124 cid,
125 value,
126}: {
127 uri: string;
128 cid: string;
129 value: LeafletDocumentRecord;
130}): LeafletDocumentView {
131 return {
132 rkey: uriToRkey(uri),
133 cid,
134 title: value.title,
135 description: value.description,
136 author: value.author,
137 publication: value.publication,
138 publishedAt: value.publishedAt,
139 };
140}
141
142export function leafletBlocksToHTML({
143 record,
144 did,
145}: {
146 record: LeafletDocumentRecord;
147 did: string;
148}) {
149 let html = "";
150 const firstPage = record.pages[0];
151 let blocks: PubLeafletPagesLinearDocument.Block[] = [];
152
153 if (is(PubLeafletPagesLinearDocument.mainSchema, firstPage)) {
154 blocks = firstPage.blocks || [];
155 }
156
157 for (const block of blocks) {
158 html += parseBlocks({ block, did });
159 }
160
161 return sanitizeHTML(html, {
162 allowedAttributes: {
163 "*": ["class", "style"],
164 img: ["src", "height", "width", "alt"],
165 a: ["href", "target", "rel"],
166 iframe: ["height", "allow", "loading", "src"],
167 },
168 allowedTags: [
169 "img",
170 "pre",
171 "code",
172 "p",
173 "a",
174 "b",
175 "s",
176 "ul",
177 "li",
178 "i",
179 "h1",
180 "h2",
181 "h3",
182 "h4",
183 "h5",
184 "h6",
185 "hr",
186 "div",
187 "span",
188 "blockquote",
189 "iframe",
190 ],
191 selfClosing: ["img"],
192 });
193}
194
195export class RichText {
196 unicodeText: UnicodeString;
197 facets?: Facet[];
198 constructor(props: { text: string; facets: Facet[] }) {
199 this.unicodeText = new UnicodeString(props.text);
200 this.facets = props.facets;
201 if (this.facets) {
202 this.facets = this.facets
203 .filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
204 .sort((a, b) => a.index.byteStart - b.index.byteStart);
205 }
206 }
207
208 *segments(): Generator<RichTextSegment, void, void> {
209 const facets = this.facets || [];
210 if (!facets.length) {
211 yield { text: this.unicodeText.utf16 };
212 return;
213 }
214
215 let textCursor = 0;
216 let facetCursor = 0;
217 do {
218 const currFacet = facets[facetCursor];
219 if (currFacet) {
220 if (textCursor < currFacet.index.byteStart) {
221 yield {
222 text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
223 };
224 } else if (textCursor > currFacet.index.byteStart) {
225 facetCursor++;
226 continue;
227 }
228 if (currFacet.index.byteStart < currFacet.index.byteEnd) {
229 const subtext = this.unicodeText.slice(
230 currFacet.index.byteStart,
231 currFacet.index.byteEnd,
232 );
233 if (!subtext.trim()) {
234 // dont empty string entities
235 yield { text: subtext };
236 } else {
237 yield { text: subtext, facet: currFacet.features };
238 }
239 }
240 textCursor = currFacet.index.byteEnd;
241 facetCursor++;
242 }
243 } while (facetCursor < facets.length);
244 if (textCursor < this.unicodeText.length) {
245 yield {
246 text: this.unicodeText.slice(textCursor, this.unicodeText.length),
247 };
248 }
249 }
250}
251
252export function parseTextBlock(block: PubLeafletBlocksText.Main) {
253 let html = "";
254 const rt = new RichText({
255 text: block.plaintext,
256 facets: block.facets || [],
257 });
258 const children = [];
259 for (const segment of rt.segments()) {
260 const link = segment.facet?.find(
261 (segment) => segment.$type === "pub.leaflet.richtext.facet#link",
262 );
263 const isBold = segment.facet?.find(
264 (segment) => segment.$type === "pub.leaflet.richtext.facet#bold",
265 );
266 const isCode = segment.facet?.find(
267 (segment) => segment.$type === "pub.leaflet.richtext.facet#code",
268 );
269 const isStrikethrough = segment.facet?.find(
270 (segment) => segment.$type === "pub.leaflet.richtext.facet#strikethrough",
271 );
272 const isUnderline = segment.facet?.find(
273 (segment) => segment.$type === "pub.leaflet.richtext.facet#underline",
274 );
275 const isItalic = segment.facet?.find(
276 (segment) => segment.$type === "pub.leaflet.richtext.facet#italic",
277 );
278 if (isCode) {
279 children.push(`<pre><code>${segment.text}</code></pre>`);
280 } else if (link) {
281 children.push(
282 `<a href="${link.uri}" target="_blank" rel="noopener noreferrer">${segment.text}</a>`,
283 );
284 } else if (isBold) {
285 children.push(`<b>${segment.text}</b>`);
286 } else if (isStrikethrough) {
287 children.push(`<s>${segment.text}</s>`);
288 } else if (isUnderline) {
289 children.push(
290 `<span style="text-decoration:underline;">${segment.text}</span>`,
291 );
292 } else if (isItalic) {
293 children.push(`<i>${segment.text}</i>`);
294 } else {
295 children.push(`${segment.text}`);
296 }
297 }
298 html += `<p>${children.join("")}</p>`;
299
300 return html.trim();
301}
302
303export function parseBlocks({
304 block,
305 did,
306}: {
307 block: PubLeafletPagesLinearDocument.Block;
308 did: string;
309}): string {
310 let html = "";
311
312 if (is(PubLeafletBlocksText.mainSchema, block.block)) {
313 html += parseTextBlock(block.block);
314 }
315
316 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
317 if (block.block.level === 1) {
318 html += `<h2>${block.block.plaintext}</h2>`;
319 }
320 }
321 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
322 if (block.block.level === 2) {
323 html += `<h3>${block.block.plaintext}</h3>`;
324 }
325 }
326 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
327 if (block.block.level === 3) {
328 html += `<h4>${block.block.plaintext}</h4>`;
329 }
330 }
331 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
332 if (!block.block.level) {
333 html += `<h6>${block.block.plaintext}</h6>`;
334 }
335 }
336
337 if (is(PubLeafletBlocksHorizontalRule.mainSchema, block.block)) {
338 html += `<hr />`;
339 }
340 if (is(PubLeafletBlocksUnorderedList.mainSchema, block.block)) {
341 html += `<ul>${block.block.children.map((child) => renderListItem({ item: child, did })).join("")}</ul>`;
342 }
343
344 if (is(PubLeafletBlocksMath.mainSchema, block.block)) {
345 html += `<div>${katex.renderToString(block.block.tex, { displayMode: true, output: "html", throwOnError: false })}</div>`;
346 }
347
348 if (is(PubLeafletBlocksCode.mainSchema, block.block)) {
349 html += `<pre><code data-language=${block.block.language}>${block.block.plaintext}</code></pre>`;
350 }
351
352 if (is(PubLeafletBlocksImage.mainSchema, block.block)) {
353 // @ts-ignore
354 html += `<div><img src="https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${block.block.image.ref.$link}@jpeg" height="${block.block.aspectRatio.height}" width="${block.block.aspectRatio.width}" alt="${block.block.alt}" /></div>`;
355 }
356
357 if (is(PubLeafletBlocksBlockquote.mainSchema, block.block)) {
358 html += `<blockquote>${parseTextBlock(block.block)}</blockquote>`;
359 }
360
361 if (is(PubLeafletBlocksIframe.mainSchema, block.block)) {
362 // @ts-ignore
363 html += `<iframe height="${block.block.height}" allow="fullscreen" loading="lazy" src="${block.block.url}"></iframe>`;
364 }
365
366 return html.trim();
367}
368
369export function renderListItem({
370 item,
371 did,
372}: {
373 item: PubLeafletBlocksUnorderedList.ListItem;
374 did: string;
375}): string {
376 const children: string | null = item.children?.length
377 ? `<ul>${item.children.map((child) => renderListItem({ item: child, did }))}</ul>`
378 : "";
379
380 return `<li>${parseBlocks({ block: { block: item.content }, did })}${children}</li>`;
381}
382
383// yoinked from: https://github.com/mary-ext/atcute/blob/trunk/packages/lexicons/lexicons/lib/syntax/handle.ts
384const PLC_DID_RE = /^did:plc:([a-z2-7]{24})$/;
385
386export const isPlcDid = (input: string): input is Did<"plc"> => {
387 return input.length === 32 && PLC_DID_RE.test(input);
388};