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/src/index.js ADDED
@@ -0,0 +1,129 @@
1
+ // Public library API.
2
+
3
+ import { readFileSync, readdirSync } from 'node:fs';
4
+ import { dirname, basename, relative, join, resolve as resolvePath } from 'node:path';
5
+ import { compile, dispose, D2SyntaxError } from './compile.js';
6
+ import { mapBoard } from './map.js';
7
+ import { emit } from './emit.js';
8
+
9
+ export { dispose, D2SyntaxError };
10
+
11
+ /** Error thrown in strict mode when input uses features that degrade. */
12
+ export class UnsupportedFeatureError extends Error {
13
+ constructor(warnings) {
14
+ super(
15
+ `strict mode: input uses ${warnings.length} unsupported feature(s):\n` +
16
+ warnings.map((w) => ` - ${w.message}`).join('\n')
17
+ );
18
+ this.name = 'UnsupportedFeatureError';
19
+ this.code = 'UNSUPPORTED_STRICT';
20
+ this.warnings = warnings;
21
+ }
22
+ }
23
+
24
+ function createWarnings() {
25
+ const seen = new Set();
26
+ const list = [];
27
+ return {
28
+ list,
29
+ add(code, message) {
30
+ if (seen.has(code)) return;
31
+ seen.add(code);
32
+ list.push({ code, message });
33
+ },
34
+ };
35
+ }
36
+
37
+ /** Collect a board and its nested boards (layers/scenarios/steps) as pages. */
38
+ function collectBoards(diagram, prefix, out) {
39
+ out.push({ diagram, name: diagram.name || (out.length === 0 ? 'Page-1' : `page-${out.length + 1}`), prefix });
40
+ for (const kind of ['layers', 'scenarios', 'steps']) {
41
+ for (const child of diagram[kind] ?? []) {
42
+ collectBoards(child, prefix ? `${prefix}/${child.name}` : child.name, out);
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Convert D2 source text to an uncompressed draw.io XML string.
50
+ *
51
+ * @param {string} d2Source
52
+ * @param {object} [options]
53
+ * @param {'dagre'|'elk'} [options.layout] layout engine (default dagre)
54
+ * @param {number} [options.themeID] D2 theme id (default 0)
55
+ * @param {boolean} [options.strict] fail on unsupported features instead of degrading
56
+ * @param {boolean} [options.waypoints] preserve D2 edge routes as draw.io waypoints
57
+ * @param {(warning: {code: string, message: string}) => void} [options.onWarning]
58
+ * @returns {Promise<string>}
59
+ */
60
+ export async function convert(d2Source, options = {}) {
61
+ // Strip a UTF-8 BOM so a BOM'd file or stream still parses.
62
+ if (d2Source.charCodeAt(0) === 0xfeff) d2Source = d2Source.slice(1);
63
+ const result = await compile(d2Source, options);
64
+ const warnings = createWarnings();
65
+
66
+ // The root board's config echoes the effective theme (an in-file
67
+ // vars.d2-config can set it when the caller does not).
68
+ const boardOptions = {
69
+ ...options,
70
+ themeID: result.diagram?.config?.themeID ?? options.themeID ?? 0,
71
+ };
72
+
73
+ const boards = collectBoards(result.diagram, '', []);
74
+ const pages = boards.map((b, i) => {
75
+ const page = mapBoard(b.diagram, boardOptions, warnings);
76
+ if (b.prefix) page.name = b.prefix;
77
+ else if (!b.diagram.name) page.name = i === 0 ? 'Page-1' : `page-${i + 1}`;
78
+ return page;
79
+ });
80
+
81
+ if (options.strict && warnings.list.length > 0) {
82
+ throw new UnsupportedFeatureError(warnings.list);
83
+ }
84
+ if (options.onWarning) for (const w of warnings.list) options.onWarning(w);
85
+
86
+ return emit(pages);
87
+ }
88
+
89
+ /**
90
+ * Build a virtual filesystem map of every .d2 file under rootDir (relative
91
+ * paths, forward slashes) so the compiler can resolve @imports.
92
+ */
93
+ export function buildImportFs(rootDir) {
94
+ const fsMap = {};
95
+ const walk = (dir) => {
96
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
97
+ if (entry.isDirectory()) {
98
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
99
+ walk(join(dir, entry.name));
100
+ } else if (entry.name.endsWith('.d2')) {
101
+ const abs = join(dir, entry.name);
102
+ const rel = relative(rootDir, abs).split('\\').join('/');
103
+ fsMap[rel] = readFileSync(abs, 'utf8');
104
+ }
105
+ }
106
+ };
107
+ walk(rootDir);
108
+ return fsMap;
109
+ }
110
+
111
+ /**
112
+ * Convert a .d2 file to draw.io XML. Sibling .d2 files (recursively under
113
+ * the file's directory) are made available to the compiler so relative
114
+ * @imports resolve.
115
+ *
116
+ * @param {string} inputPath path to the .d2 file
117
+ * @param {object} [options] same options as convert()
118
+ * @returns {Promise<string>}
119
+ */
120
+ export async function convertFile(inputPath, options = {}) {
121
+ const abs = resolvePath(inputPath);
122
+ const source = readFileSync(abs, 'utf8');
123
+ const rootDir = dirname(abs);
124
+ return convert(source, {
125
+ ...options,
126
+ fsMap: buildImportFs(rootDir),
127
+ inputPath: basename(abs),
128
+ });
129
+ }
package/src/map.js ADDED
@@ -0,0 +1,447 @@
1
+ // Map the D2 diagram IR (d2target.Diagram) to draw.io pages of cells.
2
+ //
3
+ // The IR arrives flat: every shape carries a dotted id, a nesting level, and
4
+ // ABSOLUTE coordinates. draw.io wants a parent tree with child coordinates
5
+ // relative to the parent, so this module rebuilds the tree, converts
6
+ // coordinates, and translates styles.
7
+
8
+ import { createIdAllocator } from './ids.js';
9
+ import { resolveColor } from './themes.js';
10
+ import { htmlLabel, escHtml } from './emit.js';
11
+ import { sqlTableLabel, classLabel, iconUrl } from './special.js';
12
+
13
+ // D2 shape type -> draw.io style prefix. Values verified against the draw.io
14
+ // default stylesheet where possible; visual fidelity is refined per fixture.
15
+ const SHAPE_STYLES = {
16
+ rectangle: '',
17
+ square: '',
18
+ page: 'shape=note;',
19
+ parallelogram: 'shape=parallelogram;perimeter=parallelogramPerimeter;',
20
+ document: 'shape=document;',
21
+ cylinder: 'shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=15;',
22
+ queue: 'shape=cylinder3;direction=north;boundedLbl=1;backgroundOutline=1;size=15;',
23
+ package: 'shape=folder;tabWidth=60;tabHeight=20;tabPosition=left;',
24
+ step: 'shape=step;perimeter=stepPerimeter;',
25
+ callout: 'shape=callout;',
26
+ stored_data: 'shape=dataStorage;',
27
+ person: 'shape=actor;',
28
+ 'c4-person': 'shape=actor;',
29
+ diamond: 'rhombus;',
30
+ oval: 'ellipse;',
31
+ circle: 'ellipse;',
32
+ hexagon: 'shape=hexagon;perimeter=hexagonPerimeter2;',
33
+ cloud: 'shape=cloud;',
34
+ // Structured shapes render as one vertex with a formatted label.
35
+ sql_table: '',
36
+ class: '',
37
+ // Sequence containers render as a plain frame.
38
+ sequence_diagram: '',
39
+ };
40
+
41
+ // D2 arrowhead -> draw.io startArrow/endArrow value + fill flag.
42
+ // The IR vocabulary was probed live: filled variants arrive as
43
+ // "filled-diamond", explicit unfilled triangles as "unfilled-triangle".
44
+ const ARROWHEADS = {
45
+ none: { arrow: 'none', fill: 0 },
46
+ triangle: { arrow: 'block', fill: 1 },
47
+ 'unfilled-triangle': { arrow: 'block', fill: 0 },
48
+ arrow: { arrow: 'classicThin', fill: 1 },
49
+ diamond: { arrow: 'diamond', fill: 0 },
50
+ 'filled-diamond': { arrow: 'diamond', fill: 1 },
51
+ circle: { arrow: 'oval', fill: 0 },
52
+ 'filled-circle': { arrow: 'oval', fill: 1 },
53
+ box: { arrow: 'box', fill: 0 },
54
+ 'filled-box': { arrow: 'box', fill: 1 },
55
+ 'cf-one': { arrow: 'ERone', fill: 0 },
56
+ 'cf-one-required': { arrow: 'ERmandOne', fill: 0 },
57
+ 'cf-many': { arrow: 'ERzeroToMany', fill: 0 },
58
+ 'cf-many-required': { arrow: 'ERoneToMany', fill: 0 },
59
+ cross: { arrow: 'cross', fill: 0 },
60
+ };
61
+
62
+ function fontStyleBits(s) {
63
+ let bits = 0;
64
+ if (s.bold) bits |= 1;
65
+ if (s.italic) bits |= 2;
66
+ if (s.underline) bits |= 4;
67
+ return bits;
68
+ }
69
+
70
+ /** Shared text style fragments for both vertices and edges. */
71
+ function commonTextStyle(s, themeID, style) {
72
+ if (s.fontSize && s.fontSize !== 12) style.push(`fontSize=${s.fontSize}`);
73
+ if (s.color) style.push(`fontColor=${resolveColor(s.color, themeID)}`);
74
+ if (s.fontFamily === 'mono') style.push('fontFamily=Courier New');
75
+ const bits = fontStyleBits(s);
76
+ if (bits) style.push(`fontStyle=${bits}`);
77
+ }
78
+
79
+ /**
80
+ * Translate the IR labelPosition (e.g. INSIDE_TOP_CENTER, OUTSIDE_BOTTOM_LEFT)
81
+ * into draw.io align keys. The INSIDE_MIDDLE_CENTER default adds nothing.
82
+ */
83
+ function labelPositionStyle(pos, style) {
84
+ if (!pos || pos === 'INSIDE_MIDDLE_CENTER' || pos === 'UNLOCKED') return;
85
+ const m = /^(INSIDE|OUTSIDE|BORDER)_(TOP|MIDDLE|BOTTOM)_(LEFT|CENTER|RIGHT)$/.exec(pos);
86
+ if (!m) return;
87
+ const [, region, vert, horiz] = m;
88
+ if (region === 'OUTSIDE' && vert === 'TOP') {
89
+ style.push('verticalLabelPosition=top', 'verticalAlign=bottom');
90
+ } else if (region === 'OUTSIDE' && vert === 'BOTTOM') {
91
+ style.push('verticalLabelPosition=bottom', 'verticalAlign=top');
92
+ } else if (region === 'OUTSIDE' && vert === 'MIDDLE' && horiz === 'LEFT') {
93
+ style.push('labelPosition=left', 'align=right');
94
+ return;
95
+ } else if (region === 'OUTSIDE' && vert === 'MIDDLE' && horiz === 'RIGHT') {
96
+ style.push('labelPosition=right', 'align=left');
97
+ return;
98
+ } else {
99
+ // INSIDE and BORDER variants approximate to inside alignment.
100
+ if (vert === 'TOP') style.push('verticalAlign=top');
101
+ if (vert === 'BOTTOM') style.push('verticalAlign=bottom');
102
+ }
103
+ if (horiz === 'LEFT') style.push('align=left');
104
+ if (horiz === 'RIGHT') style.push('align=right');
105
+ }
106
+
107
+ function vertexStyle(shape, themeID, warnings) {
108
+ const style = [];
109
+ const isContainer = Boolean(shape.__hasChildren);
110
+
111
+ let prefix = SHAPE_STYLES[shape.type];
112
+ if (prefix === undefined) {
113
+ warnings.add(
114
+ `shape-${shape.type}`,
115
+ `shape "${shape.type}" has no draw.io mapping; rendered as a rectangle (first: ${shape.id})`
116
+ );
117
+ prefix = '';
118
+ }
119
+ if (prefix) style.push(prefix.replace(/;$/, ''));
120
+ else style.push(shape.borderRadius > 0 ? 'rounded=1' : 'rounded=0');
121
+ if (prefix && shape.borderRadius > 0) style.push('rounded=1');
122
+ style.push('whiteSpace=wrap', 'html=1');
123
+
124
+ if (isContainer) {
125
+ style.push('container=1', 'collapsible=0', 'verticalAlign=top');
126
+ } else {
127
+ labelPositionStyle(shape.labelPosition, style);
128
+ }
129
+ const isTable = shape.type === 'sql_table' || shape.type === 'class';
130
+ if (isTable) {
131
+ // D2 table semantics invert the usual keys: fill colors the HEADER
132
+ // band, stroke fills the BODY, color is the header text. A single
133
+ // draw.io shape gets the body fill and a header-colored border; the
134
+ // label builder colors the header text to match.
135
+ style.push('align=left', 'spacingLeft=6', 'spacingRight=6', 'verticalAlign=top');
136
+ if (shape.stroke) style.push(`fillColor=${resolveColor(shape.stroke, themeID)}`);
137
+ if (shape.fill) style.push(`strokeColor=${resolveColor(shape.fill, themeID)}`);
138
+ } else {
139
+ if (shape.fill) style.push(`fillColor=${resolveColor(shape.fill, themeID)}`);
140
+ if (shape.stroke) style.push(`strokeColor=${resolveColor(shape.stroke, themeID)}`);
141
+ }
142
+ if (shape.strokeWidth && shape.strokeWidth !== 1) style.push(`strokeWidth=${shape.strokeWidth}`);
143
+ if (shape.strokeDash) {
144
+ style.push('dashed=1', `dashPattern=${shape.strokeDash} ${shape.strokeDash}`);
145
+ }
146
+ if (shape.opacity != null && shape.opacity < 1) {
147
+ style.push(`opacity=${Math.round(shape.opacity * 100)}`);
148
+ }
149
+ if (shape.shadow) style.push('shadow=1');
150
+ if (isTable) {
151
+ // Body text stays default-dark; shape.color is the header text color
152
+ // and is applied inside the label instead.
153
+ if (shape.fontSize && shape.fontSize !== 12) style.push(`fontSize=${shape.fontSize}`);
154
+ } else {
155
+ commonTextStyle(shape, themeID, style);
156
+ }
157
+
158
+ for (const [flag, code] of [
159
+ ['multiple', 'style-multiple'],
160
+ ['3d', 'style-3d'],
161
+ ['double-border', 'style-double-border'],
162
+ ['animated', 'style-animated'],
163
+ ['fillPattern', 'style-fill-pattern'],
164
+ ]) {
165
+ if (shape[flag]) {
166
+ warnings.add(code, `style "${flag}" has no draw.io equivalent; ignored (first: ${shape.id})`);
167
+ }
168
+ }
169
+ return style.join(';') + ';';
170
+ }
171
+
172
+ function textStyle(shape, themeID) {
173
+ const style = ['text', 'html=1', 'align=center', 'verticalAlign=middle'];
174
+ commonTextStyle(shape, themeID, style);
175
+ return style.join(';') + ';';
176
+ }
177
+
178
+ function codeStyle(shape, themeID) {
179
+ const style = [
180
+ 'rounded=0',
181
+ 'whiteSpace=wrap',
182
+ 'html=1',
183
+ 'align=left',
184
+ 'verticalAlign=middle',
185
+ 'spacingLeft=6',
186
+ 'fontFamily=Courier New',
187
+ ];
188
+ if (shape.fill) style.push(`fillColor=${resolveColor(shape.fill, themeID)}`);
189
+ if (shape.stroke) style.push(`strokeColor=${resolveColor(shape.stroke, themeID)}`);
190
+ commonTextStyle(shape, themeID, style);
191
+ return style.join(';') + ';';
192
+ }
193
+
194
+ function imageStyle(shape, url) {
195
+ const style = [
196
+ 'shape=image',
197
+ 'imageAspect=0',
198
+ 'verticalLabelPosition=bottom',
199
+ 'verticalAlign=top',
200
+ 'html=1',
201
+ `image=${url}`,
202
+ ];
203
+ return style.join(';') + ';';
204
+ }
205
+
206
+ function arrowFor(value, connId, warnings) {
207
+ const hit = ARROWHEADS[value];
208
+ if (hit) return hit;
209
+ warnings.add(
210
+ `arrowhead-${value}`,
211
+ `arrowhead "${value}" has no draw.io mapping; using a filled triangle (first: ${connId})`
212
+ );
213
+ return ARROWHEADS.triangle;
214
+ }
215
+
216
+ function edgeStyle(conn, themeID, options, warnings) {
217
+ const style = ['edgeStyle=orthogonalEdgeStyle', 'rounded=0', 'html=1'];
218
+ if (options.waypoints && conn.isCurve) style.push('curved=1');
219
+
220
+ const src = arrowFor(conn.srcArrow ?? 'none', conn.id, warnings);
221
+ const dst = arrowFor(conn.dstArrow ?? 'none', conn.id, warnings);
222
+ style.push(`startArrow=${src.arrow}`, `startFill=${src.fill}`);
223
+ style.push(`endArrow=${dst.arrow}`, `endFill=${dst.fill}`);
224
+
225
+ if (conn.stroke) {
226
+ const c = resolveColor(conn.stroke, themeID);
227
+ style.push(`strokeColor=${c}`);
228
+ if (!conn.color) style.push(`fontColor=${c}`);
229
+ }
230
+ if (conn.strokeWidth && conn.strokeWidth !== 1) style.push(`strokeWidth=${conn.strokeWidth}`);
231
+ if (conn.strokeDash) style.push('dashed=1', `dashPattern=${conn.strokeDash} ${conn.strokeDash}`);
232
+ if (conn.opacity != null && conn.opacity < 1) style.push(`opacity=${Math.round(conn.opacity * 100)}`);
233
+ if (conn.animated) {
234
+ warnings.add(
235
+ 'style-animated',
236
+ `style "animated" has no draw.io equivalent; ignored (first: ${conn.id})`
237
+ );
238
+ }
239
+ commonTextStyle(conn, themeID, style);
240
+ return style.join(';') + ';';
241
+ }
242
+
243
+ /**
244
+ * Find a shape's parent id: the longest dotted prefix that names an existing
245
+ * shape one nesting level up. Quoted segments (ids may contain '"x.y"') are
246
+ * safe because a prefix cut inside quotes never matches an existing id.
247
+ */
248
+ function parentIdOf(shape, byId) {
249
+ if (shape.level == null || shape.level <= 1) return null;
250
+ const id = shape.id;
251
+ for (let i = id.length - 1; i > 0; i--) {
252
+ if (id[i] !== '.') continue;
253
+ const candidate = id.slice(0, i);
254
+ const parent = byId.get(candidate);
255
+ if (parent && parent.level === shape.level - 1) return candidate;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /** Build the label cell value for a shape, dispatching on its type. */
261
+ function shapeValue(shape, warnings, themeID) {
262
+ const headerColor = shape.fill ? resolveColor(shape.fill, themeID) : null;
263
+ if (shape.type === 'sql_table') {
264
+ warnings.add(
265
+ 'sql-table-label',
266
+ `sql_table rendered as a single shape with formatted rows, not editable table cells (first: ${shape.id})`
267
+ );
268
+ return sqlTableLabel(shape, headerColor);
269
+ }
270
+ if (shape.type === 'class') {
271
+ warnings.add(
272
+ 'class-label',
273
+ `class rendered as a single shape with formatted members, not editable rows (first: ${shape.id})`
274
+ );
275
+ return classLabel(shape, headerColor);
276
+ }
277
+ if (shape.language === 'latex' || shape.language === 'tex') {
278
+ // draw.io renders $$...$$ via MathJax when the page has math=1.
279
+ return `$$${escHtml(shape.label ?? '')}$$`;
280
+ }
281
+ if (shape.type === 'text' && shape.language === 'markdown') {
282
+ warnings.add(
283
+ 'text-markdown',
284
+ `markdown text is rendered as plain text; formatting is lost (first: ${shape.id})`
285
+ );
286
+ }
287
+ return htmlLabel(shape.label ?? '');
288
+ }
289
+
290
+ /**
291
+ * Map one board (diagram IR) to a draw.io page.
292
+ *
293
+ * @returns {{name: string, cells: object[], background: string|null, math: boolean}}
294
+ */
295
+ export function mapBoard(diagram, options, warnings) {
296
+ const themeID = options.themeID ?? 0;
297
+ const idFor = createIdAllocator();
298
+ const byId = new Map();
299
+ for (const s of diagram.shapes ?? []) byId.set(s.id, s);
300
+
301
+ // Mark containers and remember sequence-diagram subtrees: their inner
302
+ // edges carry positions along lifelines that anchor-based routing loses.
303
+ const seqPrefixes = [];
304
+ for (const s of byId.values()) {
305
+ const pid = parentIdOf(s, byId);
306
+ s.__parentId = pid;
307
+ if (pid) byId.get(pid).__hasChildren = true;
308
+ if (s.type === 'sequence_diagram') seqPrefixes.push(s.id + '.');
309
+ }
310
+ const inSequence = (id) => seqPrefixes.some((p) => String(id).startsWith(p));
311
+
312
+ const cells = [];
313
+ const emitted = new Set();
314
+ let pageMath = false;
315
+
316
+ // Parents first (level ascending), then z-index, then IR order.
317
+ const order = new Map([...byId.keys()].map((k, i) => [k, i]));
318
+ const shapes = [...byId.values()].sort(
319
+ (a, b) =>
320
+ (a.level ?? 1) - (b.level ?? 1) ||
321
+ (a.zIndex ?? 0) - (b.zIndex ?? 0) ||
322
+ order.get(a.id) - order.get(b.id)
323
+ );
324
+
325
+ for (const shape of shapes) {
326
+ const parentShape = shape.__parentId ? byId.get(shape.__parentId) : null;
327
+ const x = shape.pos.x - (parentShape ? parentShape.pos.x : 0);
328
+ const y = shape.pos.y - (parentShape ? parentShape.pos.y : 0);
329
+
330
+ let style;
331
+ let value;
332
+ const url = iconUrl(shape.icon);
333
+ if (shape.language === 'latex' || shape.language === 'tex') pageMath = true;
334
+ if (shape.type === 'text') {
335
+ value = shapeValue(shape, warnings, themeID);
336
+ style = textStyle(shape, themeID);
337
+ } else if (shape.type === 'code') {
338
+ value = htmlLabel(shape.label ?? '');
339
+ style = codeStyle(shape, themeID);
340
+ } else if (shape.type === 'image' && url) {
341
+ value = htmlLabel(shape.label ?? '');
342
+ style = imageStyle(shape, url);
343
+ } else {
344
+ if (url) {
345
+ warnings.add(
346
+ 'icon-on-shape',
347
+ `icons on regular shapes are not mapped; icon dropped (first: ${shape.id})`
348
+ );
349
+ }
350
+ value = shapeValue(shape, warnings, themeID);
351
+ style = vertexStyle(shape, themeID, warnings);
352
+ }
353
+
354
+ if (shape.link && /^(layers|scenarios|steps|root|_)(\.|$)/.test(shape.link)) {
355
+ warnings.add(
356
+ 'board-link',
357
+ `links to boards (${shape.link}) are kept as literal text and will not navigate in draw.io (first: ${shape.id})`
358
+ );
359
+ }
360
+ cells.push({
361
+ id: idFor(shape.id),
362
+ value,
363
+ style,
364
+ parent: parentShape ? idFor(parentShape.id) : '1',
365
+ x,
366
+ y,
367
+ w: shape.width,
368
+ h: shape.height,
369
+ tooltip: shape.tooltip || null,
370
+ link: shape.link || null,
371
+ });
372
+ emitted.add(shape.id);
373
+ }
374
+
375
+ let edgeN = 0;
376
+ for (const conn of diagram.connections ?? []) {
377
+ const route = Array.isArray(conn.route) ? conn.route : [];
378
+ const srcOk = emitted.has(conn.src);
379
+ const dstOk = emitted.has(conn.dst);
380
+ const sequence = inSequence(conn.src) || inSequence(conn.dst);
381
+
382
+ // Fixed-geometry edges: sequence content (messages, lifelines, and any
383
+ // connection with a phantom endpoint) keeps d2's computed route.
384
+ const fixed = (sequence || !srcOk || !dstOk) && route.length >= 2;
385
+ if ((!srcOk || !dstOk) && !fixed) {
386
+ warnings.add(
387
+ 'edge-endpoint-missing',
388
+ `connection ${conn.src} -> ${conn.dst} skipped: endpoint not rendered`
389
+ );
390
+ continue;
391
+ }
392
+ if (sequence) {
393
+ warnings.add(
394
+ 'sequence-fixed',
395
+ 'sequence diagram messages and lifelines use fixed geometry; they do not re-route when shapes move'
396
+ );
397
+ }
398
+
399
+ const cell = {
400
+ id: `edge-${edgeN++}`,
401
+ value: htmlLabel(conn.label ?? ''),
402
+ style: edgeStyle(conn, themeID, options, warnings),
403
+ parent: '1',
404
+ edge: true,
405
+ tooltip: conn.tooltip || null,
406
+ link: conn.link || null,
407
+ };
408
+ if (fixed) {
409
+ cell.sourcePoint = route[0];
410
+ cell.targetPoint = route[route.length - 1];
411
+ if (route.length > 2) cell.points = route.slice(1, -1);
412
+ // Straight segments, not orthogonal re-routing, for fixed geometry.
413
+ cell.style = cell.style.replace('edgeStyle=orthogonalEdgeStyle;', 'edgeStyle=none;');
414
+ } else {
415
+ cell.source = idFor(conn.src);
416
+ cell.target = idFor(conn.dst);
417
+ if (options.waypoints && route.length > 2) {
418
+ cell.points = route.slice(1, -1);
419
+ }
420
+ }
421
+ cells.push(cell);
422
+
423
+ // Arrowhead labels ride the edge as standard draw.io edge-label
424
+ // children positioned near their end.
425
+ for (const [end, rx] of [
426
+ ['srcLabel', -0.75],
427
+ ['dstLabel', 0.75],
428
+ ]) {
429
+ const l = conn[end];
430
+ if (l && l.label) {
431
+ cells.push({
432
+ id: `${cell.id}-${end === 'srcLabel' ? 'src' : 'dst'}-label`,
433
+ value: htmlLabel(l.label),
434
+ style: 'edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;',
435
+ parent: cell.id,
436
+ edgeLabel: true,
437
+ rx,
438
+ });
439
+ }
440
+ }
441
+ }
442
+
443
+ // The board's root pseudo-shape carries the page background fill.
444
+ const background = diagram.root?.fill ? resolveColor(diagram.root.fill, themeID) : null;
445
+
446
+ return { name: diagram.name || 'Page-1', cells, background, math: pageMath };
447
+ }
package/src/special.js ADDED
@@ -0,0 +1,56 @@
1
+ // HTML label builders for D2's structured shapes (sql_table, class).
2
+ //
3
+ // Both render as a single draw.io vertex whose html=1 label reproduces the
4
+ // header and rows. Rows are not individually editable cells; the feature
5
+ // matrix documents this as partial support.
6
+
7
+ import { escHtml } from './emit.js';
8
+
9
+ const VISIBILITY_PREFIX = { public: '+ ', private: '- ', protected: '# ' };
10
+
11
+ /** Rebuild a URL string from the Go net/url object the IR carries. */
12
+ export function iconUrl(icon) {
13
+ if (!icon || typeof icon !== 'object') return null;
14
+ const scheme = icon.Scheme ? `${icon.Scheme}://` : '';
15
+ const path = icon.RawPath || icon.Path || '';
16
+ const query = icon.RawQuery ? `?${icon.RawQuery}` : '';
17
+ const fragment = icon.Fragment ? `#${icon.Fragment}` : '';
18
+ return `${scheme}${icon.Host ?? ''}${path}${query}${fragment}` || null;
19
+ }
20
+
21
+ function header(text, color) {
22
+ const inner = `<b>${escHtml(text)}</b>`;
23
+ return color ? `<font color="${color}">${inner}</font>` : inner;
24
+ }
25
+
26
+ /** sql_table: header + one line per column with type and constraint tags. */
27
+ export function sqlTableLabel(shape, headerColor = null) {
28
+ const rows = (shape.columns ?? []).map((col) => {
29
+ const name = escHtml(col.name?.label ?? '');
30
+ const type = escHtml(col.type?.label ?? '');
31
+ const tags = (col.constraint ?? [])
32
+ .map((c) => (c === 'primary_key' ? 'PK' : c === 'foreign_key' ? 'FK' : c === 'unique' ? 'UNQ' : escHtml(c)))
33
+ .join(', ');
34
+ return `${name}: ${type}${tags ? `&nbsp;&nbsp;<i>${tags}</i>` : ''}`;
35
+ });
36
+ return `${header(shape.label ?? '', headerColor)}<hr>${rows.join('<br>')}`;
37
+ }
38
+
39
+ /** class: header + fields block + methods block, UML visibility prefixes. */
40
+ export function classLabel(shape, headerColor = null) {
41
+ const fields = (shape.fields ?? []).map((f) => {
42
+ const vis = VISIBILITY_PREFIX[f.visibility] ?? '';
43
+ const line = `${vis}${escHtml(f.name)}: ${escHtml(f.type)}`;
44
+ return f.underline ? `<u>${line}</u>` : line;
45
+ });
46
+ const methods = (shape.methods ?? []).map((m) => {
47
+ const vis = VISIBILITY_PREFIX[m.visibility] ?? '';
48
+ const ret = m.return ? `: ${escHtml(m.return)}` : '';
49
+ const line = `${vis}${escHtml(m.name)}${ret}`;
50
+ return m.underline ? `<u>${line}</u>` : line;
51
+ });
52
+ let label = header(shape.label ?? '', headerColor);
53
+ label += `<hr>${fields.join('<br>')}`;
54
+ if (methods.length > 0) label += `<hr>${methods.join('<br>')}`;
55
+ return label;
56
+ }
@@ -0,0 +1,25 @@
1
+ // D2 theme palettes, extracted from the official terrastruct/d2 theme
2
+ // catalog (d2themescatalog) and verified against live WASM renders.
3
+ // Codes: N1-N7 neutrals, B1-B6 base, AA2/AA4/AA5 accent A, AB4/AB5 accent B.
4
+ export const THEMES = {
5
+ '0': { name: 'Neutral Default', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#0D32B2', B2: '#0D32B2', B3: '#E3E9FD', B4: '#E3E9FD', B5: '#EDF0FD', B6: '#F7F8FE', AA2: '#4A6FF3', AA4: '#EDF0FD', AA5: '#F7F8FE', AB4: '#EDF0FD', AB5: '#F7F8FE' } },
6
+ '1': { name: 'Neutral Grey', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#0A0F25', B2: '#676C7E', B3: '#9499AB', B4: '#CFD2DD', B5: '#DEE1EB', B6: '#EEF1F8', AA2: '#676C7E', AA4: '#CFD2DD', AA5: '#DEE1EB', AB4: '#CFD2DD', AB5: '#DEE1EB' } },
7
+ '3': { name: 'Flagship Terrastruct', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#000E3D', B2: '#234CDA', B3: '#6B8AFB', B4: '#A6B8F8', B5: '#D2DBFD', B6: '#E7EAFF', AA2: '#5829DC', AA4: '#B4AEF8', AA5: '#E4DBFF', AB4: '#7FDBF8', AB5: '#C3F0FF' } },
8
+ '4': { name: 'Cool Classics', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#000536', B2: '#0F66B7', B3: '#4393DD', B4: '#87BFF3', B5: '#BCDDFB', B6: '#E5F3FF', AA2: '#076F6F', AA4: '#77DEDE', AA5: '#C3F8F8', AB4: '#C1A2F3', AB5: '#DACEFB' } },
9
+ '5': { name: 'Mixed Berry Blue', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#000536', B2: '#0F66B7', B3: '#4393DD', B4: '#87BFF3', B5: '#BCDDFB', B6: '#E5F3FF', AA2: '#7639C5', AA4: '#C1A2F3', AA5: '#DACEFB', AB4: '#EA99C6', AB5: '#FFDEF1' } },
10
+ '6': { name: 'Grape Soda', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#170034', B2: '#7639C5', B3: '#8F70D1', B4: '#C1A2F3', B5: '#DACEFB', B6: '#F2EDFF', AA2: '#0F66B7', AA4: '#87BFF3', AA5: '#BCDDFB', AB4: '#EA99C6', AB5: '#FFDAEF' } },
11
+ '7': { name: 'Aubergine', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#170034', B2: '#7639C5', B3: '#8F70D1', B4: '#D0B9F5', B5: '#E7DEFF', B6: '#F4F0FF', AA2: '#0F66B7', AA4: '#87BFF3', AA5: '#BCDDFB', AB4: '#92E3E3', AB5: '#D7F5F5' } },
12
+ '8': { name: 'Colorblind Clear', colors: { N1: '#0A0F25', N2: '#676C7E', N3: '#9499AB', N4: '#CFD2DD', N5: '#DEE1EB', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#010E31', B2: '#173688', B3: '#5679D4', B4: '#84A1EC', B5: '#C8D6F9', B6: '#E5EDFF', AA2: '#048E63', AA4: '#A6E2D0', AA5: '#CAF2E6', AB4: '#FFDA90', AB5: '#FFF0D1' } },
13
+ '100': { name: 'Vanilla Nitro Cola', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#1E1303', B2: '#55452F', B3: '#9A876C', B4: '#C9B9A1', B5: '#E9DBCA', B6: '#FAF1E6', AA2: '#D35F0A', AA4: '#FABA8A', AA5: '#FFE0C7', AB4: '#84A1EC', AB5: '#D5E0FD' } },
14
+ '101': { name: 'Orange Creamsicle', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#311602', B2: '#D35F0A', B3: '#F18F47', B4: '#FABA8A', B5: '#FFE0C7', B6: '#FFF6EF', AA2: '#13A477', AA4: '#A6E2D0', AA5: '#CAF2E6', AB4: '#FEEC8C', AB5: '#FFF8CF' } },
15
+ '102': { name: 'Shirley Temple', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#31021D', B2: '#9B1A48', B3: '#D2517F', B4: '#EA99B6', B5: '#FFDAE7', B6: '#FCEDF2', AA2: '#D35F0A', AA4: '#FABA8A', AA5: '#FFE0C7', AB4: '#FFE767', AB5: '#FFF2AA' } },
16
+ '103': { name: 'Earth Tones', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#1E1303', B2: '#55452F', B3: '#9A876C', B4: '#C9B9A1', B5: '#E9DBCA', B6: '#FAF1E6', AA2: '#D35F0A', AA4: '#FABA8A', AA5: '#FFE0C7', AB4: '#FFE767', AB5: '#FFF2AA' } },
17
+ '104': { name: 'Everglade Green', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#023324', B2: '#048E63', B3: '#49BC99', B4: '#A6E2D0', B5: '#CAF2E6', B6: '#EBFDF7', AA2: '#D35F0A', AA4: '#FABA8A', AA5: '#FFE0C7', AB4: '#C9B9A1', AB5: '#E9DBCA' } },
18
+ '105': { name: 'Buttered Toast', colors: { N1: '#170206', N2: '#535152', N3: '#787777', N4: '#CCCACA', N5: '#DFDCDC', N6: '#ECEBEB', N7: '#FFFFFF', B1: '#312102', B2: '#DF9C18', B3: '#FDC659', B4: '#FFDA90', B5: '#FFF0D1', B6: '#FFF7E7', AA2: '#55452F', AA4: '#C9B9A1', AA5: '#E9DBCA', AB4: '#FABA8A', AB5: '#FFE0C7' } },
19
+ '200': { name: 'Dark Mauve', colors: { N1: '#CDD6F4', N2: '#BAC2DE', N3: '#A6ADC8', N4: '#585B70', N5: '#45475A', N6: '#313244', N7: '#1E1E2E', B1: '#CBA6f7', B2: '#CBA6f7', B3: '#6C7086', B4: '#585B70', B5: '#45475A', B6: '#313244', AA2: '#f38BA8', AA4: '#45475A', AA5: '#313244', AB4: '#45475A', AB5: '#313244' } },
20
+ '201': { name: 'Dark Flagship Terrastruct', colors: { N1: '#F4F6FA', N2: '#BBBEC9', N3: '#868A96', N4: '#676D7D', N5: '#3A3D49', N6: '#191C28', N7: '#000410', B1: '#F4F6FA', B2: '#6B8AFB', B3: '#3733E9', B4: '#070B67', B5: '#0B1197', B6: '#3733E9', AA2: '#8B5DEE', AA4: '#4918B1', AA5: '#7240DD', AB4: '#00607C', AB5: '#01799D' } },
21
+ '300': { name: 'Terminal', colors: { N1: '#000410', N2: '#0000B8', N3: '#9499AB', N4: '#CFD2DD', N5: '#C3DEF3', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#000410', B2: '#0000E4', B3: '#5AA4DC', B4: '#E7E9EE', B5: '#F5F6F9', B6: '#FFFFFF', AA2: '#008566', AA4: '#45BBA5', AA5: '#7ACCBD', AB4: '#F1C759', AB5: '#F9E088' } },
22
+ '301': { name: 'Terminal Grayscale', colors: { N1: '#000410', N2: '#000410', N3: '#9499AB', N4: '#FFFFFF', N5: '#FFFFFF', N6: '#EEF1F8', N7: '#FFFFFF', B1: '#000410', B2: '#000410', B3: '#FFFFFF', B4: '#E7E9EE', B5: '#F5F6F9', B6: '#FFFFFF', AA2: '#6D7284', AA4: '#F5F6F9', AA5: '#FFFFFF', AB4: '#F5F6F9', AB5: '#FFFFFF' } },
23
+ '302': { name: 'Origami', colors: { N1: '#170206', N2: '#6F0019', N3: '#FFFFFF', N4: '#E07088', N5: '#D2B098', N6: '#FFFFFF', N7: '#FFFFFF', B1: '#170206', B2: '#A62543', B3: '#E07088', B4: '#F3E0D2', B5: '#FAF1E6', B6: '#FFFBF8', AA2: '#0A4EA6', AA4: '#3182CD', AA5: '#68A8E4', AB4: '#E07088', AB5: '#F19CAE' } },
24
+ '303': { name: 'C4', colors: { N1: '#0f5eaa', N2: '#707070', N3: '#FFFFFF', N4: '#073b6f', N5: '#999999', N6: '#FFFFFF', N7: '#FFFFFF', B1: '#073b6f', B2: '#08427b', B3: '#3c7fc0', B4: '#438dd5', B5: '#8a8a8a', B6: '#999999', AA2: '#0f5eaa', AA4: '#707070', AA5: '#f5f5f5', AB4: '#e1e1e1', AB5: '#f0f0f0' } },
25
+ };