CLI tool to sync your Markdown to Leaflet
leafletpub atproto cli markdown
31
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 357 lines 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}