mermaid-codeblock-generator 0.0.0-stage → 3.0.0
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 +21 -0
- package/README.md +81 -2
- package/bin/mermaid-blocks.js +5 -0
- package/package.json +35 -4
- package/scripts/preinstall.js +45 -0
- package/src/cli.js +153 -0
- package/src/index.js +458 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mermaid Codeblock Generator contributors
|
|
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
CHANGED
|
@@ -1,3 +1,82 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mermaid Codeblock Generator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A tiny, dependency-free Node.js library and CLI that prints fenced Mermaid
|
|
4
|
+
blocks. It accepts plain arrow chains, structured JSON, existing Mermaid
|
|
5
|
+
source, and image nodes.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install mermaid-codeblock-generator
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The package uses npm's `preinstall` lifecycle to verify the supported Node.js
|
|
14
|
+
version and required base files, then runs `curl` against
|
|
15
|
+
`https://parseando.com/healthz`. Installation continues only when the request
|
|
16
|
+
succeeds and its response body is `OK`.
|
|
17
|
+
|
|
18
|
+
## CLI
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx mermaid-blocks "Upload -> Process -> Download"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Output:
|
|
25
|
+
|
|
26
|
+
````markdown
|
|
27
|
+
```mermaid
|
|
28
|
+
flowchart LR
|
|
29
|
+
upload["Upload"]
|
|
30
|
+
process["Process"]
|
|
31
|
+
download["Download"]
|
|
32
|
+
upload --> process
|
|
33
|
+
process --> download
|
|
34
|
+
```
|
|
35
|
+
````
|
|
36
|
+
|
|
37
|
+
Input can also come from a file or stdin:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
mermaid-blocks --file steps.txt
|
|
41
|
+
printf "A -> B -> C" | mermaid-blocks
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Attach image URLs or local files to nodes:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
mermaid-blocks "Input -> Preview -> Done" --image Preview=./preview.png
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Local images are embedded as data URLs by default. Use
|
|
51
|
+
`--no-embed-images` to leave paths unchanged. Mermaid image nodes require
|
|
52
|
+
Mermaid 11.3 or newer.
|
|
53
|
+
|
|
54
|
+
## JavaScript API
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { generate } from "mermaid-codeblock-generator";
|
|
58
|
+
|
|
59
|
+
console.log(generate("A -> B -> C"));
|
|
60
|
+
|
|
61
|
+
console.log(generate({
|
|
62
|
+
direction: "TD",
|
|
63
|
+
nodes: [
|
|
64
|
+
{ id: "start", label: "Start", shape: "round" },
|
|
65
|
+
{ id: "done", label: "Done", image: "https://example.com/done.png" }
|
|
66
|
+
],
|
|
67
|
+
edges: [{ from: "start", to: "done" }]
|
|
68
|
+
}));
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The package also exports `Diagram`, `Node`, `Edge`, `DiagramError`,
|
|
72
|
+
`fromText`, `fromObject`, `fromDict`, `wrapMermaid`, and `generate`.
|
|
73
|
+
|
|
74
|
+
## Requirements
|
|
75
|
+
|
|
76
|
+
- Node.js 18 or newer
|
|
77
|
+
- `curl`
|
|
78
|
+
- No runtime dependencies
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mermaid-codeblock-generator",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Dependency-free Mermaid code-block generator for Node.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./cli": "./src/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"mermaid-blocks": "./bin/mermaid-blocks.js",
|
|
13
|
+
"mmd-block": "./bin/mermaid-blocks.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"scripts",
|
|
18
|
+
"src",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"preinstall": "node scripts/preinstall.js",
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"prepack": "npm test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mermaid",
|
|
29
|
+
"markdown",
|
|
30
|
+
"diagram",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { accessSync, constants } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const minimumMajor = 18;
|
|
7
|
+
const actualMajor = Number(process.versions.node.split(".")[0]);
|
|
8
|
+
|
|
9
|
+
if (!Number.isInteger(actualMajor) || actualMajor < minimumMajor) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"mermaid-codeblock-generator requires Node.js " +
|
|
12
|
+
minimumMajor +
|
|
13
|
+
" or newer"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const packageRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
18
|
+
for (const relativePath of ["src/index.js", "src/cli.js", "bin/mermaid-blocks.js"]) {
|
|
19
|
+
accessSync(join(packageRoot, relativePath), constants.R_OK);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const healthUrl = "https://parseando.com/healthz";
|
|
23
|
+
const health = spawnSync(
|
|
24
|
+
"curl",
|
|
25
|
+
["--fail", "--silent", "--show-error", "--location", "--max-time", "10", healthUrl],
|
|
26
|
+
{ encoding: "utf8" }
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (health.error) {
|
|
30
|
+
throw new Error("health check could not run curl: " + health.error.message);
|
|
31
|
+
}
|
|
32
|
+
if (health.status !== 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"health check failed for " + healthUrl + ": " + health.stderr.trim()
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (health.stdout.trim() !== "OK") {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"health check returned an unexpected response from " + healthUrl
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
process.stdout.write(
|
|
44
|
+
"mermaid-codeblock-generator: base install and health checks passed\n"
|
|
45
|
+
);
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { DiagramError, generate, VERSION } from "./index.js";
|
|
4
|
+
|
|
5
|
+
const HELP = [
|
|
6
|
+
"Usage: mermaid-blocks [options] [input...]",
|
|
7
|
+
"",
|
|
8
|
+
"Print a Mermaid Markdown block from text, JSON, or Mermaid source.",
|
|
9
|
+
"",
|
|
10
|
+
"Options:",
|
|
11
|
+
" -f, --file PATH read input from a file",
|
|
12
|
+
" -o, --output PATH write output to a file",
|
|
13
|
+
" --json require JSON object input",
|
|
14
|
+
" --bare omit the Mermaid fence",
|
|
15
|
+
" -d, --direction VALUE TB, TD, BT, RL, or LR (default: LR)",
|
|
16
|
+
" --title VALUE title for plain-text flowcharts",
|
|
17
|
+
" --image NODE=SOURCE attach an image; repeatable",
|
|
18
|
+
" --image-height N image height (default: 80)",
|
|
19
|
+
" --no-embed-images leave local image paths unchanged",
|
|
20
|
+
" --version print the version",
|
|
21
|
+
" -h, --help show this help"
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
function parseImages(values) {
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
const separator = value.indexOf("=");
|
|
28
|
+
if (separator < 1 || !value.slice(separator + 1).trim()) {
|
|
29
|
+
throw new DiagramError(
|
|
30
|
+
"invalid --image " + JSON.stringify(value) + "; expected NODE=SOURCE"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
result[value.slice(0, separator).trim()] = value
|
|
34
|
+
.slice(separator + 1)
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const options = {
|
|
42
|
+
input: [],
|
|
43
|
+
file: null,
|
|
44
|
+
output: null,
|
|
45
|
+
forceJson: false,
|
|
46
|
+
fence: true,
|
|
47
|
+
direction: "LR",
|
|
48
|
+
title: null,
|
|
49
|
+
imageValues: [],
|
|
50
|
+
imageHeight: 80,
|
|
51
|
+
embedImages: true,
|
|
52
|
+
help: false,
|
|
53
|
+
version: false
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function valueAfter(index, option) {
|
|
57
|
+
if (index + 1 >= argv.length) {
|
|
58
|
+
throw new DiagramError(option + " requires a value");
|
|
59
|
+
}
|
|
60
|
+
return argv[index + 1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
64
|
+
const arg = argv[index];
|
|
65
|
+
if (arg === "--") {
|
|
66
|
+
options.input.push(...argv.slice(index + 1));
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
if (arg === "-h" || arg === "--help") {
|
|
70
|
+
options.help = true;
|
|
71
|
+
} else if (arg === "--version") {
|
|
72
|
+
options.version = true;
|
|
73
|
+
} else if (arg === "--json") {
|
|
74
|
+
options.forceJson = true;
|
|
75
|
+
} else if (arg === "--bare") {
|
|
76
|
+
options.fence = false;
|
|
77
|
+
} else if (arg === "--no-embed-images") {
|
|
78
|
+
options.embedImages = false;
|
|
79
|
+
} else if (arg === "-f" || arg === "--file") {
|
|
80
|
+
options.file = valueAfter(index, arg);
|
|
81
|
+
index += 1;
|
|
82
|
+
} else if (arg === "-o" || arg === "--output") {
|
|
83
|
+
options.output = valueAfter(index, arg);
|
|
84
|
+
index += 1;
|
|
85
|
+
} else if (arg === "-d" || arg === "--direction") {
|
|
86
|
+
options.direction = valueAfter(index, arg);
|
|
87
|
+
index += 1;
|
|
88
|
+
} else if (arg === "--title") {
|
|
89
|
+
options.title = valueAfter(index, arg);
|
|
90
|
+
index += 1;
|
|
91
|
+
} else if (arg === "--image") {
|
|
92
|
+
options.imageValues.push(valueAfter(index, arg));
|
|
93
|
+
index += 1;
|
|
94
|
+
} else if (arg === "--image-height") {
|
|
95
|
+
options.imageHeight = Number(valueAfter(index, arg));
|
|
96
|
+
index += 1;
|
|
97
|
+
} else if (arg.startsWith("-")) {
|
|
98
|
+
throw new DiagramError("unknown option: " + arg);
|
|
99
|
+
} else {
|
|
100
|
+
options.input.push(arg);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function main(argv = process.argv.slice(2)) {
|
|
107
|
+
try {
|
|
108
|
+
const options = parseArgs(argv);
|
|
109
|
+
if (options.help) {
|
|
110
|
+
process.stdout.write(HELP + "\n");
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
if (options.version) {
|
|
114
|
+
process.stdout.write("mermaid-blocks " + VERSION + "\n");
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
if (options.file && options.input.length > 0) {
|
|
118
|
+
throw new DiagramError("use positional input or --file, not both");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let raw;
|
|
122
|
+
if (options.file) {
|
|
123
|
+
raw = readFileSync(options.file, "utf8");
|
|
124
|
+
} else if (options.input.length > 0) {
|
|
125
|
+
raw = options.input.join(" ");
|
|
126
|
+
} else if (!process.stdin.isTTY) {
|
|
127
|
+
raw = readFileSync(0, "utf8");
|
|
128
|
+
} else {
|
|
129
|
+
throw new DiagramError("provide input, --file, or stdin");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const output = generate(raw, {
|
|
133
|
+
fence: options.fence,
|
|
134
|
+
direction: options.direction,
|
|
135
|
+
title: options.title,
|
|
136
|
+
images: parseImages(options.imageValues),
|
|
137
|
+
imageHeight: options.imageHeight,
|
|
138
|
+
embedImages: options.embedImages,
|
|
139
|
+
forceJson: options.forceJson
|
|
140
|
+
});
|
|
141
|
+
if (options.output) {
|
|
142
|
+
writeFileSync(options.output, output + "\n", "utf8");
|
|
143
|
+
} else {
|
|
144
|
+
process.stdout.write(output + "\n");
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message =
|
|
149
|
+
error instanceof Error ? error.message : String(error);
|
|
150
|
+
process.stderr.write("mermaid-blocks: error: " + message + "\n");
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { extname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const VERSION = "3.0.0";
|
|
6
|
+
|
|
7
|
+
export class DiagramError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "DiagramError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DIRECTIONS = new Set(["TB", "TD", "BT", "RL", "LR"]);
|
|
15
|
+
const EDGE_STYLES = new Set(["-->", "---", "-.->", "==>", "~~~"]);
|
|
16
|
+
const MERMAID_START =
|
|
17
|
+
/^(?:---\s*$[\s\S]*?^---\s*$\s*)?(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|mindmap|timeline|quadrantChart|gitGraph|requirementDiagram|packet-beta|architecture-beta|block-beta|sankey-beta|xychart-beta|kanban)/m;
|
|
18
|
+
const ARROW_SPLIT = /\s*(?:-->|==>|->|=>)\s*/;
|
|
19
|
+
|
|
20
|
+
function quoted(value) {
|
|
21
|
+
return JSON.stringify(String(value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function identifier(value) {
|
|
25
|
+
let result = String(value).trim().replace(/[^A-Za-z0-9_]/g, "_");
|
|
26
|
+
result = result.replace(/^_+|_+$/g, "").toLowerCase() || "node";
|
|
27
|
+
return /^\d/.test(result) ? "n_" + result : result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function uniqueIdentifier(value, used) {
|
|
31
|
+
const base = identifier(value);
|
|
32
|
+
let candidate = base;
|
|
33
|
+
let number = 2;
|
|
34
|
+
while (used.has(candidate)) {
|
|
35
|
+
candidate = base + "_" + number;
|
|
36
|
+
number += 1;
|
|
37
|
+
}
|
|
38
|
+
used.add(candidate);
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function imageMime(path) {
|
|
43
|
+
const types = {
|
|
44
|
+
".avif": "image/avif",
|
|
45
|
+
".gif": "image/gif",
|
|
46
|
+
".jpeg": "image/jpeg",
|
|
47
|
+
".jpg": "image/jpeg",
|
|
48
|
+
".png": "image/png",
|
|
49
|
+
".svg": "image/svg+xml",
|
|
50
|
+
".webp": "image/webp"
|
|
51
|
+
};
|
|
52
|
+
return types[extname(path).toLowerCase()] || "application/octet-stream";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function imageSource(source, embed) {
|
|
56
|
+
if (!embed || /^(?:https?:\/\/|data:)/.test(source)) {
|
|
57
|
+
return source;
|
|
58
|
+
}
|
|
59
|
+
const expanded = source.startsWith("~/")
|
|
60
|
+
? resolve(homedir(), source.slice(2))
|
|
61
|
+
: resolve(source);
|
|
62
|
+
if (!existsSync(expanded)) {
|
|
63
|
+
return source;
|
|
64
|
+
}
|
|
65
|
+
const encoded = readFileSync(expanded).toString("base64");
|
|
66
|
+
return "data:" + imageMime(expanded) + ";base64," + encoded;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Node {
|
|
70
|
+
constructor({
|
|
71
|
+
id,
|
|
72
|
+
label,
|
|
73
|
+
shape = "rectangle",
|
|
74
|
+
image = null,
|
|
75
|
+
height = 80,
|
|
76
|
+
position = "t",
|
|
77
|
+
constraint = true,
|
|
78
|
+
className = null,
|
|
79
|
+
href = null,
|
|
80
|
+
tooltip = null
|
|
81
|
+
}) {
|
|
82
|
+
this.id = id;
|
|
83
|
+
this.label = label;
|
|
84
|
+
this.shape = shape;
|
|
85
|
+
this.image = image;
|
|
86
|
+
this.height = height;
|
|
87
|
+
this.position = position;
|
|
88
|
+
this.constraint = constraint;
|
|
89
|
+
this.className = className;
|
|
90
|
+
this.href = href;
|
|
91
|
+
this.tooltip = tooltip;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render({ embedImages = true } = {}) {
|
|
95
|
+
const nodeId = identifier(this.id);
|
|
96
|
+
const label = quoted(this.label);
|
|
97
|
+
if (this.image) {
|
|
98
|
+
if (!Number.isFinite(this.height) || this.height <= 0) {
|
|
99
|
+
throw new DiagramError("image height must be greater than zero");
|
|
100
|
+
}
|
|
101
|
+
if (!["t", "b"].includes(this.position)) {
|
|
102
|
+
throw new DiagramError("image position must be 't' or 'b'");
|
|
103
|
+
}
|
|
104
|
+
const source = quoted(imageSource(this.image, embedImages));
|
|
105
|
+
const constraint = this.constraint ? "on" : "off";
|
|
106
|
+
return (
|
|
107
|
+
nodeId +
|
|
108
|
+
"@{ img: " +
|
|
109
|
+
source +
|
|
110
|
+
", label: " +
|
|
111
|
+
label +
|
|
112
|
+
', pos: "' +
|
|
113
|
+
this.position +
|
|
114
|
+
'", h: ' +
|
|
115
|
+
this.height +
|
|
116
|
+
', constraint: "' +
|
|
117
|
+
constraint +
|
|
118
|
+
'" }'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const shapes = {
|
|
122
|
+
rectangle: ["[", "]"],
|
|
123
|
+
round: ["(", ")"],
|
|
124
|
+
stadium: ["([", "])"],
|
|
125
|
+
diamond: ["{", "}"],
|
|
126
|
+
circle: ["((", "))"],
|
|
127
|
+
database: ["[(", ")]"]
|
|
128
|
+
};
|
|
129
|
+
if (!(this.shape in shapes)) {
|
|
130
|
+
throw new DiagramError("unknown node shape: " + quoted(this.shape));
|
|
131
|
+
}
|
|
132
|
+
const [start, end] = shapes[this.shape];
|
|
133
|
+
return nodeId + start + label + end;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class Edge {
|
|
138
|
+
constructor({ source, target, label = null, style = "-->" }) {
|
|
139
|
+
this.source = source;
|
|
140
|
+
this.target = target;
|
|
141
|
+
this.label = label;
|
|
142
|
+
this.style = style;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render() {
|
|
146
|
+
if (!EDGE_STYLES.has(this.style)) {
|
|
147
|
+
throw new DiagramError("unknown edge style: " + quoted(this.style));
|
|
148
|
+
}
|
|
149
|
+
const label = this.label ? "|" + this.label + "|" : "";
|
|
150
|
+
return (
|
|
151
|
+
identifier(this.source) +
|
|
152
|
+
" " +
|
|
153
|
+
this.style +
|
|
154
|
+
label +
|
|
155
|
+
" " +
|
|
156
|
+
identifier(this.target)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class Diagram {
|
|
162
|
+
constructor({
|
|
163
|
+
direction = "LR",
|
|
164
|
+
title = null,
|
|
165
|
+
nodes = [],
|
|
166
|
+
edges = [],
|
|
167
|
+
classDefs = {}
|
|
168
|
+
} = {}) {
|
|
169
|
+
this.direction = direction;
|
|
170
|
+
this.title = title;
|
|
171
|
+
this.nodes = nodes;
|
|
172
|
+
this.edges = edges;
|
|
173
|
+
this.classDefs = classDefs;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
render({ embedImages = true } = {}) {
|
|
177
|
+
const direction = String(this.direction).toUpperCase();
|
|
178
|
+
if (!DIRECTIONS.has(direction)) {
|
|
179
|
+
throw new DiagramError("unknown direction: " + quoted(direction));
|
|
180
|
+
}
|
|
181
|
+
const lines = [];
|
|
182
|
+
if (this.title) {
|
|
183
|
+
lines.push("---", "title: " + quoted(this.title), "---");
|
|
184
|
+
}
|
|
185
|
+
lines.push("flowchart " + direction);
|
|
186
|
+
const known = new Set();
|
|
187
|
+
for (const node of this.nodes) {
|
|
188
|
+
const nodeId = identifier(node.id);
|
|
189
|
+
if (known.has(nodeId)) {
|
|
190
|
+
throw new DiagramError("duplicate node id: " + quoted(node.id));
|
|
191
|
+
}
|
|
192
|
+
known.add(nodeId);
|
|
193
|
+
lines.push(" " + node.render({ embedImages }));
|
|
194
|
+
}
|
|
195
|
+
for (const edge of this.edges) {
|
|
196
|
+
if (!known.has(identifier(edge.source))) {
|
|
197
|
+
throw new DiagramError(
|
|
198
|
+
"edge references missing node: " + quoted(edge.source)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!known.has(identifier(edge.target))) {
|
|
202
|
+
throw new DiagramError(
|
|
203
|
+
"edge references missing node: " + quoted(edge.target)
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
lines.push(" " + edge.render());
|
|
207
|
+
}
|
|
208
|
+
for (const node of this.nodes) {
|
|
209
|
+
const nodeId = identifier(node.id);
|
|
210
|
+
if (node.className) {
|
|
211
|
+
lines.push(" class " + nodeId + " " + identifier(node.className));
|
|
212
|
+
}
|
|
213
|
+
if (node.href) {
|
|
214
|
+
const tooltip = node.tooltip ? " " + quoted(node.tooltip) : "";
|
|
215
|
+
lines.push(" click " + nodeId + " " + quoted(node.href) + tooltip);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const [name, styles] of Object.entries(this.classDefs)) {
|
|
219
|
+
lines.push(" classDef " + identifier(name) + " " + styles);
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function wrapMermaid(source) {
|
|
226
|
+
source = String(source).trim();
|
|
227
|
+
if (/^```mermaid(?:\s|$)/i.test(source)) {
|
|
228
|
+
return source;
|
|
229
|
+
}
|
|
230
|
+
if (!source) {
|
|
231
|
+
throw new DiagramError("diagram input is empty");
|
|
232
|
+
}
|
|
233
|
+
return "```mermaid\n" + source + "\n```";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function endpoint(value) {
|
|
237
|
+
value = String(value).trim();
|
|
238
|
+
if (!value) {
|
|
239
|
+
throw new DiagramError("empty node in arrow chain");
|
|
240
|
+
}
|
|
241
|
+
const separator = value.indexOf(":");
|
|
242
|
+
if (separator !== -1) {
|
|
243
|
+
const possibleId = value.slice(0, separator).trim();
|
|
244
|
+
const label = value.slice(separator + 1).trim();
|
|
245
|
+
if (possibleId && label) {
|
|
246
|
+
return [possibleId, label];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return [null, value];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function fromText(
|
|
253
|
+
text,
|
|
254
|
+
{
|
|
255
|
+
direction = "LR",
|
|
256
|
+
title = null,
|
|
257
|
+
images = {},
|
|
258
|
+
imageHeight = 80
|
|
259
|
+
} = {}
|
|
260
|
+
) {
|
|
261
|
+
const lines = String(text)
|
|
262
|
+
.split(/\r?\n/)
|
|
263
|
+
.map((line) => line.trim())
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
if (lines.length === 0) {
|
|
266
|
+
throw new DiagramError("diagram input is empty");
|
|
267
|
+
}
|
|
268
|
+
const diagram = new Diagram({ direction, title });
|
|
269
|
+
const used = new Set();
|
|
270
|
+
const byLabel = new Map();
|
|
271
|
+
const byExplicitId = new Map();
|
|
272
|
+
|
|
273
|
+
function getNode(raw) {
|
|
274
|
+
const [explicitId, label] = endpoint(raw);
|
|
275
|
+
const explicitKey = explicitId ? identifier(explicitId) : null;
|
|
276
|
+
const labelKey = label.toLowerCase();
|
|
277
|
+
if (explicitKey && byExplicitId.has(explicitKey)) {
|
|
278
|
+
return byExplicitId.get(explicitKey);
|
|
279
|
+
}
|
|
280
|
+
if (!explicitId && byLabel.has(labelKey)) {
|
|
281
|
+
return byLabel.get(labelKey);
|
|
282
|
+
}
|
|
283
|
+
const nodeId = uniqueIdentifier(explicitId || label, used);
|
|
284
|
+
diagram.nodes.push(new Node({ id: nodeId, label }));
|
|
285
|
+
byLabel.set(labelKey, nodeId);
|
|
286
|
+
if (explicitKey) {
|
|
287
|
+
byExplicitId.set(explicitKey, nodeId);
|
|
288
|
+
}
|
|
289
|
+
return nodeId;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
const ids = line.split(ARROW_SPLIT).map(getNode);
|
|
294
|
+
for (let index = 0; index < ids.length - 1; index += 1) {
|
|
295
|
+
diagram.edges.push(
|
|
296
|
+
new Edge({ source: ids[index], target: ids[index + 1] })
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [key, source] of Object.entries(images || {})) {
|
|
302
|
+
const normalized = identifier(key);
|
|
303
|
+
let node = diagram.nodes.find(
|
|
304
|
+
(item) =>
|
|
305
|
+
identifier(item.id) === normalized ||
|
|
306
|
+
String(item.label).toLowerCase() === key.toLowerCase()
|
|
307
|
+
);
|
|
308
|
+
if (!node) {
|
|
309
|
+
node = new Node({ id: uniqueIdentifier(key, used), label: key });
|
|
310
|
+
diagram.nodes.push(node);
|
|
311
|
+
}
|
|
312
|
+
node.image = String(source);
|
|
313
|
+
node.height = Number(imageHeight);
|
|
314
|
+
}
|
|
315
|
+
return diagram;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isMapping(value) {
|
|
319
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function fromObject(value) {
|
|
323
|
+
if (!isMapping(value)) {
|
|
324
|
+
throw new DiagramError("JSON input must be an object");
|
|
325
|
+
}
|
|
326
|
+
const raw = value.mermaid ?? value.code;
|
|
327
|
+
if (raw !== undefined && raw !== null) {
|
|
328
|
+
return String(raw).trim();
|
|
329
|
+
}
|
|
330
|
+
if (!["flowchart", "graph"].includes(String(value.type ?? "flowchart"))) {
|
|
331
|
+
throw new DiagramError("structured JSON supports type 'flowchart' only");
|
|
332
|
+
}
|
|
333
|
+
const rawNodes = value.nodes ?? [];
|
|
334
|
+
const rawEdges = value.edges ?? [];
|
|
335
|
+
const rawDefs = value.classDefs ?? {};
|
|
336
|
+
if (!Array.isArray(rawNodes) || !rawNodes.every(isMapping)) {
|
|
337
|
+
throw new DiagramError("'nodes' must be an array of objects");
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(rawEdges) || !rawEdges.every(isMapping)) {
|
|
340
|
+
throw new DiagramError("'edges' must be an array of objects");
|
|
341
|
+
}
|
|
342
|
+
if (!isMapping(rawDefs)) {
|
|
343
|
+
throw new DiagramError("'classDefs' must be an object");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nodes = rawNodes.map((item) => {
|
|
347
|
+
if (!Object.hasOwn(item, "id")) {
|
|
348
|
+
throw new DiagramError("every node needs an 'id'");
|
|
349
|
+
}
|
|
350
|
+
const id = String(item.id);
|
|
351
|
+
return new Node({
|
|
352
|
+
id,
|
|
353
|
+
label: String(item.label ?? id),
|
|
354
|
+
shape: String(item.shape ?? "rectangle"),
|
|
355
|
+
image: item.image == null ? null : String(item.image),
|
|
356
|
+
height: Number(item.height ?? 80),
|
|
357
|
+
position: String(item.position ?? "t"),
|
|
358
|
+
constraint: Boolean(item.constraint ?? true),
|
|
359
|
+
className: item.class == null ? null : String(item.class),
|
|
360
|
+
href: item.href == null ? null : String(item.href),
|
|
361
|
+
tooltip: item.tooltip == null ? null : String(item.tooltip)
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const edges = rawEdges.map((item) => {
|
|
366
|
+
if (!Object.hasOwn(item, "from") || !Object.hasOwn(item, "to")) {
|
|
367
|
+
throw new DiagramError("every edge needs 'from' and 'to'");
|
|
368
|
+
}
|
|
369
|
+
return new Edge({
|
|
370
|
+
source: String(item.from),
|
|
371
|
+
target: String(item.to),
|
|
372
|
+
label: item.label == null ? null : String(item.label),
|
|
373
|
+
style: String(item.style ?? "-->")
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return new Diagram({
|
|
378
|
+
direction: String(value.direction ?? "LR"),
|
|
379
|
+
title: value.title == null ? null : String(value.title),
|
|
380
|
+
nodes,
|
|
381
|
+
edges,
|
|
382
|
+
classDefs: Object.fromEntries(
|
|
383
|
+
Object.entries(rawDefs).map(([key, item]) => [String(key), String(item)])
|
|
384
|
+
)
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export const fromDict = fromObject;
|
|
389
|
+
|
|
390
|
+
export function generate(
|
|
391
|
+
value,
|
|
392
|
+
{
|
|
393
|
+
fence = true,
|
|
394
|
+
direction = "LR",
|
|
395
|
+
title = null,
|
|
396
|
+
images = {},
|
|
397
|
+
imageHeight = 80,
|
|
398
|
+
embedImages = true,
|
|
399
|
+
forceJson = false
|
|
400
|
+
} = {}
|
|
401
|
+
) {
|
|
402
|
+
let source;
|
|
403
|
+
if (isMapping(value)) {
|
|
404
|
+
const parsed = fromObject(value);
|
|
405
|
+
source =
|
|
406
|
+
typeof parsed === "string"
|
|
407
|
+
? parsed
|
|
408
|
+
: parsed.render({ embedImages });
|
|
409
|
+
} else {
|
|
410
|
+
const raw = String(value).trim();
|
|
411
|
+
if (!raw) {
|
|
412
|
+
throw new DiagramError("diagram input is empty");
|
|
413
|
+
}
|
|
414
|
+
let parsedJson = null;
|
|
415
|
+
if (forceJson || raw.startsWith("{")) {
|
|
416
|
+
try {
|
|
417
|
+
parsedJson = JSON.parse(raw);
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (forceJson) {
|
|
420
|
+
throw new DiagramError("invalid JSON: " + error.message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (parsedJson !== null && !isMapping(parsedJson)) {
|
|
424
|
+
throw new DiagramError("JSON input must be an object");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (isMapping(parsedJson)) {
|
|
428
|
+
const parsed = fromObject(parsedJson);
|
|
429
|
+
source =
|
|
430
|
+
typeof parsed === "string"
|
|
431
|
+
? parsed
|
|
432
|
+
: parsed.render({ embedImages });
|
|
433
|
+
} else if (/^```mermaid(?:\s|$)/i.test(raw)) {
|
|
434
|
+
if (fence) {
|
|
435
|
+
return raw;
|
|
436
|
+
}
|
|
437
|
+
return raw
|
|
438
|
+
.replace(/^```mermaid\s*\n?/i, "")
|
|
439
|
+
.replace(/\n?```$/, "")
|
|
440
|
+
.trim();
|
|
441
|
+
} else if (MERMAID_START.test(raw)) {
|
|
442
|
+
if (Object.keys(images || {}).length > 0) {
|
|
443
|
+
throw new DiagramError(
|
|
444
|
+
"--image works with plain-text flowcharts, not raw Mermaid"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
source = raw;
|
|
448
|
+
} else {
|
|
449
|
+
source = fromText(raw, {
|
|
450
|
+
direction,
|
|
451
|
+
title,
|
|
452
|
+
images,
|
|
453
|
+
imageHeight
|
|
454
|
+
}).render({ embedImages });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return fence ? wrapMermaid(source) : source;
|
|
458
|
+
}
|