CLI tool to sync your Markdown to Leaflet
leafletpub atproto cli markdown
at main 12 kB view raw
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}