@neverprepared/mcp-phantom-diagrams 1.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/.github/workflows/ci.yml +34 -0
- package/.github/workflows/release-please.yml +50 -0
- package/CLAUDE.md +49 -0
- package/README.md +83 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +44 -0
- package/dist/docker.d.ts +2 -0
- package/dist/docker.js +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +169 -0
- package/dist/kroki.d.ts +124 -0
- package/dist/kroki.js +104 -0
- package/docker-compose.yml +37 -0
- package/package.json +32 -0
- package/src/cache.ts +65 -0
- package/src/docker.ts +102 -0
- package/src/index.ts +207 -0
- package/src/kroki.ts +140 -0
- package/test/integration.test.ts +55 -0
- package/test/unit.test.ts +124 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests — require a running Kroki instance.
|
|
3
|
+
* Set SKIP_INTEGRATION=1 to skip (e.g. in CI without Docker).
|
|
4
|
+
* The tests call ensureKrokiRunning() in before(), so Docker must be available.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, before } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { convertDiagram } from "../src/kroki.ts";
|
|
9
|
+
import { ensureKrokiRunning } from "../src/docker.ts";
|
|
10
|
+
|
|
11
|
+
const skip = process.env.SKIP_INTEGRATION === "1" ? "SKIP_INTEGRATION=1" : false;
|
|
12
|
+
|
|
13
|
+
describe("Kroki round-trips", { skip }, () => {
|
|
14
|
+
before(async () => {
|
|
15
|
+
await ensureKrokiRunning();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("plantuml → svg contains <svg> element", async () => {
|
|
19
|
+
const result = await convertDiagram(
|
|
20
|
+
"plantuml",
|
|
21
|
+
"@startuml\nAlice -> Bob: hello\n@enduml",
|
|
22
|
+
"svg"
|
|
23
|
+
);
|
|
24
|
+
assert.equal(result.format, "svg");
|
|
25
|
+
assert.ok((result.data as string).includes("<svg"), "expected SVG document");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("graphviz → png has PNG magic bytes", async () => {
|
|
29
|
+
const result = await convertDiagram("graphviz", "digraph { A -> B }", "png");
|
|
30
|
+
assert.equal(result.format, "png");
|
|
31
|
+
const buf = result.data as Buffer;
|
|
32
|
+
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
|
33
|
+
assert.equal(buf[0], 0x89);
|
|
34
|
+
assert.equal(buf[1], 0x50); // P
|
|
35
|
+
assert.equal(buf[2], 0x4e); // N
|
|
36
|
+
assert.equal(buf[3], 0x47); // G
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("mermaid → svg (companion container)", async () => {
|
|
40
|
+
const result = await convertDiagram(
|
|
41
|
+
"mermaid",
|
|
42
|
+
"graph TD\n A --> B",
|
|
43
|
+
"svg"
|
|
44
|
+
);
|
|
45
|
+
assert.equal(result.format, "svg");
|
|
46
|
+
assert.ok((result.data as string).includes("<svg"), "expected SVG document");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("invalid plantuml source returns 4xx error with hint", async () => {
|
|
50
|
+
await assert.rejects(
|
|
51
|
+
() => convertDiagram("plantuml", "this is not valid plantuml markup !!!", "svg"),
|
|
52
|
+
/syntax error|rejected/i
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { DIAGRAM_TYPES, convertDiagram } from "../src/kroki.ts";
|
|
4
|
+
import { diagramCache } from "../src/cache.ts";
|
|
5
|
+
|
|
6
|
+
describe("DIAGRAM_TYPES map", () => {
|
|
7
|
+
test("every type has at least one format", () => {
|
|
8
|
+
for (const [type, info] of Object.entries(DIAGRAM_TYPES)) {
|
|
9
|
+
assert.ok(info.formats.length > 0, `${type} has no formats`);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("svg is supported by every built-in type", () => {
|
|
14
|
+
for (const [type, info] of Object.entries(DIAGRAM_TYPES)) {
|
|
15
|
+
if (!info.requiresCompanion) {
|
|
16
|
+
assert.ok(info.formats.includes("svg"), `built-in type ${type} should support svg`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("mermaid supports png (companion fix)", () => {
|
|
22
|
+
assert.ok(DIAGRAM_TYPES.mermaid.formats.includes("png"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("dbml supports png", () => {
|
|
26
|
+
assert.ok(DIAGRAM_TYPES.dbml.formats.includes("png"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("blockdiag-family types all marked requiresCompanion", () => {
|
|
30
|
+
for (const t of ["seqdiag", "actdiag", "nwdiag", "packetdiag", "rackdiag"] as const) {
|
|
31
|
+
assert.ok(DIAGRAM_TYPES[t].requiresCompanion, `${t} should require companion`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("convertDiagram input validation", () => {
|
|
37
|
+
test("rejects source exceeding 256 KB", async () => {
|
|
38
|
+
const bigSource = "x".repeat(256 * 1024 + 1);
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
() => convertDiagram("plantuml", bigSource, "svg"),
|
|
41
|
+
/exceeds.*limit/i
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rejects CRLF in option key", async () => {
|
|
46
|
+
await assert.rejects(
|
|
47
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", { "bad\r\nkey": "val" }),
|
|
48
|
+
/Invalid option key/
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects CRLF in option value", async () => {
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", { key: "bad\r\nvalue" }),
|
|
55
|
+
/Invalid option value/
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("rejects non-printable chars in option key", async () => {
|
|
60
|
+
await assert.rejects(
|
|
61
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", { "key\x00null": "val" }),
|
|
62
|
+
/Invalid option key/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects non-printable chars in option value", async () => {
|
|
67
|
+
await assert.rejects(
|
|
68
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", { key: "val\x00null" }),
|
|
69
|
+
/Invalid option value/
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects CRLF in query option key", async () => {
|
|
74
|
+
await assert.rejects(
|
|
75
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", undefined, { "bad\r\nkey": "val" }),
|
|
76
|
+
/Invalid query option key/
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("rejects CRLF in query option value", async () => {
|
|
81
|
+
await assert.rejects(
|
|
82
|
+
() => convertDiagram("plantuml", "@startuml\n@enduml", "svg", undefined, { key: "bad\r\nval" }),
|
|
83
|
+
/Invalid query option value/
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("DiagramCache", () => {
|
|
89
|
+
test("miss returns undefined", () => {
|
|
90
|
+
const key = diagramCache.cacheKey("plantuml", "source", "svg");
|
|
91
|
+
// Use a fresh key unlikely to exist
|
|
92
|
+
assert.equal(diagramCache.get("no-such-key-" + Math.random()), undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("set then get returns same result", () => {
|
|
96
|
+
const result = { format: "svg" as const, data: "<svg/>" };
|
|
97
|
+
const key = diagramCache.cacheKey("graphviz", "digraph{}", "svg");
|
|
98
|
+
diagramCache.set(key, result);
|
|
99
|
+
const hit = diagramCache.get(key);
|
|
100
|
+
assert.deepEqual(hit, result);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("stats reflect cached entries", () => {
|
|
104
|
+
const before = diagramCache.stats();
|
|
105
|
+
const result = { format: "svg" as const, data: "<svg>hello</svg>" };
|
|
106
|
+
const key = diagramCache.cacheKey("nomnoml", "#direction: right", "svg", {}, { theme: "dark" });
|
|
107
|
+
diagramCache.set(key, result);
|
|
108
|
+
const after = diagramCache.stats();
|
|
109
|
+
assert.ok(after.entries >= before.entries);
|
|
110
|
+
assert.ok(after.bytes >= before.bytes);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("cacheKey differs with different query_options", () => {
|
|
114
|
+
const k1 = diagramCache.cacheKey("plantuml", "src", "svg", {}, { theme: "dark" });
|
|
115
|
+
const k2 = diagramCache.cacheKey("plantuml", "src", "svg", {}, { theme: "light" });
|
|
116
|
+
assert.notEqual(k1, k2);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("cacheKey differs with different header options", () => {
|
|
120
|
+
const k1 = diagramCache.cacheKey("plantuml", "src", "svg", { key: "a" });
|
|
121
|
+
const k2 = diagramCache.cacheKey("plantuml", "src", "svg", { key: "b" });
|
|
122
|
+
assert.notEqual(k1, k2);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|