d2-to-drawio 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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/d2-to-drawio.js +153 -0
- package/package.json +59 -0
- package/src/compile.js +97 -0
- package/src/emit.js +128 -0
- package/src/ids.js +21 -0
- package/src/index.js +129 -0
- package/src/map.js +447 -0
- package/src/special.js +56 -0
- package/src/theme-data.js +25 -0
- package/src/themes.js +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moawiah Jamal
|
|
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,172 @@
|
|
|
1
|
+
# d2-to-drawio
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/d2-to-drawio)
|
|
4
|
+
[](https://github.com/Moawiah188/d2-to-drawio/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/Moawiah188/d2-to-drawio/blob/main/LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
|
|
8
|
+
Convert D2 diagrams to draw.io files: keep authoring diagrams as code in [D2](https://d2lang.com), and hand fully editable drawio / [diagrams.net](https://www.diagrams.net) files to everyone else. Shapes, containers, edges, styles, and positions all come across as native draw.io elements, not as a pasted image.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
npx d2-to-drawio examples/basic.d2 -o basic.drawio
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
```d2
|
|
17
|
+
direction: right
|
|
18
|
+
|
|
19
|
+
user: Visitor { shape: person }
|
|
20
|
+
app: "Web App"
|
|
21
|
+
db: "Postgres" { shape: cylinder }
|
|
22
|
+
|
|
23
|
+
user -> app: "browses"
|
|
24
|
+
app -> db: "reads / writes"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
npx d2-to-drawio examples/basic.d2 -o basic.drawio
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The result, opened in draw.io, every element selectable and editable:
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
A richer example with containers, a queue, crow's foot cardinality, and tooltips ([source](https://github.com/Moawiah188/d2-to-drawio/blob/main/examples/microservices.d2)):
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
Open the committed outputs directly in your browser:
|
|
40
|
+
|
|
41
|
+
- [Open basic.drawio in app.diagrams.net](https://app.diagrams.net/#Uhttps%3A%2F%2Fraw.githubusercontent.com%2FMoawiah188%2Fd2-to-drawio%2Fmain%2Fexamples%2Fbasic.drawio)
|
|
42
|
+
- [Open microservices.drawio in app.diagrams.net](https://app.diagrams.net/#Uhttps%3A%2F%2Fraw.githubusercontent.com%2FMoawiah188%2Fd2-to-drawio%2Fmain%2Fexamples%2Fmicroservices.drawio)
|
|
43
|
+
|
|
44
|
+
<!-- PLACEHOLDER: drop a real screenshot of one of these files open in the app.diagrams.net editor (with the draw.io UI visible) here, e.g. docs/assets/editor-screenshot.png, and reference it with an absolute raw.githubusercontent.com URL. -->
|
|
45
|
+
|
|
46
|
+
## Why this exists
|
|
47
|
+
|
|
48
|
+
Plenty of teams standardize on draw.io and Confluence for documentation, while the engineers drawing the systems would rather write diagrams as code, version them, and review them in pull requests. Exporting D2 to SVG or PNG hands the team a static picture nobody can edit; switching the whole team to D2 rarely happens. This tool removes that one-way door: engineers keep the `.d2` source of truth, everyone else gets a native `.drawio` file they can open, edit, and paste into Confluence.
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
npm install -g d2-to-drawio
|
|
54
|
+
d2-to-drawio diagram.d2 -o diagram.drawio
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Or without installing:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
npx d2-to-drawio diagram.d2 -o diagram.drawio
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Or from stdin to stdout:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
cat diagram.d2 | d2-to-drawio > diagram.drawio
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## CLI reference
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Usage: d2-to-drawio [options] [input.d2]
|
|
73
|
+
|
|
74
|
+
Reads from the input file, or from stdin when no file is given (or "-").
|
|
75
|
+
Writes to --output, or to stdout.
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
-o, --output <file> write the .drawio XML to a file instead of stdout
|
|
79
|
+
--layout <name> layout engine: dagre (default) or elk
|
|
80
|
+
--theme <id> D2 theme id (default 0)
|
|
81
|
+
--waypoints preserve D2 edge routes as fixed waypoints
|
|
82
|
+
--strict fail when input uses features that would degrade
|
|
83
|
+
-q, --quiet suppress warnings on stderr
|
|
84
|
+
-h, --help show this help
|
|
85
|
+
-v, --version print the version
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Behavior notes:
|
|
89
|
+
|
|
90
|
+
- `@imports` resolve automatically: for file input, every `.d2` under the input file's directory is available to the compiler; for stdin, imports resolve against the current directory.
|
|
91
|
+
- Unsupported D2 features never crash a conversion. Each degrades to the closest visual and prints one warning line to stderr. `--strict` turns those warnings into a failing exit instead.
|
|
92
|
+
- By default draw.io re-routes edges orthogonally, which keeps them fully editable. `--waypoints` pins d2's exact computed routes instead.
|
|
93
|
+
- Exit codes: 0 success, 1 conversion or input error, 2 usage error.
|
|
94
|
+
|
|
95
|
+
## Library API
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { convert, convertFile, dispose } from 'd2-to-drawio';
|
|
99
|
+
|
|
100
|
+
const xml = await convert('a -> b: hello');
|
|
101
|
+
// or, resolving imports relative to the file:
|
|
102
|
+
const xml2 = await convertFile('diagram.d2', { layout: 'elk' });
|
|
103
|
+
|
|
104
|
+
await dispose();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `convert(d2Source, options)` returns a Promise of the `.drawio` XML string.
|
|
108
|
+
- `convertFile(inputPath, options)` reads the file and feeds sibling `.d2` files to the compiler so relative imports work.
|
|
109
|
+
- `options`: `layout` ('dagre' | 'elk'), `themeID` (number), `strict` (boolean), `waypoints` (boolean), `onWarning` (callback receiving `{code, message}`), `fsMap`/`inputPath` (virtual filesystem for imports when using `convert`).
|
|
110
|
+
- `dispose()` releases the compiler's worker thread. Call it when done, or your process will stay alive. The next `convert` after a `dispose` transparently starts a fresh engine.
|
|
111
|
+
- Errors: malformed D2 rejects with `D2SyntaxError` (message carries `file:line:column`); strict-mode degradations reject with `UnsupportedFeatureError` (carrying the warning list).
|
|
112
|
+
|
|
113
|
+
Output is deterministic: identical input produces byte-identical output, so generated files diff cleanly in version control.
|
|
114
|
+
|
|
115
|
+
## Feature support
|
|
116
|
+
|
|
117
|
+
The full, fixture-backed matrix lives in [docs/FEATURE-MATRIX.md](https://github.com/Moawiah188/d2-to-drawio/blob/main/docs/FEATURE-MATRIX.md). Summary:
|
|
118
|
+
|
|
119
|
+
| Area | Support |
|
|
120
|
+
|---|---|
|
|
121
|
+
| Shape catalog (rectangle, square, page, parallelogram, document, cylinder, queue, package, step, callout, stored_data, person, diamond, oval, circle, hexagon, cloud, image, text) | Full |
|
|
122
|
+
| Containers, any nesting depth, grid diagrams | Full |
|
|
123
|
+
| Connections: `->` `<-` `<->` `--`, chains, parallel edges, self-loops | Full |
|
|
124
|
+
| Arrowheads incl. crow's foot ER markers, arrowhead labels | Full |
|
|
125
|
+
| Styles: fill, stroke, stroke-width, stroke-dash, opacity, shadow, fonts, bold/italic/underline | Full |
|
|
126
|
+
| Themes: all 20 official palettes | Full |
|
|
127
|
+
| Compiler-level features: vars, globs, classes, imports, overrides, null | Full |
|
|
128
|
+
| Boards (layers, scenarios, steps) | Full, one draw.io page per board |
|
|
129
|
+
| Tooltips and links | Full |
|
|
130
|
+
| sql_table, class (UML) | Partial: formatted single shapes, not row-by-row cells |
|
|
131
|
+
| Sequence diagrams | Partial: exact d2 geometry, but fixed (does not re-route) |
|
|
132
|
+
| Markdown / LaTeX / code labels | Partial: plain text / MathJax / monospace box |
|
|
133
|
+
| multiple, 3d, double-border, fill-pattern, animated, sketch | Unsupported, warned |
|
|
134
|
+
|
|
135
|
+
## Limitations
|
|
136
|
+
|
|
137
|
+
- The compiler dependency (`@terrastruct/d2`, the official WASM build of D2) is around 60 MB installed and takes a couple of seconds to start. One-shot CLI runs pay that startup; batch conversions should use the library API, which reuses the engine across calls.
|
|
138
|
+
- sql_table and class shapes are single shapes with formatted labels. Editing individual rows as draw.io table cells is on the roadmap.
|
|
139
|
+
- Sequence diagram messages and lifelines keep d2's exact geometry and will not re-route if you move the actors.
|
|
140
|
+
- Links between D2 boards (`link: layers.x`) do not become page links in draw.io yet.
|
|
141
|
+
- The TALA layout engine is not part of the embedded compiler; `dagre` and `elk` are available.
|
|
142
|
+
|
|
143
|
+
## How it works
|
|
144
|
+
|
|
145
|
+
The official D2 compiler (as a WASM build) parses the source and runs the real layout engine, producing fully positioned shapes and routed connections. This tool walks that output, rebuilds the container tree, and translates shapes, styles, and theme colors into draw.io's mxGraph cell model. It then emits deterministic, uncompressed `.drawio` XML that draw.io opens like any hand-drawn file.
|
|
146
|
+
|
|
147
|
+
## Roadmap
|
|
148
|
+
|
|
149
|
+
Only things actually planned:
|
|
150
|
+
|
|
151
|
+
- Native draw.io table cells for sql_table and class shapes.
|
|
152
|
+
- draw.io sketch style for D2 sketch mode.
|
|
153
|
+
- Page links for D2 board links.
|
|
154
|
+
- Icons on regular shapes (currently only `shape: image` carries an image).
|
|
155
|
+
|
|
156
|
+
## Related projects
|
|
157
|
+
|
|
158
|
+
As of July 2026 this is the only dedicated D2 to draw.io converter I am aware of. Related tools:
|
|
159
|
+
|
|
160
|
+
- [D2](https://github.com/terrastruct/d2): the diagram language itself.
|
|
161
|
+
- [draw.io / diagrams.net](https://github.com/jgraph/drawio): the editor this tool targets.
|
|
162
|
+
- [@whitebite/diagram-converter](https://www.npmjs.com/package/@whitebite/diagram-converter): a universal multi-format diagram converter whose published build includes a D2 parser and drawio generator via a shared intermediate representation. A generalist; this tool goes deeper on D2 fidelity (layout preservation, themes, special shapes, degradation warnings).
|
|
163
|
+
- [mmd2drawio](https://github.com/youyoubilly/mmd2drawio): the same idea for Mermaid.
|
|
164
|
+
- The SVG detour: D2 can export SVG that draw.io imports as a static image. Works, but nothing is editable; that gap is the point of this project (see also [jgraph/drawio#3764](https://github.com/jgraph/drawio/issues/3764), an open request for D2 input in draw.io itself).
|
|
165
|
+
|
|
166
|
+
## Contributing
|
|
167
|
+
|
|
168
|
+
See [CONTRIBUTING.md](https://github.com/Moawiah188/d2-to-drawio/blob/main/CONTRIBUTING.md). The short version: fixture first, then the mapping code, then a row in the feature matrix. Bug reports with a minimal `.d2` snippet are gold.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
[MIT](https://github.com/Moawiah188/d2-to-drawio/blob/main/LICENSE). The `@terrastruct/d2` dependency is MPL-2.0, used unmodified.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI for d2-to-drawio: convert D2 diagram source to draw.io XML.
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import {
|
|
10
|
+
convert,
|
|
11
|
+
convertFile,
|
|
12
|
+
buildImportFs,
|
|
13
|
+
dispose,
|
|
14
|
+
D2SyntaxError,
|
|
15
|
+
UnsupportedFeatureError,
|
|
16
|
+
} from '../src/index.js';
|
|
17
|
+
|
|
18
|
+
const HELP = `Usage: d2-to-drawio [options] [input.d2]
|
|
19
|
+
|
|
20
|
+
Convert a D2 diagram (d2lang.com) to an editable draw.io / diagrams.net file.
|
|
21
|
+
|
|
22
|
+
Reads from the input file, or from stdin when no file is given (or "-").
|
|
23
|
+
Writes to --output, or to stdout.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-o, --output <file> write the .drawio XML to a file instead of stdout
|
|
27
|
+
--layout <name> layout engine: dagre (default) or elk
|
|
28
|
+
--theme <id> D2 theme id (default 0)
|
|
29
|
+
--waypoints preserve D2 edge routes as fixed waypoints
|
|
30
|
+
--strict fail when input uses features that would degrade
|
|
31
|
+
-q, --quiet suppress warnings on stderr
|
|
32
|
+
-h, --help show this help
|
|
33
|
+
-v, --version print the version
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
d2-to-drawio diagram.d2 -o diagram.drawio
|
|
37
|
+
cat diagram.d2 | d2-to-drawio > diagram.drawio
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
function version() {
|
|
41
|
+
const pkg = JSON.parse(
|
|
42
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
|
|
43
|
+
);
|
|
44
|
+
return pkg.version;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function usageError(msg) {
|
|
48
|
+
process.stderr.write(`error: ${msg}\n\n${HELP}`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readStdin() {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
55
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
let args;
|
|
60
|
+
try {
|
|
61
|
+
args = parseArgs({
|
|
62
|
+
allowPositionals: true,
|
|
63
|
+
options: {
|
|
64
|
+
output: { type: 'string', short: 'o' },
|
|
65
|
+
layout: { type: 'string' },
|
|
66
|
+
theme: { type: 'string' },
|
|
67
|
+
waypoints: { type: 'boolean' },
|
|
68
|
+
strict: { type: 'boolean' },
|
|
69
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
70
|
+
help: { type: 'boolean', short: 'h' },
|
|
71
|
+
version: { type: 'boolean', short: 'v' },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
usageError(err.message);
|
|
76
|
+
}
|
|
77
|
+
const { values, positionals } = args;
|
|
78
|
+
|
|
79
|
+
if (values.help) {
|
|
80
|
+
process.stdout.write(HELP);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
if (values.version) {
|
|
84
|
+
process.stdout.write(`${version()}\n`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (positionals.length > 1) usageError('expected at most one input file');
|
|
88
|
+
if (values.layout && values.layout !== 'dagre' && values.layout !== 'elk') {
|
|
89
|
+
usageError(`unknown layout "${values.layout}" (expected dagre or elk)`);
|
|
90
|
+
}
|
|
91
|
+
let themeID;
|
|
92
|
+
if (values.theme !== undefined) {
|
|
93
|
+
themeID = Number(values.theme);
|
|
94
|
+
if (!Number.isInteger(themeID) || themeID < 0) usageError(`invalid theme id "${values.theme}"`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// layout and themeID stay undefined unless given, so an in-file
|
|
98
|
+
// vars.d2-config block keeps control of them.
|
|
99
|
+
const convertOptions = {
|
|
100
|
+
layout: values.layout,
|
|
101
|
+
themeID,
|
|
102
|
+
strict: Boolean(values.strict),
|
|
103
|
+
waypoints: Boolean(values.waypoints),
|
|
104
|
+
onWarning: values.quiet ? undefined : (w) => process.stderr.write(`warning: ${w.message}\n`),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const inputPath = positionals[0];
|
|
108
|
+
let xml;
|
|
109
|
+
try {
|
|
110
|
+
if (!inputPath || inputPath === '-') {
|
|
111
|
+
if (process.stdin.isTTY) usageError('no input file given and stdin is a terminal');
|
|
112
|
+
const source = await readStdin();
|
|
113
|
+
// Imports in piped input resolve against the current directory.
|
|
114
|
+
xml = await convert(source, { ...convertOptions, fsMap: buildImportFs(process.cwd()) });
|
|
115
|
+
} else {
|
|
116
|
+
xml = await convertFile(inputPath, convertOptions);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err && (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'EISDIR')) {
|
|
120
|
+
process.stderr.write(`error: cannot read "${inputPath}": ${err.message}\n`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
if (err instanceof D2SyntaxError || err instanceof UnsupportedFeatureError) {
|
|
124
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (values.output) {
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(values.output, xml, 'utf8');
|
|
133
|
+
} catch (err) {
|
|
134
|
+
process.stderr.write(`error: cannot write "${values.output}": ${err.message}\n`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
process.stdout.write(xml);
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
main()
|
|
144
|
+
.then(async (code) => {
|
|
145
|
+
await dispose();
|
|
146
|
+
// The engine's worker thread would otherwise keep the process alive.
|
|
147
|
+
process.exit(code);
|
|
148
|
+
})
|
|
149
|
+
.catch(async (err) => {
|
|
150
|
+
process.stderr.write(`error: ${err && err.message ? err.message : err}\n`);
|
|
151
|
+
await dispose();
|
|
152
|
+
process.exit(1);
|
|
153
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "d2-to-drawio",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"test": "node --test test/unit.test.mjs test/snapshot.test.mjs test/cli.test.mjs",
|
|
6
|
+
"snapshots": "node test/run-fixtures.mjs",
|
|
7
|
+
"coverage": "node --test --experimental-test-coverage test/unit.test.mjs test/snapshot.test.mjs test/cli.test.mjs"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"d2",
|
|
11
|
+
"d2lang",
|
|
12
|
+
"drawio",
|
|
13
|
+
"draw.io",
|
|
14
|
+
"diagrams.net",
|
|
15
|
+
"diagram",
|
|
16
|
+
"converter",
|
|
17
|
+
"diagrams-as-code",
|
|
18
|
+
"cli",
|
|
19
|
+
"flowchart",
|
|
20
|
+
"architecture-diagram",
|
|
21
|
+
"text-to-diagram",
|
|
22
|
+
"mxgraph",
|
|
23
|
+
"export"
|
|
24
|
+
],
|
|
25
|
+
"author": "Moawiah Jamal",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"description": "Convert D2 diagrams to editable draw.io / diagrams.net files (.drawio XML) from the CLI or Node.js",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"fast-xml-parser": "^5.10.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@terrastruct/d2": "^0.1.33"
|
|
34
|
+
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"d2-to-drawio": "bin/d2-to-drawio.js"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"exports": {
|
|
42
|
+
".": "./src/index.js",
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/Moawiah188/d2-to-drawio#readme",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/Moawiah188/d2-to-drawio.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/Moawiah188/d2-to-drawio/issues"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"src",
|
|
55
|
+
"bin",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
]
|
|
59
|
+
}
|
package/src/compile.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Wrapper around the official D2 compiler (@terrastruct/d2, WASM).
|
|
2
|
+
//
|
|
3
|
+
// The engine runs in a worker thread that keeps the Node event loop alive,
|
|
4
|
+
// so this module manages a lazy singleton and exposes dispose() to release
|
|
5
|
+
// it. Library callers must await dispose() (or exit the process) when done.
|
|
6
|
+
|
|
7
|
+
import { D2 } from '@terrastruct/d2';
|
|
8
|
+
|
|
9
|
+
/** Error thrown when the D2 source does not compile. */
|
|
10
|
+
export class D2SyntaxError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'D2SyntaxError';
|
|
14
|
+
this.code = 'D2_SYNTAX';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let instance = null;
|
|
19
|
+
|
|
20
|
+
function getInstance() {
|
|
21
|
+
if (!instance) instance = new D2();
|
|
22
|
+
return instance;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The engine reports compile problems as a JSON array of
|
|
27
|
+
* {range, errmsg} objects serialized into the error message. Flatten that
|
|
28
|
+
* into d2's own "file:line:col: description" lines, never a stack trace.
|
|
29
|
+
*/
|
|
30
|
+
function formatCompileError(err) {
|
|
31
|
+
const raw = String(err && err.message ? err.message : err).trim();
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (Array.isArray(parsed)) {
|
|
35
|
+
const msgs = parsed.map((e) => e.errmsg ?? JSON.stringify(e));
|
|
36
|
+
if (msgs.length > 0) return msgs.join('\n');
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// not JSON: fall through to the raw message
|
|
40
|
+
}
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compile D2 source to the positioned diagram IR.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} source D2 source text
|
|
48
|
+
* @param {object} [options]
|
|
49
|
+
* @param {'dagre'|'elk'} [options.layout] layout engine (default dagre)
|
|
50
|
+
* @param {number} [options.themeID] D2 theme id (default 0)
|
|
51
|
+
* @param {Record<string, string>} [options.fsMap] virtual filesystem for imports:
|
|
52
|
+
* relative path -> file content. Required for sources that use @imports.
|
|
53
|
+
* @param {string} [options.inputPath] entry filename inside fsMap (and the name
|
|
54
|
+
* used in error messages). Defaults to "input.d2".
|
|
55
|
+
* @returns {Promise<object>} the CompileResponse ({ diagram, graph, ... })
|
|
56
|
+
*/
|
|
57
|
+
export async function compile(source, options = {}) {
|
|
58
|
+
const d2 = getInstance();
|
|
59
|
+
// Only forward explicitly-set options so an in-file `vars.d2-config`
|
|
60
|
+
// block keeps control of anything the caller leaves unset.
|
|
61
|
+
const compileOptions = {};
|
|
62
|
+
if (options.layout !== undefined) compileOptions.layout = options.layout;
|
|
63
|
+
if (options.themeID !== undefined) compileOptions.themeID = options.themeID;
|
|
64
|
+
if (options.sketch !== undefined) compileOptions.sketch = options.sketch;
|
|
65
|
+
if (options.pad !== undefined) compileOptions.pad = options.pad;
|
|
66
|
+
|
|
67
|
+
const inputPath = options.inputPath ?? 'input.d2';
|
|
68
|
+
const fs = { ...(options.fsMap ?? {}), [inputPath]: source };
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
return await d2.compile({ fs, inputPath, options: compileOptions });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new D2SyntaxError(formatCompileError(err));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Release the engine worker so the process can exit naturally.
|
|
79
|
+
* Safe to call multiple times; compile() after dispose() re-creates it.
|
|
80
|
+
*/
|
|
81
|
+
export async function dispose() {
|
|
82
|
+
if (!instance) return;
|
|
83
|
+
const d2 = instance;
|
|
84
|
+
instance = null;
|
|
85
|
+
// The published API has no terminate method. The worker handle appears on
|
|
86
|
+
// the instance once init completes, so wait for readiness first: a worker
|
|
87
|
+
// still starting up would otherwise leak and keep the process alive.
|
|
88
|
+
try {
|
|
89
|
+
await d2.ready;
|
|
90
|
+
} catch {
|
|
91
|
+
// init failed: there is no worker to stop
|
|
92
|
+
}
|
|
93
|
+
const worker = d2.worker;
|
|
94
|
+
if (worker && typeof worker.terminate === 'function') {
|
|
95
|
+
await worker.terminate();
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/emit.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// draw.io XML emission.
|
|
2
|
+
//
|
|
3
|
+
// Input is a list of pages, each holding an ordered list of cells (vertices
|
|
4
|
+
// and edges). Output is an uncompressed .drawio mxfile string.
|
|
5
|
+
//
|
|
6
|
+
// Invariants enforced here, per the draw.io format contract:
|
|
7
|
+
// mxfile > diagram > mxGraphModel > root, root cells "0" and "1",
|
|
8
|
+
// every vertex carries mxGeometry with numeric x/y/width/height,
|
|
9
|
+
// cells with a tooltip or link are wrapped in an <object> element that
|
|
10
|
+
// carries the id and label while the inner mxCell carries neither.
|
|
11
|
+
//
|
|
12
|
+
// Attribute order is fixed so identical input produces byte-identical output.
|
|
13
|
+
|
|
14
|
+
/** Escape text for an XML attribute value. */
|
|
15
|
+
export function escAttr(s) {
|
|
16
|
+
return String(s)
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/\r?\n/g, ' ');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Escape text destined for HTML content inside a draw.io html=1 label. */
|
|
25
|
+
export function escHtml(s) {
|
|
26
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a draw.io html=1 label from plain text: HTML-escape it and turn
|
|
31
|
+
* newlines into <br>. draw.io re-parses the cell value as HTML, so raw text
|
|
32
|
+
* must be escaped once here (and once more as an XML attribute at emit).
|
|
33
|
+
*/
|
|
34
|
+
export function htmlLabel(s) {
|
|
35
|
+
return escHtml(s).replace(/\r?\n/g, '<br>');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Format a coordinate: round to 2 decimals, no trailing zeros. */
|
|
39
|
+
export function num(n) {
|
|
40
|
+
return String(Math.round(n * 100) / 100);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function vertexXml(cell) {
|
|
44
|
+
// Edge-label children use relative geometry along their parent edge.
|
|
45
|
+
if (cell.edgeLabel) {
|
|
46
|
+
return (
|
|
47
|
+
`<mxCell id="${escAttr(cell.id)}" value="${escAttr(cell.value)}" style="${escAttr(
|
|
48
|
+
cell.style
|
|
49
|
+
)}" vertex="1" connectable="0" parent="${escAttr(cell.parent)}">` +
|
|
50
|
+
`<mxGeometry x="${num(cell.rx)}" relative="1" as="geometry"/></mxCell>`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const geom = `<mxGeometry x="${num(cell.x)}" y="${num(cell.y)}" width="${num(cell.w)}" height="${num(cell.h)}" as="geometry"/>`;
|
|
54
|
+
const core = (id, value) =>
|
|
55
|
+
`<mxCell${id}${value} style="${escAttr(cell.style)}" vertex="1" parent="${escAttr(cell.parent)}">${geom}</mxCell>`;
|
|
56
|
+
|
|
57
|
+
if (cell.tooltip || cell.link) {
|
|
58
|
+
// Metadata wrapper: id and label move to the <object>; the inner mxCell
|
|
59
|
+
// carries neither (draw.io's own codec does the same id hoisting).
|
|
60
|
+
let attrs = ` label="${escAttr(cell.value)}"`;
|
|
61
|
+
if (cell.tooltip) attrs += ` tooltip="${escAttr(cell.tooltip)}"`;
|
|
62
|
+
if (cell.link) attrs += ` link="${escAttr(cell.link)}"`;
|
|
63
|
+
return `<object${attrs} id="${escAttr(cell.id)}">${core('', '')}</object>`;
|
|
64
|
+
}
|
|
65
|
+
return core(` id="${escAttr(cell.id)}"`, ` value="${escAttr(cell.value)}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function edgeXml(cell) {
|
|
69
|
+
// Fixed endpoints (sourcePoint/targetPoint) carry edges that have no cell
|
|
70
|
+
// to anchor to, e.g. sequence-diagram lifelines and messages.
|
|
71
|
+
let inner = '';
|
|
72
|
+
if (cell.sourcePoint) {
|
|
73
|
+
inner += `<mxPoint x="${num(cell.sourcePoint.x)}" y="${num(cell.sourcePoint.y)}" as="sourcePoint"/>`;
|
|
74
|
+
}
|
|
75
|
+
if (cell.targetPoint) {
|
|
76
|
+
inner += `<mxPoint x="${num(cell.targetPoint.x)}" y="${num(cell.targetPoint.y)}" as="targetPoint"/>`;
|
|
77
|
+
}
|
|
78
|
+
if (cell.points && cell.points.length > 0) {
|
|
79
|
+
const pts = cell.points.map((p) => `<mxPoint x="${num(p.x)}" y="${num(p.y)}"/>`).join('');
|
|
80
|
+
inner += `<Array as="points">${pts}</Array>`;
|
|
81
|
+
}
|
|
82
|
+
const geom = inner
|
|
83
|
+
? `<mxGeometry relative="1" as="geometry">${inner}</mxGeometry>`
|
|
84
|
+
: `<mxGeometry relative="1" as="geometry"/>`;
|
|
85
|
+
|
|
86
|
+
const endpoints =
|
|
87
|
+
(cell.source ? ` source="${escAttr(cell.source)}"` : '') +
|
|
88
|
+
(cell.target ? ` target="${escAttr(cell.target)}"` : '');
|
|
89
|
+
const core = (id, value) =>
|
|
90
|
+
`<mxCell${id}${value} style="${escAttr(cell.style)}" edge="1" parent="${escAttr(
|
|
91
|
+
cell.parent
|
|
92
|
+
)}"${endpoints}>${geom}</mxCell>`;
|
|
93
|
+
|
|
94
|
+
if (cell.tooltip || cell.link) {
|
|
95
|
+
let attrs = ` label="${escAttr(cell.value)}"`;
|
|
96
|
+
if (cell.tooltip) attrs += ` tooltip="${escAttr(cell.tooltip)}"`;
|
|
97
|
+
if (cell.link) attrs += ` link="${escAttr(cell.link)}"`;
|
|
98
|
+
return `<object${attrs} id="${escAttr(cell.id)}">${core('', '')}</object>`;
|
|
99
|
+
}
|
|
100
|
+
return core(` id="${escAttr(cell.id)}"`, ` value="${escAttr(cell.value)}"`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Emit a complete uncompressed .drawio file.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<{name: string, cells: Array<object>, background?: string}>} pages
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function emit(pages) {
|
|
110
|
+
const diagrams = pages.map((page, i) => {
|
|
111
|
+
const cells = page.cells
|
|
112
|
+
.map((c) => (c.edge ? edgeXml(c) : vertexXml(c)))
|
|
113
|
+
.join('');
|
|
114
|
+
const background =
|
|
115
|
+
page.background && page.background !== 'none'
|
|
116
|
+
? ` background="${escAttr(page.background)}"`
|
|
117
|
+
: '';
|
|
118
|
+
const math = page.math ? '1' : '0';
|
|
119
|
+
return (
|
|
120
|
+
`<diagram id="page-${i + 1}" name="${escAttr(page.name)}">` +
|
|
121
|
+
`<mxGraphModel dx="800" dy="600" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" ` +
|
|
122
|
+
`arrows="1" fold="1" page="0" pageScale="1" math="${math}" shadow="0"${background}>` +
|
|
123
|
+
`<root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root>` +
|
|
124
|
+
`</mxGraphModel></diagram>`
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
return `<mxfile host="app.diagrams.net" type="device">${diagrams.join('')}</mxfile>\n`;
|
|
128
|
+
}
|
package/src/ids.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Deterministic draw.io cell id allocation.
|
|
2
|
+
//
|
|
3
|
+
// Ids derive from the D2 key: readable, stable across runs, deduplicated
|
|
4
|
+
// with numeric suffixes. "0" and "1" are reserved for the draw.io root cells.
|
|
5
|
+
|
|
6
|
+
export function createIdAllocator() {
|
|
7
|
+
const used = new Set(['0', '1']);
|
|
8
|
+
const byKey = new Map();
|
|
9
|
+
|
|
10
|
+
return function idFor(key) {
|
|
11
|
+
if (byKey.has(key)) return byKey.get(key);
|
|
12
|
+
let base = String(key).replace(/[^A-Za-z0-9_]/g, '_');
|
|
13
|
+
if (!base || /^\d+$/.test(base)) base = `n_${base}`;
|
|
14
|
+
let id = base;
|
|
15
|
+
let n = 2;
|
|
16
|
+
while (used.has(id)) id = `${base}_${n++}`;
|
|
17
|
+
used.add(id);
|
|
18
|
+
byKey.set(key, id);
|
|
19
|
+
return id;
|
|
20
|
+
};
|
|
21
|
+
}
|