leaflet.pub astro loader
1import { is } from "@atcute/lexicons";
2import { AtUri, UnicodeString } from "@atproto/api";
3import sanitizeHTML from "sanitize-html";
4import {
5 PubLeafletBlocksHeader,
6 PubLeafletBlocksText,
7 PubLeafletPagesLinearDocument,
8} from "./lexicons/index.js";
9import type {
10 Facet,
11 GetLeafletDocumentsParams,
12 GetSingleLeafletDocumentParams,
13 LeafletDocumentRecord,
14 LeafletDocumentView,
15 MiniDoc,
16 RichTextSegment,
17} from "./types.js";
18
19export class LiveLoaderError extends Error {
20 constructor(
21 message: string,
22 public code?: string,
23 ) {
24 super(message);
25 this.name = "LiveLoaderError";
26 }
27}
28
29export function uriToRkey(uri: string): string {
30 const u = AtUri.make(uri);
31 if (!u.rkey) {
32 throw new Error("Failed to get rkey from uri.");
33 }
34 return u.rkey;
35}
36
37export async function resolveMiniDoc(handleOrDid: string) {
38 try {
39 const response = await fetch(
40 `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${handleOrDid}`,
41 );
42
43 if (!response.ok || response.status >= 300) {
44 throw new Error(
45 `could not resolve did doc due to invalid handle or did ${handleOrDid}`,
46 );
47 }
48 const data = (await response.json()) as MiniDoc;
49
50 return data.pds;
51 } catch {
52 throw new Error(`failed to resolve handle: ${handleOrDid}`);
53 }
54}
55
56export async function getLeafletDocuments({
57 repo,
58 reverse,
59 cursor,
60 agent,
61 limit,
62}: GetLeafletDocumentsParams) {
63 const response = await agent.com.atproto.repo.listRecords({
64 repo,
65 collection: "pub.leaflet.document",
66 cursor,
67 reverse,
68 limit,
69 });
70
71 if (response.success === false) {
72 throw new LiveLoaderError(
73 "error fetching leaflet documents",
74 "DOCUMENT_FETCH_ERROR",
75 );
76 }
77
78 return {
79 documents: response?.data?.records,
80 cursor: response?.data?.cursor,
81 };
82}
83
84export async function getSingleLeafletDocument({
85 agent,
86 repo,
87 id,
88}: GetSingleLeafletDocumentParams) {
89 const response = await agent.com.atproto.repo.getRecord({
90 repo,
91 collection: "pub.leaflet.document",
92 rkey: id,
93 });
94
95 if (response.success === false) {
96 throw new LiveLoaderError(
97 "error fetching single document",
98 "DOCUMENT_FETCH_ERROR",
99 );
100 }
101
102 return response?.data;
103}
104
105export function leafletDocumentRecordToView({
106 uri,
107 cid,
108 value,
109}: {
110 uri: string;
111 cid: string;
112 value: LeafletDocumentRecord;
113}): LeafletDocumentView {
114 return {
115 rkey: uriToRkey(uri),
116 cid,
117 title: value.title,
118 description: value.description,
119 author: value.author,
120 publication: value.publication,
121 publishedAt: value.publishedAt,
122 };
123}
124
125export function leafletBlocksToHTML(record: {
126 id: string;
127 uri: string;
128 cid: string;
129 value: LeafletDocumentRecord;
130}) {
131 let html = "";
132 const firstPage = record.value.pages[0];
133 let blocks: PubLeafletPagesLinearDocument.Block[] = [];
134 if (is(PubLeafletPagesLinearDocument.mainSchema, firstPage)) {
135 blocks = firstPage.blocks || [];
136 }
137
138 for (const block of blocks) {
139 if (is(PubLeafletBlocksText.mainSchema, block.block)) {
140 const rt = new RichText({
141 text: block.block.plaintext,
142 facets: block.block.facets || [],
143 });
144 const children = [];
145 for (const segment of rt.segments()) {
146 const link = segment.facet?.find(
147 (segment) => segment.$type === "pub.leaflet.richtext.facet#link",
148 );
149 const isBold = segment.facet?.find(
150 (segment) => segment.$type === "pub.leaflet.richtext.facet#bold",
151 );
152 const isCode = segment.facet?.find(
153 (segment) => segment.$type === "pub.leaflet.richtext.facet#code",
154 );
155 const isStrikethrough = segment.facet?.find(
156 (segment) =>
157 segment.$type === "pub.leaflet.richtext.facet#strikethrough",
158 );
159 const isUnderline = segment.facet?.find(
160 (segment) => segment.$type === "pub.leaflet.richtext.facet#underline",
161 );
162 const isItalic = segment.facet?.find(
163 (segment) => segment.$type === "pub.leaflet.richtext.facet#italic",
164 );
165 if (isCode) {
166 children.push(` <code>
167 ${segment.text}
168 </code>`);
169 } else if (link) {
170 children.push(
171 ` <a
172 href="${link.uri}"
173 target="_blank"
174 >
175 ${segment.text}
176 </a>`,
177 );
178 } else if (isBold) {
179 children.push(`<b>${segment.text}</b>`);
180 } else if (isStrikethrough) {
181 children.push(`<s>${segment.text}</s>`);
182 } else if (isUnderline) {
183 children.push(
184 `<span style="text-decoration:underline;">${segment.text}</span>`,
185 );
186 } else if (isItalic) {
187 children.push(`<i>${segment.text}</i>`);
188 } else {
189 children.push(
190 `
191 ${segment.text}
192 `,
193 );
194 }
195 }
196 html += `<p>${children.join("\n")}</p>`;
197 }
198
199 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
200 if (block.block.level === 1) {
201 html += `<h2>${block.block.plaintext}</h2>`;
202 }
203 }
204 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
205 if (block.block.level === 2) {
206 html += `<h3>${block.block.plaintext}</h3>`;
207 }
208 }
209 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
210 if (block.block.level === 3) {
211 html += `<h4>${block.block.plaintext}</h4>`;
212 }
213 }
214 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) {
215 if (!block.block.level) {
216 html += `<h6>${block.block.plaintext}</h6>`;
217 }
218 }
219 }
220
221 return sanitizeHTML(html);
222}
223
224export class RichText {
225 unicodeText: UnicodeString;
226 facets?: Facet[];
227
228 constructor(props: { text: string; facets: Facet[] }) {
229 this.unicodeText = new UnicodeString(props.text);
230 this.facets = props.facets;
231 if (this.facets) {
232 this.facets = this.facets
233 .filter((facet) => facet.index.byteStart <= facet.index.byteEnd)
234 .sort((a, b) => a.index.byteStart - b.index.byteStart);
235 }
236 }
237
238 *segments(): Generator<RichTextSegment, void, void> {
239 const facets = this.facets || [];
240 if (!facets.length) {
241 yield { text: this.unicodeText.utf16 };
242 return;
243 }
244
245 let textCursor = 0;
246 let facetCursor = 0;
247 do {
248 const currFacet = facets[facetCursor];
249 if (currFacet) {
250 if (textCursor < currFacet.index.byteStart) {
251 yield {
252 text: this.unicodeText.slice(textCursor, currFacet.index.byteStart),
253 };
254 } else if (textCursor > currFacet.index.byteStart) {
255 facetCursor++;
256 continue;
257 }
258 if (currFacet.index.byteStart < currFacet.index.byteEnd) {
259 const subtext = this.unicodeText.slice(
260 currFacet.index.byteStart,
261 currFacet.index.byteEnd,
262 );
263 if (!subtext.trim()) {
264 // dont empty string entities
265 yield { text: subtext };
266 } else {
267 yield { text: subtext, facet: currFacet.features };
268 }
269 }
270 textCursor = currFacet.index.byteEnd;
271 facetCursor++;
272 }
273 } while (facetCursor < facets.length);
274 if (textCursor < this.unicodeText.length) {
275 yield {
276 text: this.unicodeText.slice(textCursor, this.unicodeText.length),
277 };
278 }
279 }
280}