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