CLI tool to sync your Markdown to Leaflet
leafletpub
atproto
cli
markdown
1import type {
2 PubLeafletBlocksUnorderedList,
3 PubLeafletPagesLinearDocument,
4 PubLeafletRichtextFacet,
5} from "@atcute/leaflet";
6import type { BlockContent, DefinitionContent, Nodes, PhrasingContent, RootContent } from "mdast";
7import type { Blob } from "@atcute/lexicons";
8import type { Replacement, ReplacementCtx } from "./config";
9import { visit } from "unist-util-visit";
10
11export function generateBlocks(
12 children: RootContent[],
13 uploadedImages: Map<string, { blob: Blob; width: number; height: number }>,
14 codeblockTheme?: string
15) {
16 codeblockTheme ??= "catppuccin-mocha";
17 return children
18 .flatMap((val): PubLeafletPagesLinearDocument.Block | PubLeafletPagesLinearDocument.Block[] | null => {
19 if (val.type == "heading") {
20 const { text, facets } = getTextAndFacets(val.children, "", [], uploadedImages);
21
22 return {
23 $type: "pub.leaflet.pages.linearDocument#block",
24 block: {
25 $type: "pub.leaflet.blocks.header",
26 plaintext: text,
27 level: Math.min(val.depth, 3),
28 facets: facets,
29 },
30 };
31 } else if (val.type == "thematicBreak") {
32 return {
33 $type: "pub.leaflet.pages.linearDocument#block",
34 block: { $type: "pub.leaflet.blocks.horizontalRule" },
35 };
36 } else if (val.type == "paragraph") {
37 const { text, facets, blocks } = getTextAndFacets(val.children, "", [], uploadedImages);
38
39 if (blocks.length > 0) {
40 return blocks;
41 }
42
43 return {
44 $type: "pub.leaflet.pages.linearDocument#block",
45 block: {
46 $type: "pub.leaflet.blocks.text",
47 plaintext: text,
48 facets: facets,
49 },
50 };
51 } else if (val.type == "code") {
52 return {
53 $type: "pub.leaflet.pages.linearDocument#block",
54 block: {
55 $type: "pub.leaflet.blocks.code",
56 plaintext: val.value,
57 language: val.lang == null ? undefined : val.lang,
58 syntaxHighlightingTheme: codeblockTheme,
59 },
60 };
61 } else if (val.type == "blockquote") {
62 for (const child of val.children) {
63 if (child.type == "paragraph") {
64 const { text, facets } = getTextAndFacets(child.children, "", [], uploadedImages);
65
66 return {
67 $type: "pub.leaflet.pages.linearDocument#block",
68 block: { $type: "pub.leaflet.blocks.blockquote", plaintext: text, facets: facets },
69 };
70 }
71 }
72 } else if (val.type == "list") {
73 return {
74 $type: "pub.leaflet.pages.linearDocument#block",
75 block: {
76 $type: "pub.leaflet.blocks.unorderedList",
77 children: val.children.map((listItem) => {
78 const listChild: PubLeafletBlocksUnorderedList.ListItem = {
79 $type: "pub.leaflet.blocks.unorderedList#listItem",
80 content: undefined!,
81 };
82
83 //only headers, images and text allowed
84 for (const child of listItem.children) {
85 if (child.type == "heading") {
86 const { text, facets } = getTextAndFacets(child.children, "", [], uploadedImages);
87 listChild.content = {
88 $type: "pub.leaflet.blocks.header",
89 plaintext: text,
90 facets: facets,
91 level: Math.min(child.depth, 3),
92 };
93 break;
94 } else if (child.type == "paragraph") {
95 const check = listItem.checked == undefined ? "" : listItem.checked ? "[ ] " : "[x] ";
96 const { text, facets } = getTextAndFacets(child.children, "", [], uploadedImages);
97 listChild.content = { $type: "pub.leaflet.blocks.text", plaintext: check + text, facets: facets };
98 break;
99 }
100 }
101
102 return listChild;
103 }),
104 },
105 };
106 }
107
108 return null;
109 })
110 .filter((val) => !!val);
111}
112
113export function gatherImages(children: RootContent[], res?: string[]) {
114 res ??= [];
115
116 const walkPhrasingContent = (children: PhrasingContent[], res: string[]) => {
117 for (const child of children) {
118 if (child.type == "image") {
119 res.push(child.url);
120 } else if ("children" in child) {
121 res = walkPhrasingContent(child.children, res);
122 }
123 }
124
125 return res;
126 };
127
128 const walkBlockDefinitionContent = (children: (BlockContent | DefinitionContent)[], res: string[]) => {
129 for (const child of children) {
130 if (child.type == "paragraph") {
131 res = walkPhrasingContent(child.children, res);
132 } else if (child.type == "blockquote") {
133 res = walkBlockDefinitionContent(child.children, res);
134 } else if (child.type == "list") {
135 for (const listItem of child.children) {
136 res = walkBlockDefinitionContent(listItem.children, res);
137 }
138 }
139 }
140
141 return res;
142 };
143
144 for (const child of children) {
145 if (child.type == "image") {
146 res.push(child.url);
147 } else if (child.type == "paragraph") {
148 res = walkPhrasingContent(child.children, res);
149 } else if (child.type == "list") {
150 for (const listItem of child.children) {
151 res = walkBlockDefinitionContent(listItem.children, res);
152 }
153 }
154 }
155
156 return res;
157}
158
159export function getTextAndFacets(
160 children: PhrasingContent[],
161 text: string,
162 facets: PubLeafletRichtextFacet.Main[],
163 uploadedImages: Map<string, { blob: Blob; width: number; height: number }>,
164 offset?: number,
165 parents?: PhrasingContent[]
166): {
167 text: string;
168 facets: PubLeafletRichtextFacet.Main[];
169 offset: number;
170 blocks: PubLeafletPagesLinearDocument.Block[];
171} {
172 offset ??= 0;
173 parents ??= [];
174 let blocks: PubLeafletPagesLinearDocument.Block[] = [];
175 const encoder = new TextEncoder("utf-8");
176
177 for (const content of children) {
178 if (content.type == "text") {
179 const availableFacets = parents.filter(
180 (val) => val.type == "emphasis" || val.type == "strong" || val.type == "link" || val.type == "delete"
181 );
182
183 if (availableFacets.length > 0)
184 facets = [
185 {
186 $type: "pub.leaflet.richtext.facet",
187 features: parents
188 .filter(
189 (val) => val.type == "emphasis" || val.type == "strong" || val.type == "link" || val.type == "delete"
190 )
191 .map((val): PubLeafletRichtextFacet.Main["features"][0] => {
192 if (val.type == "emphasis") {
193 return { $type: "pub.leaflet.richtext.facet#italic" };
194 }
195
196 if (val.type == "link") {
197 return {
198 $type: "pub.leaflet.richtext.facet#link",
199 uri: val.url as PubLeafletRichtextFacet.Link["uri"],
200 };
201 }
202
203 if (val.type == "delete") {
204 return {
205 $type: "pub.leaflet.richtext.facet#strikethrough",
206 };
207 }
208
209 return { $type: "pub.leaflet.richtext.facet#bold" };
210 }),
211 index: {
212 $type: "pub.leaflet.richtext.facet#byteSlice",
213 byteStart: offset,
214 byteEnd: offset + encoder.encode(content.value).length,
215 },
216 },
217 ...facets,
218 ];
219
220 text += content.value;
221 offset += encoder.encode(content.value).length;
222 } else if (content.type == "break") {
223 blocks.push({
224 $type: "pub.leaflet.pages.linearDocument#block",
225 block: {
226 $type: "pub.leaflet.blocks.text",
227 plaintext: text,
228 facets: facets,
229 },
230 });
231
232 text = "";
233 facets = [];
234 offset = 0;
235 } else if (
236 content.type == "emphasis" ||
237 content.type == "strong" ||
238 content.type == "link" ||
239 content.type == "delete"
240 ) {
241 const res = getTextAndFacets(content.children, text, facets, uploadedImages, offset, [content, ...parents!]);
242 facets = [...res.facets];
243
244 offset = res.offset;
245 text = res.text;
246 blocks = [...blocks, ...res.blocks];
247 } else if (content.type == "inlineCode") {
248 facets = [
249 {
250 $type: "pub.leaflet.richtext.facet",
251 index: {
252 $type: "pub.leaflet.richtext.facet#byteSlice",
253 byteStart: offset,
254 byteEnd: offset + encoder.encode(content.value).length,
255 },
256 features: [{ $type: "pub.leaflet.richtext.facet#code" }],
257 },
258 ...facets,
259 ];
260 text += content.value;
261 offset += encoder.encode(content.value).length;
262 } else if (content.type == "image") {
263 if (text != "") {
264 blocks.push({
265 $type: "pub.leaflet.pages.linearDocument#block",
266 block: {
267 $type: "pub.leaflet.blocks.text",
268 plaintext: text,
269 facets: facets,
270 },
271 });
272
273 text = "";
274 facets = [];
275 offset = 0;
276 }
277
278 blocks.push({
279 $type: "pub.leaflet.pages.linearDocument#block",
280 block: {
281 $type: "pub.leaflet.blocks.image",
282 image: uploadedImages.get(content.url)!.blob,
283 alt: !content.alt ? undefined : content.alt,
284 aspectRatio: {
285 $type: "pub.leaflet.blocks.image#aspectRatio",
286 height: uploadedImages.get(content.url)!.height,
287 width: uploadedImages.get(content.url)!.width,
288 },
289 },
290 });
291 }
292 }
293
294 facets = facets.filter((val, _i, array) => {
295 const samePosFacets = array.filter(
296 (facet) => facet.index.byteStart == val.index.byteStart && facet.index.byteEnd == val.index.byteEnd
297 );
298 if (samePosFacets.length > 1) {
299 const mostFacetsObj = samePosFacets.reduce((prev, curr) =>
300 prev.features.length < curr.features.length ? curr : prev
301 );
302 if (val == mostFacetsObj) return true;
303 return false;
304 }
305 return true;
306 });
307
308 if (blocks.length > 0) {
309 blocks.push({
310 $type: "pub.leaflet.pages.linearDocument#block",
311 block: { $type: "pub.leaflet.blocks.text", plaintext: text, facets: facets },
312 });
313 }
314
315 blocks = blocks.filter((val) => {
316 if (val.block.$type == "pub.leaflet.blocks.text" && val.block.plaintext.trim() == "") {
317 return false;
318 }
319 return true;
320 });
321
322 return { text, facets, offset, blocks };
323}
324
325export function replaceInAst(tree: Nodes, replacement: Replacement, context: ReplacementCtx) {
326 const regex = /{{([-\w]+?)}}/g;
327
328 const getValue = (key: string) => {
329 if (Array.isArray(replacement)) {
330 return replacement.find((val) => val[0] == key)?.[1];
331 } else {
332 return replacement(key, context);
333 }
334 };
335
336 visit(tree, "text", function (node, index, parent) {
337 const regexRes = regex.exec(node.value);
338 if (regexRes) {
339 const key = [...regexRes][1]!;
340 const val = getValue(key);
341 if (val)
342 node.value =
343 node.value.substring(0, regexRes.index) + val + node.value.substring(regexRes.index + regexRes[0].length);
344 }
345 });
346
347 visit(tree, "link", function (node, index, parent) {
348 const regexRes = regex.exec(node.url);
349 if (regexRes) {
350 const key = [...regexRes][1]!;
351 const val = getValue(key);
352 if (val)
353 node.url =
354 node.url.substring(0, regexRes.index) + val + node.url.substring(regexRes.index + regexRes[0].length);
355 }
356 });
357}