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