mermaid-to-excalidraw-cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # mermaid-to-excalidraw-cli
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![GitHub issues](https://img.shields.io/github/issues/ayurkin/mermaid-to-excalidraw-cli)](https://github.com/ayurkin/mermaid-to-excalidraw-cli/issues)
5
+
6
+ Convert Mermaid (`.mmd`) diagrams into Excalidraw (`.excalidraw`) using a headless Chromium runtime for accurate layout and text metrics.
7
+
8
+ ## Why
9
+
10
+ - Mermaid is easy to edit as text.
11
+ - Excalidraw is great for visual docs and hand edits.
12
+ - This tool keeps Mermaid as the source of truth while generating readable Excalidraw output.
13
+
14
+ ## Features
15
+
16
+ - High-fidelity conversion using real browser rendering (Playwright + Chromium)
17
+ - Converts single files or entire folders (recursive)
18
+ - Deterministic output suitable for version control
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install
24
+ npx playwright install chromium
25
+ ```
26
+
27
+ For global usage:
28
+
29
+ ```bash
30
+ npm link
31
+ ```
32
+
33
+ After publishing to npm:
34
+
35
+ ```bash
36
+ npm install -g mermaid-to-excalidraw-cli
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ mmd2excalidraw <input> [output]
43
+ ```
44
+
45
+ ### Examples
46
+
47
+ ```bash
48
+ # Convert a single file
49
+ mmd2excalidraw docs/architecture/service-overview.mmd
50
+
51
+ # Convert all .mmd files in a directory (recursive)
52
+ mmd2excalidraw docs/architecture
53
+
54
+ # Convert directory and write outputs to a separate folder
55
+ mmd2excalidraw docs/architecture -o docs/architecture
56
+ ```
57
+
58
+ ## Options
59
+
60
+ - `--font-size <number>`: Mermaid font size (default: 16)
61
+ - `--curve <linear|basis>`: Flowchart curve style (default: linear)
62
+ - `--max-edges <number>`: Max edges (default: 500)
63
+ - `--max-text-size <number>`: Max text size (default: 50000)
64
+ - `-o, --output <path>`: Output file or directory
65
+ - `-h, --help`: Show help
66
+
67
+ ## Output
68
+
69
+ The CLI writes Excalidraw scene JSON with both shapes and text elements, ready to open in Excalidraw or the VS Code extension.
70
+
71
+ ## Troubleshooting
72
+
73
+ - **Playwright error / Chromium missing**
74
+ Run `npx playwright install chromium`.
75
+
76
+ - **Conversion hangs**
77
+ Ensure no firewall blocks local loopback access.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ node bin/mmd2excalidraw.mjs --help
83
+ ```
84
+
85
+ ## Publish
86
+
87
+ 1. Update `version` in `package.json`
88
+ 2. `npm publish`
89
+
90
+ ## License
91
+
92
+ MIT
93
+
94
+ ## Credits
95
+
96
+ This CLI is built on top of the official Excalidraw Mermaid converter:
97
+ https://github.com/excalidraw/mermaid-to-excalidraw
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import http from "node:http";
7
+ import { fileURLToPath } from "node:url";
8
+ import { chromium } from "playwright";
9
+
10
+ const DEFAULTS = {
11
+ fontSize: 16,
12
+ curve: "linear",
13
+ maxEdges: 500,
14
+ maxTextSize: 50000,
15
+ };
16
+
17
+ function printHelp() {
18
+ const helpText = `Usage:
19
+ mmd2excalidraw <input> [output]
20
+
21
+ Options:
22
+ -o, --output <path> Output file or directory
23
+ --font-size <number> Mermaid font size (default: ${DEFAULTS.fontSize})
24
+ --curve <linear|basis> Mermaid flowchart curve (default: ${DEFAULTS.curve})
25
+ --max-edges <number> Max edges (default: ${DEFAULTS.maxEdges})
26
+ --max-text-size <number> Max text size (default: ${DEFAULTS.maxTextSize})
27
+ -h, --help Show this help
28
+
29
+ Notes:
30
+ Requires Playwright with Chromium installed:
31
+ npx playwright install chromium
32
+
33
+ Examples:
34
+ mmd2excalidraw docs/architecture/service-overview.mmd
35
+ mmd2excalidraw docs/architecture -o docs/architecture
36
+ `;
37
+ console.log(helpText);
38
+ }
39
+
40
+ function parseArgs(argv) {
41
+ const options = { ...DEFAULTS };
42
+ let output = null;
43
+ const positionals = [];
44
+
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const arg = argv[i];
47
+ if (arg === "-h" || arg === "--help") {
48
+ printHelp();
49
+ process.exit(0);
50
+ }
51
+
52
+ if (arg === "-o" || arg === "--output") {
53
+ output = argv[i + 1];
54
+ i += 1;
55
+ continue;
56
+ }
57
+
58
+ if (arg === "--font-size") {
59
+ options.fontSize = Number.parseInt(argv[i + 1], 10);
60
+ i += 1;
61
+ continue;
62
+ }
63
+
64
+ if (arg === "--curve") {
65
+ options.curve = argv[i + 1] || DEFAULTS.curve;
66
+ i += 1;
67
+ continue;
68
+ }
69
+
70
+ if (arg === "--max-edges") {
71
+ options.maxEdges = Number.parseInt(argv[i + 1], 10);
72
+ i += 1;
73
+ continue;
74
+ }
75
+
76
+ if (arg === "--max-text-size") {
77
+ options.maxTextSize = Number.parseInt(argv[i + 1], 10);
78
+ i += 1;
79
+ continue;
80
+ }
81
+
82
+ if (arg.startsWith("--")) {
83
+ throw new Error(`Unknown option: ${arg}`);
84
+ }
85
+
86
+ positionals.push(arg);
87
+ }
88
+
89
+ if (positionals.length === 0) {
90
+ printHelp();
91
+ throw new Error("Missing input path.");
92
+ }
93
+
94
+ return {
95
+ input: positionals[0],
96
+ output: output ?? positionals[1] ?? null,
97
+ options,
98
+ };
99
+ }
100
+
101
+ function getPackageRoot() {
102
+ const scriptPath = fileURLToPath(import.meta.url);
103
+ return path.resolve(path.dirname(scriptPath), "..");
104
+ }
105
+
106
+ function getContentType(filePath) {
107
+ const ext = path.extname(filePath).toLowerCase();
108
+ switch (ext) {
109
+ case ".js":
110
+ case ".mjs":
111
+ return "text/javascript";
112
+ case ".css":
113
+ return "text/css";
114
+ case ".json":
115
+ return "application/json";
116
+ case ".svg":
117
+ return "image/svg+xml";
118
+ case ".map":
119
+ return "application/json";
120
+ default:
121
+ return "application/octet-stream";
122
+ }
123
+ }
124
+
125
+ function createServer() {
126
+ const packageRoot = getPackageRoot();
127
+ const moduleRoots = new Map([
128
+ [
129
+ "/modules/mermaid-to-excalidraw/",
130
+ path.join(
131
+ packageRoot,
132
+ "node_modules",
133
+ "@excalidraw",
134
+ "mermaid-to-excalidraw",
135
+ "dist"
136
+ ),
137
+ ],
138
+ [
139
+ "/modules/mermaid/",
140
+ path.join(packageRoot, "node_modules", "mermaid", "dist"),
141
+ ],
142
+ [
143
+ "/modules/nanoid/",
144
+ path.join(packageRoot, "node_modules", "nanoid"),
145
+ ],
146
+ [
147
+ "/modules/markdown-to-text/",
148
+ path.join(packageRoot, "vendor", "markdown-to-text"),
149
+ ],
150
+ [
151
+ "/excalidraw/",
152
+ path.join(
153
+ packageRoot,
154
+ "node_modules",
155
+ "@excalidraw",
156
+ "excalidraw",
157
+ "dist"
158
+ ),
159
+ ],
160
+ [
161
+ "/react/",
162
+ path.join(packageRoot, "node_modules", "react", "umd"),
163
+ ],
164
+ [
165
+ "/react-dom/",
166
+ path.join(packageRoot, "node_modules", "react-dom", "umd"),
167
+ ],
168
+ ]);
169
+
170
+ const importMap = {
171
+ imports: {
172
+ mermaid: "/modules/mermaid/mermaid.esm.mjs",
173
+ nanoid: "/modules/nanoid/index.browser.js",
174
+ "@excalidraw/markdown-to-text":
175
+ "/modules/markdown-to-text/index.mjs",
176
+ "@excalidraw/mermaid-to-excalidraw":
177
+ "/modules/mermaid-to-excalidraw/index.js",
178
+ },
179
+ };
180
+
181
+ const html = `<!doctype html>
182
+ <html>
183
+ <head>
184
+ <meta charset="utf-8" />
185
+ <script type="importmap">${JSON.stringify(importMap)}</script>
186
+ <script src="/react/react.production.min.js"></script>
187
+ <script src="/react-dom/react-dom.production.min.js"></script>
188
+ <script src="/excalidraw/excalidraw.production.min.js"></script>
189
+ <script type="module">
190
+ import { parseMermaidToExcalidraw } from "@excalidraw/mermaid-to-excalidraw";
191
+ window.parseMermaidToExcalidraw = parseMermaidToExcalidraw;
192
+ </script>
193
+ </head>
194
+ <body></body>
195
+ </html>`;
196
+
197
+ const server = http.createServer(async (req, res) => {
198
+ try {
199
+ const url = new URL(req.url ?? "/", "http://localhost");
200
+ const pathname = url.pathname;
201
+
202
+ if (pathname === "/" || pathname === "/index.html") {
203
+ res.writeHead(200, { "Content-Type": "text/html" });
204
+ res.end(html);
205
+ return;
206
+ }
207
+
208
+ for (const [prefix, root] of moduleRoots.entries()) {
209
+ if (!pathname.startsWith(prefix)) {
210
+ continue;
211
+ }
212
+
213
+ const relativePath = pathname.slice(prefix.length);
214
+ const safePath = path
215
+ .normalize(relativePath)
216
+ .replace(/^([/\\]*\.\.)+/, "");
217
+ const filePath = path.join(root, safePath);
218
+ const stat = await fs.stat(filePath).catch(() => null);
219
+ if (!stat || !stat.isFile()) {
220
+ res.writeHead(404);
221
+ res.end("Not found");
222
+ return;
223
+ }
224
+
225
+ const fileBuffer = await fs.readFile(filePath);
226
+ res.writeHead(200, { "Content-Type": getContentType(filePath) });
227
+ res.end(fileBuffer);
228
+ return;
229
+ }
230
+
231
+ res.writeHead(404);
232
+ res.end("Not found");
233
+ } catch (error) {
234
+ res.writeHead(500);
235
+ res.end("Server error");
236
+ console.error(error);
237
+ }
238
+ });
239
+
240
+ return new Promise((resolve, reject) => {
241
+ server.listen(0, "127.0.0.1", () => {
242
+ const address = server.address();
243
+ if (!address || typeof address === "string") {
244
+ reject(new Error("Failed to start server."));
245
+ return;
246
+ }
247
+ resolve({ server, url: `http://127.0.0.1:${address.port}` });
248
+ });
249
+ });
250
+ }
251
+
252
+ async function listMermaidFiles(dir) {
253
+ const entries = await fs.readdir(dir, { withFileTypes: true });
254
+ const files = [];
255
+
256
+ for (const entry of entries) {
257
+ const fullPath = path.join(dir, entry.name);
258
+ if (entry.isDirectory()) {
259
+ files.push(...(await listMermaidFiles(fullPath)));
260
+ } else if (entry.isFile() && entry.name.endsWith(".mmd")) {
261
+ files.push(fullPath);
262
+ }
263
+ }
264
+
265
+ return files;
266
+ }
267
+
268
+ async function resolveOutputPath(inputPath, outputPath) {
269
+ if (!outputPath) {
270
+ return inputPath.replace(/\.mmd$/, ".excalidraw");
271
+ }
272
+
273
+ try {
274
+ const stat = await fs.stat(outputPath);
275
+ if (stat.isDirectory()) {
276
+ return path.join(
277
+ outputPath,
278
+ path.basename(inputPath).replace(/\.mmd$/, ".excalidraw")
279
+ );
280
+ }
281
+ } catch {
282
+ // Output does not exist yet; treat as file path.
283
+ }
284
+
285
+ return outputPath;
286
+ }
287
+
288
+ async function convertMermaidFile(page, inputPath, outputPath, options) {
289
+ const mermaid = await fs.readFile(inputPath, "utf8");
290
+
291
+ const { elements, files } = await page.evaluate(
292
+ async ({ mermaidText, opts }) => {
293
+ const config = {
294
+ flowchart: { curve: opts.curve },
295
+ themeVariables: { fontSize: `${opts.fontSize}px` },
296
+ maxEdges: opts.maxEdges,
297
+ maxTextSize: opts.maxTextSize,
298
+ };
299
+
300
+ const { elements, files } = await window.parseMermaidToExcalidraw(
301
+ mermaidText,
302
+ config
303
+ );
304
+ const convertedElements = window.ExcalidrawLib.convertToExcalidrawElements(
305
+ elements,
306
+ { regenerateIds: false }
307
+ );
308
+ return { elements: convertedElements, files: files ?? {} };
309
+ },
310
+ { mermaidText: mermaid, opts: options }
311
+ );
312
+
313
+ const scene = {
314
+ type: "excalidraw",
315
+ version: 2,
316
+ source: "https://excalidraw.com",
317
+ elements,
318
+ appState: { viewBackgroundColor: "#ffffff" },
319
+ files: files ?? {},
320
+ };
321
+
322
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
323
+ await fs.writeFile(outputPath, JSON.stringify(scene, null, 2), "utf8");
324
+ }
325
+
326
+ async function run() {
327
+ const { input, output, options } = parseArgs(process.argv.slice(2));
328
+ const inputPath = path.resolve(process.cwd(), input);
329
+ const outputPath = output ? path.resolve(process.cwd(), output) : null;
330
+
331
+ const stats = await fs.stat(inputPath);
332
+ const { server, url } = await createServer();
333
+ const browser = await chromium.launch({ headless: true });
334
+ const page = await browser.newPage();
335
+ page.on("console", (msg) => {
336
+ console.log(`[browser:${msg.type()}] ${msg.text()}`);
337
+ });
338
+ page.on("pageerror", (error) => {
339
+ console.error(`[browser:error] ${error.message}`);
340
+ });
341
+ page.on("requestfailed", (request) => {
342
+ console.error(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`);
343
+ });
344
+
345
+ try {
346
+ await page.goto(url, { waitUntil: "load" });
347
+ await page.waitForFunction(
348
+ () =>
349
+ window.parseMermaidToExcalidraw &&
350
+ window.ExcalidrawLib?.convertToExcalidrawElements,
351
+ { timeout: 60000 }
352
+ );
353
+
354
+ if (stats.isDirectory()) {
355
+ const mermaidFiles = await listMermaidFiles(inputPath);
356
+ if (mermaidFiles.length === 0) {
357
+ console.log(`No .mmd files found under ${inputPath}`);
358
+ return;
359
+ }
360
+
361
+ for (const filePath of mermaidFiles) {
362
+ const relative = path.relative(inputPath, filePath);
363
+ const targetBase = outputPath ?? inputPath;
364
+ const targetPath = path
365
+ .join(targetBase, relative)
366
+ .replace(/\.mmd$/, ".excalidraw");
367
+ console.log(`Converting ${relative}`);
368
+ await convertMermaidFile(page, filePath, targetPath, options);
369
+ }
370
+ return;
371
+ }
372
+
373
+ if (!inputPath.endsWith(".mmd")) {
374
+ throw new Error("Input file must have .mmd extension.");
375
+ }
376
+
377
+ const finalOutput = await resolveOutputPath(inputPath, outputPath);
378
+ console.log(`Converting ${path.basename(inputPath)}`);
379
+ await convertMermaidFile(page, inputPath, finalOutput, options);
380
+ } finally {
381
+ await page.close();
382
+ await browser.close();
383
+ server.close();
384
+ }
385
+ }
386
+
387
+ run()
388
+ .then(() => process.exit(0))
389
+ .catch((error) => {
390
+ console.error(error.message || error);
391
+ process.exit(1);
392
+ });
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "mermaid-to-excalidraw-cli",
3
+ "version": "0.1.1",
4
+ "description": "Convert Mermaid (.mmd) to Excalidraw (.excalidraw) using headless Chromium.",
5
+ "license": "MIT",
6
+ "author": "Aleksandr Yurkin <alx.yurkin@gmail.com>",
7
+ "type": "module",
8
+ "bin": {
9
+ "mmd2excalidraw": "bin/mmd2excalidraw.mjs"
10
+ },
11
+ "scripts": {
12
+ "start": "node bin/mmd2excalidraw.mjs --help"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "vendor",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "mermaid-to-excalidraw",
22
+ "mermaid",
23
+ "excalidraw",
24
+ "diagram",
25
+ "cli",
26
+ "converter"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+ssh://git@github.com/ayurkin/mermaid-to-excalidraw-cli.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/ayurkin/mermaid-to-excalidraw-cli/issues"
34
+ },
35
+ "homepage": "https://github.com/ayurkin/mermaid-to-excalidraw-cli",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@excalidraw/excalidraw": "0.17.1-7381-cdf6d3e",
44
+ "@excalidraw/mermaid-to-excalidraw": "1.1.4",
45
+ "playwright": "^1.50.0",
46
+ "react": "^18.2.0",
47
+ "react-dom": "^18.2.0"
48
+ }
49
+ }
@@ -0,0 +1,80 @@
1
+ export const removeMarkdown = (markdown, options = { listUnicodeChar: "" }) => {
2
+ options = options || {};
3
+ options.listUnicodeChar = Object.prototype.hasOwnProperty.call(
4
+ options,
5
+ "listUnicodeChar"
6
+ )
7
+ ? options.listUnicodeChar
8
+ : false;
9
+ options.stripListLeaders = Object.prototype.hasOwnProperty.call(
10
+ options,
11
+ "stripListLeaders"
12
+ )
13
+ ? options.stripListLeaders
14
+ : true;
15
+ options.gfm = Object.prototype.hasOwnProperty.call(options, "gfm")
16
+ ? options.gfm
17
+ : true;
18
+ options.useImgAltText = Object.prototype.hasOwnProperty.call(
19
+ options,
20
+ "useImgAltText"
21
+ )
22
+ ? options.useImgAltText
23
+ : true;
24
+ options.preserveLinks = Object.prototype.hasOwnProperty.call(
25
+ options,
26
+ "preserveLinks"
27
+ )
28
+ ? options.preserveLinks
29
+ : false;
30
+
31
+ let output = markdown || "";
32
+
33
+ output = output.replace(/^(-\s*?|\*\s*?|_\s*?){3,}\s*$/gm, "");
34
+
35
+ try {
36
+ if (options.stripListLeaders) {
37
+ if (options.listUnicodeChar) {
38
+ output = output.replace(
39
+ /^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm,
40
+ `${options.listUnicodeChar} $1`
41
+ );
42
+ } else {
43
+ output = output.replace(/^([\s\t]*)([\*\-\+]|\d+\.)\s+/gm, "$1");
44
+ }
45
+ }
46
+ if (options.gfm) {
47
+ output = output
48
+ .replace(/\n={2,}/g, "\n")
49
+ .replace(/~{3}.*\n/g, "")
50
+ .replace(/~~/g, "")
51
+ .replace(/`{3}.*\n/g, "");
52
+ }
53
+ if (options.preserveLinks) {
54
+ output = output.replace(/\[(.*?)\][\[\(](.*?)[\]\)]/g, "$1 ($2)");
55
+ }
56
+ output = output
57
+ .replace(/<[^>]*>/g, "")
58
+ .replace(/^[=\-]{2,}\s*$/g, "")
59
+ .replace(/\[\^.+?\](\: .*?$)?/g, "")
60
+ .replace(/\s{0,2}\[.*?\]: .*?$/g, "")
61
+ .replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, options.useImgAltText ? "$1" : "")
62
+ .replace(/\[(.*?)\][\[\(].*?[\]\)]/g, "$1")
63
+ .replace(/^\s{0,3}>\s?/g, "")
64
+ .replace(/(^|\n)\s{0,3}>\s?/g, "\n\n")
65
+ .replace(/^\s{1,2}\[(.*?)\]: (\S+)( \".*?\")?\s*$/g, "")
66
+ .replace(
67
+ /^(\n)?\s{0,}#{1,6}\s+| {0,}(\n)?\s{0,}#{0,} {0,}(\n)?\s{0,}$/gm,
68
+ "$1$2$3"
69
+ )
70
+ .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2")
71
+ .replace(/([\*_]{1,3})(\S.*?\S{0,1})\1/g, "$2")
72
+ .replace(/(`{3,})(.*?)\1/gm, "$2")
73
+ .replace(/`(.+?)`/g, "$1")
74
+ .replace(/\n{2,}/g, "\n\n");
75
+ } catch (error) {
76
+ console.error(error);
77
+ return markdown;
78
+ }
79
+ return output;
80
+ };