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

Replacable placeholders in prepended and appended docs

+143 -95
+1
bun.lock
··· 21 21 "sharp": "^0.34.4", 22 22 "unified": "^11.0.5", 23 23 "unist-util-remove-position": "^5.0.0", 24 + "unist-util-visit": "^5.0.0", 24 25 "yaml": "^2.8.1", 25 26 "yocto-spinner": "^1.0.0", 26 27 },
+1
package.json
··· 51 51 "sharp": "^0.34.4", 52 52 "unified": "^11.0.5", 53 53 "unist-util-remove-position": "^5.0.0", 54 + "unist-util-visit": "^5.0.0", 54 55 "yaml": "^2.8.1", 55 56 "yocto-spinner": "^1.0.0" 56 57 }
+13 -6
src/commands/config-cmd.ts
··· 78 78 if (config.frontmatter.uploadDateKey) 79 79 printConfigLine("Upload Date Key", wrap(config.frontmatter.uploadDateKey, '"'), 2); 80 80 } 81 - if (config.prependDocPath) { 81 + if (config.prependDoc) { 82 82 printConfigLine( 83 83 "Prepend doc", 84 - (await exists(config.prependDocPath)) ? "Found" : "NotFound", 84 + (await exists(config.prependDoc.path)) ? "Found" : "NotFound", 85 85 0, 86 - (await exists(config.prependDocPath)) ? "green" : "red" 86 + (await exists(config.prependDoc.path)) ? "green" : "red" 87 87 ); 88 + if (config.prependDoc.replacement) 89 + printConfigLine( 90 + "Replacement", 91 + Array.isArray(config.prependDoc.replacement) ? "Predefined" : "Function", 92 + 2, 93 + "green" 94 + ); 88 95 } 89 - if (config.appendDocPath) { 96 + if (config.appendDoc) { 90 97 printConfigLine( 91 98 "Append doc", 92 - (await exists(config.appendDocPath)) ? "Found" : "NotFound", 99 + (await exists(config.appendDoc.path)) ? "Found" : "NotFound", 93 100 0, 94 - (await exists(config.appendDocPath)) ? "green" : "red" 101 + (await exists(config.appendDoc.path)) ? "green" : "red" 95 102 ); 96 103 } 97 104 },
+84 -86
src/commands/sync-cmd.ts
··· 10 10 import sharp from "sharp"; 11 11 import remarkGfm from "remark-gfm"; 12 12 import remarkSqueezeParagraphs from "remark-squeeze-paragraphs"; 13 - import { gatherImages, generateBlocks } from "../conversion"; 13 + import { gatherImages, generateBlocks, replaceInAst } from "../conversion"; 14 14 import type { Blob, RecordKey, ResourceUri } from "@atcute/lexicons"; 15 15 import { glob, readFile, writeFile } from "node:fs/promises"; 16 16 import { RepoReader } from "@atcute/car/v4"; ··· 83 83 84 84 consola.log(`Uploading to ${publicationName}`); 85 85 consola.log(`Found ${uploadedDocuments.size} documents on leaflet.pub`); 86 - 87 - let prependBlocks: PubLeafletPagesLinearDocument.Block[] = []; 88 - let appendBlocks: PubLeafletPagesLinearDocument.Block[] = []; 89 - 90 - if (config.prependDocPath) { 91 - if (!(await exists(config.prependDocPath))) throw new Error("Can't find Markdown doc to prepend"); 92 - 93 - const spinner = yoctoSpinner({ text: "Prepended document" }).start(); 94 - 95 - const parser = unified().use(remarkParse).use(remarkGfm).use(remarkSqueezeParagraphs); 96 - const md = (await readFile(config.prependDocPath)).toString(); 97 - const ast = parser.parse(md); 98 - removePosition(ast); 99 - 100 - const images = gatherImages(ast.children); 101 - const uploadedImages = new Map<string, { blob: Blob; width: number; height: number }>(); 102 - 103 - for (const image of images) { 104 - const resolvedPath = resolve(join(config.prependDocPath, "../", image)); 105 - 106 - if (!(await exists(resolvedPath))) 107 - throw new Error(`Couldn't find image: ${basename(resolvedPath)}. Expected at: ${resolvedPath}`); 108 - 109 - const bytes = await readFile(resolvedPath); 110 - const metadata = await sharp(bytes).metadata(); 111 - 112 - const res = await ok( 113 - client.post("com.atproto.repo.uploadBlob", { 114 - input: new Blob([bytes]), 115 - headers: [["Content-Type", `image/${metadata.format}`]], 116 - }) 117 - ); 118 - 119 - uploadedImages.set(image, { 120 - blob: res.blob, 121 - width: metadata.width, 122 - height: metadata.height, 123 - }); 124 - } 125 - 126 - prependBlocks = generateBlocks(ast.children, uploadedImages); 127 - spinner.success(); 128 - } 129 - 130 - if (config.appendDocPath) { 131 - if (!(await exists(config.appendDocPath))) throw new Error("Can't find Markdown doc to apppend"); 132 - 133 - const spinner = yoctoSpinner({ text: "Appended document" }).start(); 134 - 135 - const parser = unified().use(remarkParse).use(remarkGfm).use(remarkSqueezeParagraphs); 136 - const md = (await readFile(config.appendDocPath)).toString(); 137 - const ast = parser.parse(md); 138 - removePosition(ast); 139 - 140 - const images = gatherImages(ast.children); 141 - const uploadedImages = new Map<string, { blob: Blob; width: number; height: number }>(); 142 - 143 - for (const image of images) { 144 - const resolvedPath = resolve(join(config.appendDocPath, "../", image)); 145 - 146 - if (!(await exists(resolvedPath))) 147 - throw new Error(`Couldn't find image: ${basename(resolvedPath)}. Expected at: ${resolvedPath}`); 148 - 149 - const bytes = await readFile(resolvedPath); 150 - const metadata = await sharp(bytes).metadata(); 151 - 152 - const res = await ok( 153 - client.post("com.atproto.repo.uploadBlob", { 154 - input: new Blob([bytes]), 155 - headers: [["Content-Type", `image/${metadata.format}`]], 156 - }) 157 - ); 158 - 159 - uploadedImages.set(image, { 160 - blob: res.blob, 161 - width: metadata.width, 162 - height: metadata.height, 163 - }); 164 - } 165 - 166 - appendBlocks = generateBlocks(ast.children, uploadedImages); 167 - spinner.success(); 168 - } 169 86 170 87 const parser = unified() 171 88 .use(remarkParse) ··· 195 112 196 113 for (const file of files) { 197 114 const path = config.glob.base ? join(config.glob.base, file) : "./" + file; 198 - if (config.prependDocPath && resolve(config.prependDocPath) == resolve(path)) continue; 199 - if (config.appendDocPath && resolve(config.appendDocPath) == resolve(path)) continue; 115 + if (config.prependDoc && resolve(config.prependDoc.path) == resolve(path)) continue; 116 + if (config.appendDoc && resolve(config.appendDoc.path) == resolve(path)) continue; 200 117 201 118 const spinner = yoctoSpinner({ text: file }).start(); 119 + 120 + let prependBlocks: PubLeafletPagesLinearDocument.Block[] = []; 121 + let appendBlocks: PubLeafletPagesLinearDocument.Block[] = []; 122 + 123 + if (config.prependDoc) { 124 + if (!(await exists(config.prependDoc.path))) throw new Error("Can't find Markdown doc to prepend"); 125 + 126 + let parser2 = unified().use(remarkParse).use(remarkGfm).use(remarkSqueezeParagraphs); 127 + const md = (await readFile(config.prependDoc.path)).toString(); 128 + const ast = parser2.parse(md); 129 + removePosition(ast); 130 + if (config.prependDoc.replacement) replaceInAst(ast, config.prependDoc.replacement, { file: file }); 131 + 132 + const images = gatherImages(ast.children); 133 + const uploadedImages = new Map<string, { blob: Blob; width: number; height: number }>(); 134 + 135 + for (const image of images) { 136 + const resolvedPath = resolve(join(config.prependDoc.path, "../", image)); 137 + 138 + if (!(await exists(resolvedPath))) 139 + throw new Error(`Couldn't find image: ${basename(resolvedPath)}. Expected at: ${resolvedPath}`); 140 + 141 + const bytes = await readFile(resolvedPath); 142 + const metadata = await sharp(bytes).metadata(); 143 + 144 + const res = await ok( 145 + client.post("com.atproto.repo.uploadBlob", { 146 + input: new Blob([bytes]), 147 + headers: [["Content-Type", `image/${metadata.format}`]], 148 + }) 149 + ); 150 + 151 + uploadedImages.set(image, { 152 + blob: res.blob, 153 + width: metadata.width, 154 + height: metadata.height, 155 + }); 156 + } 157 + 158 + prependBlocks = generateBlocks(ast.children, uploadedImages); 159 + } 160 + 161 + if (config.appendDoc) { 162 + if (!(await exists(config.appendDoc.path))) throw new Error("Can't find Markdown doc to apppend"); 163 + 164 + let parser2 = unified().use(remarkParse).use(remarkGfm).use(remarkSqueezeParagraphs); 165 + const md = (await readFile(config.appendDoc.path)).toString(); 166 + const ast = parser2.parse(md); 167 + parser2.runSync(ast); 168 + removePosition(ast); 169 + if (config.appendDoc.replacement) replaceInAst(ast, config.appendDoc.replacement, { file: file }); 170 + 171 + const images = gatherImages(ast.children); 172 + const uploadedImages = new Map<string, { blob: Blob; width: number; height: number }>(); 173 + 174 + for (const image of images) { 175 + const resolvedPath = resolve(join(config.appendDoc.path, "../", image)); 176 + 177 + if (!(await exists(resolvedPath))) 178 + throw new Error(`Couldn't find image: ${basename(resolvedPath)}. Expected at: ${resolvedPath}`); 179 + 180 + const bytes = await readFile(resolvedPath); 181 + const metadata = await sharp(bytes).metadata(); 182 + 183 + const res = await ok( 184 + client.post("com.atproto.repo.uploadBlob", { 185 + input: new Blob([bytes]), 186 + headers: [["Content-Type", `image/${metadata.format}`]], 187 + }) 188 + ); 189 + 190 + uploadedImages.set(image, { 191 + blob: res.blob, 192 + width: metadata.width, 193 + height: metadata.height, 194 + }); 195 + } 196 + 197 + appendBlocks = generateBlocks(ast.children, uploadedImages); 198 + } 199 + 202 200 const md = (await readFile(config.glob.base ? join(config.glob.base, file) : file)).toString(); 203 201 const ast = parser.parse(md); 204 202 removePosition(ast);
+7 -2
src/config.ts
··· 1 1 import type { ResourceUri } from "@atcute/lexicons"; 2 2 3 + export interface ReplacementCtx { 4 + file: string; 5 + } 6 + export type Replacement = [[string, string]] | ((key: string, ctx: ReplacementCtx) => string | undefined); 7 + 3 8 export interface Config { 4 9 glob: { pattern: string; base?: string }; 5 10 frontmatter?: { type: "yaml"; titleKey: string; descriptionKey?: string; uploadDateKey?: string }; 6 11 publicationUri?: ResourceUri; 7 - prependDocPath?: string; 8 - appendDocPath?: string; 12 + prependDoc?: { path: string; replacement?: Replacement }; 13 + appendDoc?: { path: string; replacement?: Replacement }; 9 14 } 10 15 11 16 export function defineConfig(config: Config) {
+37 -1
src/conversion.ts
··· 3 3 PubLeafletPagesLinearDocument, 4 4 PubLeafletRichtextFacet, 5 5 } from "@atcute/leaflet"; 6 - import type { BlockContent, DefinitionContent, PhrasingContent, RootContent } from "mdast"; 6 + import type { BlockContent, DefinitionContent, Nodes, PhrasingContent, RootContent } from "mdast"; 7 7 import type { Blob } from "@atcute/lexicons"; 8 + import type { Replacement, ReplacementCtx } from "./config"; 9 + import { visit } from "unist-util-visit"; 8 10 9 11 export function generateBlocks( 10 12 children: RootContent[], ··· 316 318 317 319 return { text, facets, offset, blocks }; 318 320 } 321 + 322 + export function replaceInAst(tree: Nodes, replacement: Replacement, context: ReplacementCtx) { 323 + const regex = /{{([-\w]+?)}}/g; 324 + 325 + const getValue = (key: string) => { 326 + if (Array.isArray(replacement)) { 327 + return replacement.find((val) => val[0] == key)?.[1]; 328 + } else { 329 + return replacement(key, context); 330 + } 331 + }; 332 + 333 + visit(tree, "text", function (node, index, parent) { 334 + const regexRes = regex.exec(node.value); 335 + if (regexRes) { 336 + const key = [...regexRes][1]!; 337 + const val = getValue(key); 338 + if (val) 339 + node.value = 340 + node.value.substring(0, regexRes.index) + val + node.value.substring(regexRes.index + regexRes[0].length); 341 + } 342 + }); 343 + 344 + visit(tree, "link", function (node, index, parent) { 345 + const regexRes = regex.exec(node.url); 346 + if (regexRes) { 347 + const key = [...regexRes][1]!; 348 + const val = getValue(key); 349 + if (val) 350 + node.url = 351 + node.url.substring(0, regexRes.index) + val + node.url.substring(regexRes.index + regexRes[0].length); 352 + } 353 + }); 354 + }