@trohde/excal-cli 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 +195 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1245 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/render/bridge.global.js +17861 -0
- package/package.json +73 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command, CommanderError } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/core/envelope.ts
|
|
7
|
+
function buildEnvelope(opts) {
|
|
8
|
+
return {
|
|
9
|
+
schema_version: "1.0",
|
|
10
|
+
request_id: opts.request_id,
|
|
11
|
+
ok: opts.ok,
|
|
12
|
+
command: opts.command,
|
|
13
|
+
target: opts.target ?? null,
|
|
14
|
+
result: opts.result,
|
|
15
|
+
warnings: opts.warnings ?? [],
|
|
16
|
+
errors: opts.errors ?? [],
|
|
17
|
+
metrics: { duration_ms: opts.duration_ms }
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/core/errors.ts
|
|
22
|
+
var CliError = class extends Error {
|
|
23
|
+
constructor(structured) {
|
|
24
|
+
super(structured.message);
|
|
25
|
+
this.structured = structured;
|
|
26
|
+
this.name = "CliError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
function validationError(code, message, details) {
|
|
30
|
+
return new CliError({
|
|
31
|
+
code: `ERR_VALIDATION_${code}`,
|
|
32
|
+
message,
|
|
33
|
+
retryable: false,
|
|
34
|
+
suggested_action: "fix_input",
|
|
35
|
+
details
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function renderError(code, message, details) {
|
|
39
|
+
return new CliError({
|
|
40
|
+
code: `ERR_RENDER_${code}`,
|
|
41
|
+
message,
|
|
42
|
+
retryable: false,
|
|
43
|
+
suggested_action: "escalate",
|
|
44
|
+
details
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function ioError(code, message, details) {
|
|
48
|
+
return new CliError({
|
|
49
|
+
code: `ERR_IO_${code}`,
|
|
50
|
+
message,
|
|
51
|
+
retryable: true,
|
|
52
|
+
suggested_action: "retry",
|
|
53
|
+
details
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function internalError(message, details) {
|
|
57
|
+
return new CliError({
|
|
58
|
+
code: "ERR_INTERNAL_UNEXPECTED",
|
|
59
|
+
message,
|
|
60
|
+
retryable: false,
|
|
61
|
+
suggested_action: "escalate",
|
|
62
|
+
details
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function errorMessage(err) {
|
|
66
|
+
return err instanceof Error ? err.message : String(err);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/core/exit-codes.ts
|
|
70
|
+
var ExitCode = {
|
|
71
|
+
OK: 0,
|
|
72
|
+
VALIDATION: 10,
|
|
73
|
+
RENDER: 20,
|
|
74
|
+
IO: 50,
|
|
75
|
+
INTERNAL: 90
|
|
76
|
+
};
|
|
77
|
+
function exitCodeForError(code) {
|
|
78
|
+
if (code.startsWith("ERR_VALIDATION")) return ExitCode.VALIDATION;
|
|
79
|
+
if (code.startsWith("ERR_RENDER")) return ExitCode.RENDER;
|
|
80
|
+
if (code.startsWith("ERR_IO")) return ExitCode.IO;
|
|
81
|
+
return ExitCode.INTERNAL;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/core/request-id.ts
|
|
85
|
+
import { randomBytes } from "crypto";
|
|
86
|
+
function generateRequestId() {
|
|
87
|
+
const now = /* @__PURE__ */ new Date();
|
|
88
|
+
const pad = (n, len = 2) => String(n).padStart(len, "0");
|
|
89
|
+
const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
90
|
+
const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
91
|
+
const rand = randomBytes(2).toString("hex");
|
|
92
|
+
return `req_${date}_${time}_${rand}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/timer.ts
|
|
96
|
+
var Timer = class {
|
|
97
|
+
start;
|
|
98
|
+
constructor() {
|
|
99
|
+
this.start = process.hrtime.bigint();
|
|
100
|
+
}
|
|
101
|
+
elapsed() {
|
|
102
|
+
const diff = process.hrtime.bigint() - this.start;
|
|
103
|
+
return Number(diff / 1000000n);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// src/core/command-wrapper.ts
|
|
108
|
+
function wrapCommand(command, handler) {
|
|
109
|
+
return async (...args) => {
|
|
110
|
+
const timer = new Timer();
|
|
111
|
+
const requestId = generateRequestId();
|
|
112
|
+
const warnings = [];
|
|
113
|
+
const ctx = {
|
|
114
|
+
requestId,
|
|
115
|
+
timer,
|
|
116
|
+
warnings,
|
|
117
|
+
warn(w) {
|
|
118
|
+
warnings.push(w);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
let envelope;
|
|
122
|
+
try {
|
|
123
|
+
const { result, target } = await handler(ctx, ...args);
|
|
124
|
+
envelope = buildEnvelope({
|
|
125
|
+
request_id: requestId,
|
|
126
|
+
command,
|
|
127
|
+
target,
|
|
128
|
+
result,
|
|
129
|
+
ok: true,
|
|
130
|
+
warnings,
|
|
131
|
+
duration_ms: timer.elapsed()
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
const structured = err instanceof CliError ? err.structured : internalError(errorMessage(err)).structured;
|
|
135
|
+
envelope = buildEnvelope({
|
|
136
|
+
request_id: requestId,
|
|
137
|
+
command,
|
|
138
|
+
result: null,
|
|
139
|
+
ok: false,
|
|
140
|
+
warnings,
|
|
141
|
+
errors: [structured],
|
|
142
|
+
duration_ms: timer.elapsed()
|
|
143
|
+
});
|
|
144
|
+
process.exitCode = exitCodeForError(structured.code);
|
|
145
|
+
}
|
|
146
|
+
const json = JSON.stringify(envelope, null, 2) + "\n";
|
|
147
|
+
await new Promise((resolve2) => {
|
|
148
|
+
process.stdout.write(json, () => resolve2());
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/scene/schema.ts
|
|
154
|
+
import { z } from "zod";
|
|
155
|
+
var ExcalidrawElementSchema = z.object({
|
|
156
|
+
id: z.string(),
|
|
157
|
+
type: z.string(),
|
|
158
|
+
x: z.number(),
|
|
159
|
+
y: z.number(),
|
|
160
|
+
width: z.number(),
|
|
161
|
+
height: z.number(),
|
|
162
|
+
isDeleted: z.boolean().optional().default(false),
|
|
163
|
+
opacity: z.number().optional().default(100),
|
|
164
|
+
groupIds: z.array(z.string()).optional().default([]),
|
|
165
|
+
frameId: z.string().nullable().optional().default(null),
|
|
166
|
+
boundElements: z.array(
|
|
167
|
+
z.object({
|
|
168
|
+
id: z.string(),
|
|
169
|
+
type: z.string()
|
|
170
|
+
})
|
|
171
|
+
).nullable().optional().default(null),
|
|
172
|
+
// Text-specific
|
|
173
|
+
text: z.string().optional(),
|
|
174
|
+
fontSize: z.number().optional(),
|
|
175
|
+
fontFamily: z.number().optional(),
|
|
176
|
+
containerId: z.string().nullable().optional().default(null),
|
|
177
|
+
// Arrow-specific
|
|
178
|
+
startBinding: z.object({
|
|
179
|
+
elementId: z.string(),
|
|
180
|
+
focus: z.number(),
|
|
181
|
+
gap: z.number()
|
|
182
|
+
}).nullable().optional().default(null),
|
|
183
|
+
endBinding: z.object({
|
|
184
|
+
elementId: z.string(),
|
|
185
|
+
focus: z.number(),
|
|
186
|
+
gap: z.number()
|
|
187
|
+
}).nullable().optional().default(null),
|
|
188
|
+
// Image-specific
|
|
189
|
+
fileId: z.string().nullable().optional().default(null),
|
|
190
|
+
// Frame-specific
|
|
191
|
+
name: z.string().nullable().optional().default(null)
|
|
192
|
+
}).passthrough();
|
|
193
|
+
var ExcalidrawSceneSchema = z.object({
|
|
194
|
+
type: z.string().optional().default("excalidraw"),
|
|
195
|
+
version: z.number().optional().default(2),
|
|
196
|
+
source: z.string().optional(),
|
|
197
|
+
elements: z.array(ExcalidrawElementSchema),
|
|
198
|
+
appState: z.record(z.unknown()).optional().default({}),
|
|
199
|
+
files: z.record(z.unknown()).optional().default({})
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// src/core/io.ts
|
|
203
|
+
import { readFile, writeFile, rename, mkdir } from "fs/promises";
|
|
204
|
+
import { dirname, join } from "path";
|
|
205
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
206
|
+
async function readInput(fileOrDash) {
|
|
207
|
+
if (fileOrDash === "-") {
|
|
208
|
+
return readStdin();
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const content = await readFile(fileOrDash, "utf-8");
|
|
212
|
+
return { content, source: fileOrDash };
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const msg = errorMessage(err);
|
|
215
|
+
throw ioError("READ_FAILED", `Failed to read file: ${fileOrDash}: ${msg}`, {
|
|
216
|
+
path: fileOrDash
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function readStdin() {
|
|
221
|
+
return new Promise((resolve2, reject) => {
|
|
222
|
+
const chunks = [];
|
|
223
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
224
|
+
process.stdin.on("end", () => {
|
|
225
|
+
resolve2({
|
|
226
|
+
content: Buffer.concat(chunks).toString("utf-8"),
|
|
227
|
+
source: "stdin"
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
process.stdin.on("error", (err) => {
|
|
231
|
+
reject(ioError("STDIN_FAILED", `Failed to read stdin: ${err.message}`));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
async function ensureDir(dir) {
|
|
236
|
+
await mkdir(dir, { recursive: true });
|
|
237
|
+
}
|
|
238
|
+
async function writeOutput(path, data) {
|
|
239
|
+
try {
|
|
240
|
+
await ensureDir(dirname(path));
|
|
241
|
+
const tmpPath = join(dirname(path), `.tmp_${randomBytes2(4).toString("hex")}`);
|
|
242
|
+
await writeFile(tmpPath, data);
|
|
243
|
+
await rename(tmpPath, path);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const msg = errorMessage(err);
|
|
246
|
+
throw ioError("WRITE_FAILED", `Failed to write file: ${path}: ${msg}`, {
|
|
247
|
+
path
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/core/fingerprint.ts
|
|
253
|
+
import { createHash } from "crypto";
|
|
254
|
+
function sha256(data) {
|
|
255
|
+
return createHash("sha256").update(data).digest("hex");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/scene/load-scene.ts
|
|
259
|
+
async function loadScene(fileOrDash) {
|
|
260
|
+
const { content, source } = await readInput(fileOrDash);
|
|
261
|
+
const fingerprint = sha256(content);
|
|
262
|
+
let json;
|
|
263
|
+
try {
|
|
264
|
+
json = JSON.parse(content);
|
|
265
|
+
} catch {
|
|
266
|
+
throw validationError("INVALID_JSON", "Input is not valid JSON", { source });
|
|
267
|
+
}
|
|
268
|
+
const parsed = detectAndParse(json, source);
|
|
269
|
+
return { parsed, source, fingerprint };
|
|
270
|
+
}
|
|
271
|
+
function detectAndParse(json, source) {
|
|
272
|
+
if (isObject(json) && json.type === "excalidraw/clipboard" && "elements" in json) {
|
|
273
|
+
const sceneData = { ...json, type: "excalidraw" };
|
|
274
|
+
const result = ExcalidrawSceneSchema.safeParse(sceneData);
|
|
275
|
+
if (result.success) return result.data;
|
|
276
|
+
throw validationError(
|
|
277
|
+
"INVALID_CLIPBOARD",
|
|
278
|
+
`Clipboard data validation failed: ${result.error.message}`,
|
|
279
|
+
{ source }
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (isObject(json) && "elements" in json && Array.isArray(json.elements)) {
|
|
283
|
+
const result = ExcalidrawSceneSchema.safeParse(json);
|
|
284
|
+
if (result.success) return result.data;
|
|
285
|
+
throw validationError("INVALID_SCENE", `Scene validation failed: ${result.error.message}`, {
|
|
286
|
+
source,
|
|
287
|
+
issues: result.error.issues
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(json)) {
|
|
291
|
+
const wrapped = { type: "excalidraw", version: 2, elements: json };
|
|
292
|
+
const result = ExcalidrawSceneSchema.safeParse(wrapped);
|
|
293
|
+
if (result.success) return result.data;
|
|
294
|
+
throw validationError(
|
|
295
|
+
"INVALID_ELEMENTS",
|
|
296
|
+
`Elements array validation failed: ${result.error.message}`,
|
|
297
|
+
{ source, issues: result.error.issues }
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
throw validationError("UNKNOWN_FORMAT", "Input is not a recognized Excalidraw format", {
|
|
301
|
+
source
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
function isObject(v) {
|
|
305
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// src/scene/inspect-scene.ts
|
|
309
|
+
function inspectScene(scene) {
|
|
310
|
+
const countsByType = {};
|
|
311
|
+
const deletedCountsByType = {};
|
|
312
|
+
const frameElements = [];
|
|
313
|
+
const images = [];
|
|
314
|
+
const frameChildCounts = /* @__PURE__ */ new Map();
|
|
315
|
+
let deletedCount = 0;
|
|
316
|
+
let textCount = 0;
|
|
317
|
+
let boundTextCount = 0;
|
|
318
|
+
let minX = Infinity;
|
|
319
|
+
let minY = Infinity;
|
|
320
|
+
let maxX = -Infinity;
|
|
321
|
+
let maxY = -Infinity;
|
|
322
|
+
let liveCount = 0;
|
|
323
|
+
for (const el of scene.elements) {
|
|
324
|
+
if (el.isDeleted) {
|
|
325
|
+
deletedCount++;
|
|
326
|
+
deletedCountsByType[el.type] = (deletedCountsByType[el.type] ?? 0) + 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
liveCount++;
|
|
330
|
+
countsByType[el.type] = (countsByType[el.type] ?? 0) + 1;
|
|
331
|
+
minX = Math.min(minX, el.x);
|
|
332
|
+
minY = Math.min(minY, el.y);
|
|
333
|
+
maxX = Math.max(maxX, el.x + el.width);
|
|
334
|
+
maxY = Math.max(maxY, el.y + el.height);
|
|
335
|
+
if (el.frameId) {
|
|
336
|
+
frameChildCounts.set(el.frameId, (frameChildCounts.get(el.frameId) ?? 0) + 1);
|
|
337
|
+
}
|
|
338
|
+
if (el.type === "frame") {
|
|
339
|
+
frameElements.push(el);
|
|
340
|
+
}
|
|
341
|
+
if (el.type === "image") {
|
|
342
|
+
images.push({ id: el.id, fileId: el.fileId, width: el.width, height: el.height });
|
|
343
|
+
}
|
|
344
|
+
if (el.type === "text") {
|
|
345
|
+
textCount++;
|
|
346
|
+
if (el.containerId) boundTextCount++;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const frames = frameElements.map((f) => ({
|
|
350
|
+
id: f.id,
|
|
351
|
+
name: f.name,
|
|
352
|
+
child_count: frameChildCounts.get(f.id) ?? 0
|
|
353
|
+
}));
|
|
354
|
+
const bounding_box = liveCount === 0 ? null : { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
|
|
355
|
+
const binaryFiles = [];
|
|
356
|
+
if (scene.files && typeof scene.files === "object") {
|
|
357
|
+
for (const [id, file] of Object.entries(scene.files)) {
|
|
358
|
+
if (isFileEntry(file)) {
|
|
359
|
+
const size = typeof file.dataURL === "string" ? Math.round(file.dataURL.length * 3 / 4) : 0;
|
|
360
|
+
binaryFiles.push({ id, mimeType: file.mimeType ?? "unknown", size });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
element_count: liveCount,
|
|
366
|
+
deleted_count: deletedCount,
|
|
367
|
+
counts_by_type: countsByType,
|
|
368
|
+
deleted_counts_by_type: deletedCountsByType,
|
|
369
|
+
frames,
|
|
370
|
+
images,
|
|
371
|
+
text_stats: {
|
|
372
|
+
count: textCount,
|
|
373
|
+
bound_count: boundTextCount,
|
|
374
|
+
unbound_count: textCount - boundTextCount
|
|
375
|
+
},
|
|
376
|
+
bounding_box,
|
|
377
|
+
binary_files: binaryFiles
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function isFileEntry(v) {
|
|
381
|
+
return typeof v === "object" && v !== null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/scene/filters.ts
|
|
385
|
+
function applyFilters(scene, opts) {
|
|
386
|
+
let elements = scene.elements;
|
|
387
|
+
const warnings = [];
|
|
388
|
+
if (!opts.includeDeleted) {
|
|
389
|
+
elements = elements.filter((e) => !e.isDeleted);
|
|
390
|
+
}
|
|
391
|
+
if (opts.frameId) {
|
|
392
|
+
const hasById = elements.some((e) => e.id === opts.frameId || e.frameId === opts.frameId);
|
|
393
|
+
if (hasById) {
|
|
394
|
+
elements = filterByFrame(elements, opts.frameId);
|
|
395
|
+
} else {
|
|
396
|
+
const frame = elements.find(
|
|
397
|
+
(e) => e.type === "frame" && e.name === opts.frameId
|
|
398
|
+
);
|
|
399
|
+
if (frame) {
|
|
400
|
+
elements = filterByFrame(elements, frame.id);
|
|
401
|
+
} else {
|
|
402
|
+
warnings.push({
|
|
403
|
+
code: "ERR_VALIDATION_FRAME_NOT_FOUND",
|
|
404
|
+
message: `Frame not found: "${opts.frameId}" does not match any frame ID or name`,
|
|
405
|
+
retryable: false,
|
|
406
|
+
suggested_action: "fix_input"
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else if (opts.frameName) {
|
|
411
|
+
const frame = elements.find(
|
|
412
|
+
(e) => e.type === "frame" && e.name === opts.frameName
|
|
413
|
+
);
|
|
414
|
+
if (frame) {
|
|
415
|
+
elements = filterByFrame(elements, frame.id);
|
|
416
|
+
} else {
|
|
417
|
+
warnings.push({
|
|
418
|
+
code: "ERR_VALIDATION_FRAME_NOT_FOUND",
|
|
419
|
+
message: `Frame not found: no frame with name "${opts.frameName}"`,
|
|
420
|
+
retryable: false,
|
|
421
|
+
suggested_action: "fix_input"
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (opts.elementIds && opts.elementIds.length > 0) {
|
|
426
|
+
const idSet = new Set(opts.elementIds);
|
|
427
|
+
const before = elements.length;
|
|
428
|
+
elements = elements.filter((e) => idSet.has(e.id));
|
|
429
|
+
const missing = [...idSet].filter((id) => !elements.some((e) => e.id === id));
|
|
430
|
+
if (missing.length > 0) {
|
|
431
|
+
warnings.push({
|
|
432
|
+
code: "ERR_VALIDATION_ELEMENT_NOT_FOUND",
|
|
433
|
+
message: `Element IDs not found: ${missing.join(", ")}`,
|
|
434
|
+
retryable: false,
|
|
435
|
+
suggested_action: "fix_input",
|
|
436
|
+
details: { missing_ids: missing }
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return { scene: { ...scene, elements }, warnings };
|
|
441
|
+
}
|
|
442
|
+
function filterByFrame(elements, frameId) {
|
|
443
|
+
return elements.filter((e) => e.id === frameId || e.frameId === frameId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/cli/commands/inspect.ts
|
|
447
|
+
function registerInspect(program2) {
|
|
448
|
+
program2.command("inspect <file>").description("Inspect an Excalidraw scene and return metadata").option("--include-deleted", "Include deleted elements in inspection").option("--frame <id-or-name>", "Filter to a specific frame").action(
|
|
449
|
+
wrapCommand("scene.inspect", async (ctx, file, opts) => {
|
|
450
|
+
const options = opts;
|
|
451
|
+
const fileStr = file;
|
|
452
|
+
const loaded = await loadScene(fileStr);
|
|
453
|
+
const { scene: filtered, warnings: filterWarnings } = applyFilters(loaded.parsed, {
|
|
454
|
+
includeDeleted: options.includeDeleted,
|
|
455
|
+
frameId: options.frame
|
|
456
|
+
});
|
|
457
|
+
for (const w of filterWarnings) ctx.warn(w);
|
|
458
|
+
const inspection = inspectScene(filtered);
|
|
459
|
+
return {
|
|
460
|
+
target: { file: loaded.source, fingerprint: loaded.fingerprint },
|
|
461
|
+
result: inspection
|
|
462
|
+
};
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/scene/validate-scene.ts
|
|
468
|
+
function validateScene(scene, opts = {}) {
|
|
469
|
+
const checks = [];
|
|
470
|
+
const warnings = [];
|
|
471
|
+
const elementMap = new Map(scene.elements.map((e) => [e.id, e]));
|
|
472
|
+
const liveElements = scene.elements.filter((e) => !e.isDeleted);
|
|
473
|
+
{
|
|
474
|
+
const orphans = [];
|
|
475
|
+
for (const el of liveElements) {
|
|
476
|
+
if (el.frameId && !elementMap.has(el.frameId)) {
|
|
477
|
+
orphans.push(el.id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
checks.push({
|
|
481
|
+
name: "frame_references",
|
|
482
|
+
passed: orphans.length === 0,
|
|
483
|
+
message: orphans.length > 0 ? `${orphans.length} element(s) reference non-existent frames` : void 0
|
|
484
|
+
});
|
|
485
|
+
if (orphans.length > 0) {
|
|
486
|
+
warnings.push({
|
|
487
|
+
code: "ERR_VALIDATION_FRAME_ORPHAN",
|
|
488
|
+
message: `Elements reference non-existent frames: ${orphans.join(", ")}`,
|
|
489
|
+
retryable: false,
|
|
490
|
+
suggested_action: "fix_input",
|
|
491
|
+
details: { element_ids: orphans }
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
{
|
|
496
|
+
const broken = [];
|
|
497
|
+
for (const el of liveElements) {
|
|
498
|
+
if (el.type === "text" && el.containerId) {
|
|
499
|
+
const container = elementMap.get(el.containerId);
|
|
500
|
+
if (!container) {
|
|
501
|
+
broken.push(el.id);
|
|
502
|
+
} else if (container.boundElements) {
|
|
503
|
+
const hasRef = container.boundElements.some(
|
|
504
|
+
(b) => b.id === el.id && b.type === "text"
|
|
505
|
+
);
|
|
506
|
+
if (!hasRef) broken.push(el.id);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
checks.push({
|
|
511
|
+
name: "bound_text",
|
|
512
|
+
passed: broken.length === 0,
|
|
513
|
+
message: broken.length > 0 ? `${broken.length} bound text element(s) have broken references` : void 0
|
|
514
|
+
});
|
|
515
|
+
if (broken.length > 0) {
|
|
516
|
+
warnings.push({
|
|
517
|
+
code: "ERR_VALIDATION_BOUND_TEXT",
|
|
518
|
+
message: `Bound text elements with broken references: ${broken.join(", ")}`,
|
|
519
|
+
retryable: false,
|
|
520
|
+
suggested_action: "fix_input",
|
|
521
|
+
details: { element_ids: broken }
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
{
|
|
526
|
+
const broken = [];
|
|
527
|
+
for (const el of liveElements) {
|
|
528
|
+
if (el.type === "arrow") {
|
|
529
|
+
if (el.startBinding && !elementMap.has(el.startBinding.elementId)) {
|
|
530
|
+
broken.push(`${el.id}:start`);
|
|
531
|
+
}
|
|
532
|
+
if (el.endBinding && !elementMap.has(el.endBinding.elementId)) {
|
|
533
|
+
broken.push(`${el.id}:end`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
checks.push({
|
|
538
|
+
name: "arrow_bindings",
|
|
539
|
+
passed: broken.length === 0,
|
|
540
|
+
message: broken.length > 0 ? `${broken.length} arrow binding(s) reference non-existent elements` : void 0
|
|
541
|
+
});
|
|
542
|
+
if (broken.length > 0) {
|
|
543
|
+
warnings.push({
|
|
544
|
+
code: "ERR_VALIDATION_ARROW_BINDING",
|
|
545
|
+
message: `Broken arrow bindings: ${broken.join(", ")}`,
|
|
546
|
+
retryable: false,
|
|
547
|
+
suggested_action: "fix_input",
|
|
548
|
+
details: { bindings: broken }
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (opts.checkAssets) {
|
|
553
|
+
const missing = [];
|
|
554
|
+
for (const el of liveElements) {
|
|
555
|
+
if (el.type === "image" && el.fileId) {
|
|
556
|
+
if (!scene.files || !(el.fileId in scene.files)) {
|
|
557
|
+
missing.push(el.id);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
checks.push({
|
|
562
|
+
name: "image_assets",
|
|
563
|
+
passed: missing.length === 0,
|
|
564
|
+
message: missing.length > 0 ? `${missing.length} image(s) reference missing binary files` : void 0
|
|
565
|
+
});
|
|
566
|
+
if (missing.length > 0) {
|
|
567
|
+
warnings.push({
|
|
568
|
+
code: "ERR_VALIDATION_MISSING_ASSET",
|
|
569
|
+
message: `Images with missing file data: ${missing.join(", ")}`,
|
|
570
|
+
retryable: false,
|
|
571
|
+
suggested_action: "fix_input",
|
|
572
|
+
details: { element_ids: missing }
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
{
|
|
577
|
+
const knownTypes = /* @__PURE__ */ new Set([
|
|
578
|
+
"rectangle",
|
|
579
|
+
"diamond",
|
|
580
|
+
"ellipse",
|
|
581
|
+
"arrow",
|
|
582
|
+
"line",
|
|
583
|
+
"freedraw",
|
|
584
|
+
"text",
|
|
585
|
+
"image",
|
|
586
|
+
"frame",
|
|
587
|
+
"group",
|
|
588
|
+
"embeddable",
|
|
589
|
+
"iframe",
|
|
590
|
+
"magicframe"
|
|
591
|
+
]);
|
|
592
|
+
const unknownTypes = /* @__PURE__ */ new Set();
|
|
593
|
+
for (const el of liveElements) {
|
|
594
|
+
if (!knownTypes.has(el.type)) unknownTypes.add(el.type);
|
|
595
|
+
}
|
|
596
|
+
if (unknownTypes.size > 0) {
|
|
597
|
+
warnings.push({
|
|
598
|
+
code: "ERR_VALIDATION_UNKNOWN_TYPE",
|
|
599
|
+
message: `Unknown element types: ${[...unknownTypes].join(", ")}`,
|
|
600
|
+
retryable: false,
|
|
601
|
+
suggested_action: "fix_input",
|
|
602
|
+
details: { types: [...unknownTypes] }
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const valid = checks.every((c) => c.passed);
|
|
607
|
+
return { result: { valid, checks }, warnings };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/cli/commands/validate.ts
|
|
611
|
+
function registerValidate(program2) {
|
|
612
|
+
program2.command("validate <file>").description("Validate an Excalidraw scene for structural consistency").option("--check-assets", "Verify image file references exist").action(
|
|
613
|
+
wrapCommand("scene.validate", async (ctx, file, opts) => {
|
|
614
|
+
const options = opts;
|
|
615
|
+
const fileStr = file;
|
|
616
|
+
const loaded = await loadScene(fileStr);
|
|
617
|
+
const { result, warnings } = validateScene(loaded.parsed, {
|
|
618
|
+
checkAssets: options.checkAssets
|
|
619
|
+
});
|
|
620
|
+
for (const w of warnings) ctx.warn(w);
|
|
621
|
+
return {
|
|
622
|
+
target: { file: loaded.source, fingerprint: loaded.fingerprint },
|
|
623
|
+
result
|
|
624
|
+
};
|
|
625
|
+
})
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/cli/commands/render.ts
|
|
630
|
+
import { basename, extname } from "path";
|
|
631
|
+
|
|
632
|
+
// src/scene/normalize-scene.ts
|
|
633
|
+
function deterministicSeed(id) {
|
|
634
|
+
let hash = 0;
|
|
635
|
+
for (let i = 0; i < id.length; i++) {
|
|
636
|
+
hash = (hash << 5) - hash + id.charCodeAt(i) | 0;
|
|
637
|
+
}
|
|
638
|
+
return Math.abs(hash);
|
|
639
|
+
}
|
|
640
|
+
function normalizeScene(scene) {
|
|
641
|
+
const elements = scene.elements.map(normalizeElement);
|
|
642
|
+
elements.sort((a, b) => {
|
|
643
|
+
if (a.type !== b.type) return a.type.localeCompare(b.type);
|
|
644
|
+
if (a.y !== b.y) return a.y - b.y;
|
|
645
|
+
if (a.x !== b.x) return a.x - b.x;
|
|
646
|
+
return a.id.localeCompare(b.id);
|
|
647
|
+
});
|
|
648
|
+
return { ...scene, elements };
|
|
649
|
+
}
|
|
650
|
+
function normalizeElement(el) {
|
|
651
|
+
const raw = el;
|
|
652
|
+
const result = {
|
|
653
|
+
...el,
|
|
654
|
+
isDeleted: el.isDeleted ?? false,
|
|
655
|
+
opacity: el.opacity ?? 100,
|
|
656
|
+
groupIds: el.groupIds ?? [],
|
|
657
|
+
frameId: el.frameId ?? null,
|
|
658
|
+
boundElements: el.boundElements ?? null,
|
|
659
|
+
containerId: el.containerId ?? null,
|
|
660
|
+
startBinding: el.startBinding ?? null,
|
|
661
|
+
endBinding: el.endBinding ?? null,
|
|
662
|
+
fileId: el.fileId ?? null,
|
|
663
|
+
name: el.name ?? null,
|
|
664
|
+
// Properties required by @excalidraw/utils for rendering
|
|
665
|
+
angle: raw.angle ?? 0,
|
|
666
|
+
strokeColor: raw.strokeColor ?? "#1e1e1e",
|
|
667
|
+
backgroundColor: raw.backgroundColor ?? "transparent",
|
|
668
|
+
fillStyle: raw.fillStyle ?? "solid",
|
|
669
|
+
strokeWidth: raw.strokeWidth ?? 2,
|
|
670
|
+
strokeStyle: raw.strokeStyle ?? "solid",
|
|
671
|
+
roughness: raw.roughness ?? 1,
|
|
672
|
+
roundness: raw.roundness ?? null,
|
|
673
|
+
seed: raw.seed ?? deterministicSeed(el.id),
|
|
674
|
+
version: raw.version ?? 1,
|
|
675
|
+
versionNonce: raw.versionNonce ?? 1,
|
|
676
|
+
updated: raw.updated ?? Date.now(),
|
|
677
|
+
link: raw.link ?? null,
|
|
678
|
+
locked: raw.locked ?? false
|
|
679
|
+
};
|
|
680
|
+
if ((el.type === "arrow" || el.type === "line" || el.type === "freedraw") && !raw.points) {
|
|
681
|
+
result.points = [[0, 0], [el.width, el.height]];
|
|
682
|
+
}
|
|
683
|
+
if (el.type === "text") {
|
|
684
|
+
result.fontSize = raw.fontSize ?? 20;
|
|
685
|
+
result.fontFamily = raw.fontFamily ?? 1;
|
|
686
|
+
result.textAlign = raw.textAlign ?? "left";
|
|
687
|
+
result.verticalAlign = raw.verticalAlign ?? "top";
|
|
688
|
+
result.lineHeight = raw.lineHeight ?? 1.25;
|
|
689
|
+
result.originalText = raw.originalText ?? raw.text ?? "";
|
|
690
|
+
result.baseline = raw.baseline ?? 0;
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/render/bridge-page.ts
|
|
696
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
697
|
+
import { resolve, dirname as dirname2 } from "path";
|
|
698
|
+
import { fileURLToPath } from "url";
|
|
699
|
+
async function generateBridgeHtml() {
|
|
700
|
+
const thisDir = dirname2(fileURLToPath(import.meta.url));
|
|
701
|
+
const bridgePath = resolve(thisDir, "..", "render", "bridge.global.js");
|
|
702
|
+
let bridgeScript;
|
|
703
|
+
try {
|
|
704
|
+
bridgeScript = await readFile2(bridgePath, "utf-8");
|
|
705
|
+
} catch {
|
|
706
|
+
const fallbackPath = resolve(process.cwd(), "dist", "render", "bridge.global.js");
|
|
707
|
+
bridgeScript = await readFile2(fallbackPath, "utf-8");
|
|
708
|
+
}
|
|
709
|
+
return `<!DOCTYPE html>
|
|
710
|
+
<html>
|
|
711
|
+
<head><meta charset="utf-8"></head>
|
|
712
|
+
<body>
|
|
713
|
+
<script>${bridgeScript}</script>
|
|
714
|
+
<script>
|
|
715
|
+
window.__bridgeReady = typeof window.__excalidrawExport !== 'undefined' && window.__excalidrawExport.ready;
|
|
716
|
+
</script>
|
|
717
|
+
</body>
|
|
718
|
+
</html>`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// src/render/render-bridge.ts
|
|
722
|
+
var RenderBridge = class {
|
|
723
|
+
browser = null;
|
|
724
|
+
page = null;
|
|
725
|
+
bridgeHtml = null;
|
|
726
|
+
async initialize() {
|
|
727
|
+
let pw;
|
|
728
|
+
try {
|
|
729
|
+
pw = await import("playwright");
|
|
730
|
+
} catch {
|
|
731
|
+
throw renderError(
|
|
732
|
+
"BROWSER_UNAVAILABLE",
|
|
733
|
+
"Playwright is not installed. Install it with: npm install playwright"
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
this.browser = await pw.chromium.launch({ headless: true });
|
|
737
|
+
const context = await this.browser.newContext();
|
|
738
|
+
this.page = await context.newPage();
|
|
739
|
+
try {
|
|
740
|
+
await import("@excalidraw/excalidraw");
|
|
741
|
+
const { resolve: resolve2, dirname: dirname3 } = await import("path");
|
|
742
|
+
const { readFile: readFile3 } = await import("fs/promises");
|
|
743
|
+
const { createRequire } = await import("module");
|
|
744
|
+
const require2 = createRequire(import.meta.url);
|
|
745
|
+
const excalidrawDir = dirname3(require2.resolve("@excalidraw/excalidraw/package.json"));
|
|
746
|
+
await this.page.route("**/*.woff2", async (route) => {
|
|
747
|
+
try {
|
|
748
|
+
const url = new URL(route.request().url());
|
|
749
|
+
const fontName = url.pathname.split("/").pop();
|
|
750
|
+
if (fontName) {
|
|
751
|
+
const fontPath = resolve2(excalidrawDir, "dist", "excalidraw-assets", fontName);
|
|
752
|
+
const body = await readFile3(fontPath);
|
|
753
|
+
await route.fulfill({ body, contentType: "font/woff2" });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
await route.abort();
|
|
759
|
+
});
|
|
760
|
+
} catch {
|
|
761
|
+
}
|
|
762
|
+
this.bridgeHtml = await generateBridgeHtml();
|
|
763
|
+
await this.page.setContent(this.bridgeHtml);
|
|
764
|
+
await this.page.waitForFunction("window.__bridgeReady === true", {
|
|
765
|
+
timeout: 1e4
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
async exportSvg(sceneData) {
|
|
769
|
+
if (!this.page) throw renderError("EXPORT_FAILED", "Bridge not initialized");
|
|
770
|
+
try {
|
|
771
|
+
const svgString = await this.page.evaluate(
|
|
772
|
+
async (data) => {
|
|
773
|
+
return window.__excalidrawExport.exportToSvg(data);
|
|
774
|
+
},
|
|
775
|
+
sceneData
|
|
776
|
+
);
|
|
777
|
+
return svgString;
|
|
778
|
+
} catch (err) {
|
|
779
|
+
throw renderError("EXPORT_FAILED", `SVG export failed: ${errorMessage(err)}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async exportPng(sceneData, scale = 2) {
|
|
783
|
+
if (!this.page) throw renderError("EXPORT_FAILED", "Bridge not initialized");
|
|
784
|
+
try {
|
|
785
|
+
const base64 = await this.page.evaluate(
|
|
786
|
+
async (data) => {
|
|
787
|
+
const appState = { ...data.scene.appState, exportScale: data.scale };
|
|
788
|
+
return window.__excalidrawExport.exportToBlob({
|
|
789
|
+
...data.scene,
|
|
790
|
+
appState
|
|
791
|
+
});
|
|
792
|
+
},
|
|
793
|
+
{ scene: sceneData, scale }
|
|
794
|
+
);
|
|
795
|
+
return Buffer.from(base64, "base64");
|
|
796
|
+
} catch (err) {
|
|
797
|
+
throw renderError("EXPORT_FAILED", `PNG export failed: ${errorMessage(err)}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async exportPdf(sceneData) {
|
|
801
|
+
if (!this.page) throw renderError("EXPORT_FAILED", "Bridge not initialized");
|
|
802
|
+
try {
|
|
803
|
+
const svgString = await this.exportSvg(sceneData);
|
|
804
|
+
await this.page.setContent(`<!DOCTYPE html>
|
|
805
|
+
<html>
|
|
806
|
+
<head>
|
|
807
|
+
<meta charset="utf-8">
|
|
808
|
+
<style>
|
|
809
|
+
body { margin: 0; display: flex; justify-content: center; align-items: center; }
|
|
810
|
+
svg { max-width: 100%; height: auto; }
|
|
811
|
+
</style>
|
|
812
|
+
</head>
|
|
813
|
+
<body>${svgString}</body>
|
|
814
|
+
</html>`);
|
|
815
|
+
const pdf = await this.page.pdf({
|
|
816
|
+
preferCSSPageSize: true,
|
|
817
|
+
printBackground: true
|
|
818
|
+
});
|
|
819
|
+
if (this.bridgeHtml) {
|
|
820
|
+
await this.page.setContent(this.bridgeHtml);
|
|
821
|
+
await this.page.waitForFunction("window.__bridgeReady === true", {
|
|
822
|
+
timeout: 1e4
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
return Buffer.from(pdf);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
if (err instanceof Error && err.message.includes("ERR_RENDER")) throw err;
|
|
828
|
+
throw renderError("EXPORT_FAILED", `PDF export failed: ${errorMessage(err)}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async dispose() {
|
|
832
|
+
if (this.browser) {
|
|
833
|
+
await this.browser.close();
|
|
834
|
+
this.browser = null;
|
|
835
|
+
this.page = null;
|
|
836
|
+
this.bridgeHtml = null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// src/render/export-common.ts
|
|
842
|
+
import { join as join2 } from "path";
|
|
843
|
+
async function exportFormat(opts) {
|
|
844
|
+
const sceneData = {
|
|
845
|
+
elements: opts.scene.elements,
|
|
846
|
+
appState: opts.scene.appState,
|
|
847
|
+
files: opts.scene.files,
|
|
848
|
+
exportPadding: opts.exportPadding
|
|
849
|
+
};
|
|
850
|
+
const { data, bytes } = await opts.render(opts.bridge, sceneData);
|
|
851
|
+
const path = join2(opts.outDir, `${opts.baseName}.${opts.ext}`).replace(/\\/g, "/");
|
|
852
|
+
if (!opts.dryRun) {
|
|
853
|
+
await writeOutput(path, data);
|
|
854
|
+
}
|
|
855
|
+
return { type: opts.ext, path, bytes };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/render/export-svg.ts
|
|
859
|
+
async function exportSvg(opts) {
|
|
860
|
+
return exportFormat({
|
|
861
|
+
...opts,
|
|
862
|
+
ext: "svg",
|
|
863
|
+
render: async (bridge, sceneData) => {
|
|
864
|
+
const svg = await bridge.exportSvg(sceneData);
|
|
865
|
+
return { data: svg, bytes: Buffer.byteLength(svg, "utf-8") };
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/render/export-png.ts
|
|
871
|
+
async function exportPng(opts) {
|
|
872
|
+
return exportFormat({
|
|
873
|
+
...opts,
|
|
874
|
+
ext: "png",
|
|
875
|
+
render: async (bridge, sceneData) => {
|
|
876
|
+
const buf = await bridge.exportPng(sceneData, opts.scale);
|
|
877
|
+
return { data: buf, bytes: buf.length };
|
|
878
|
+
}
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/render/export-pdf.ts
|
|
883
|
+
async function exportPdf(opts) {
|
|
884
|
+
return exportFormat({
|
|
885
|
+
...opts,
|
|
886
|
+
ext: "pdf",
|
|
887
|
+
render: async (bridge, sceneData) => {
|
|
888
|
+
const buf = await bridge.exportPdf(sceneData);
|
|
889
|
+
return { data: buf, bytes: buf.length };
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/cli/commands/render.ts
|
|
895
|
+
function registerRender(program2) {
|
|
896
|
+
program2.command("render <file>").description("Render an Excalidraw scene to SVG, PNG, or PDF").option("--outDir <dir>", "Output directory", ".").option("--svg", "Export SVG").option("--png", "Export PNG (requires Playwright)").option("--pdf", "Export PDF (requires Playwright)").option("--dark-mode", "Use dark theme").option("--no-background", "Transparent background").option("--scale <n>", "Scale factor for PNG output only", "2").option("--padding <n>", "Padding in pixels", "20").option("--frame <id-or-name>", "Export specific frame only").option("--element <id>", "Export specific element only").option("--dry-run", "Run pipeline but write no files").action(
|
|
897
|
+
wrapCommand("scene.render", async (ctx, file, opts) => {
|
|
898
|
+
const options = opts;
|
|
899
|
+
const fileStr = file;
|
|
900
|
+
const loaded = await loadScene(fileStr);
|
|
901
|
+
const normalized = normalizeScene(loaded.parsed);
|
|
902
|
+
const { scene: filtered, warnings: filterWarnings } = applyFilters(normalized, {
|
|
903
|
+
frameId: options.frame,
|
|
904
|
+
frameName: options.frame,
|
|
905
|
+
elementIds: options.element ? [options.element] : void 0
|
|
906
|
+
});
|
|
907
|
+
for (const w of filterWarnings) ctx.warn(w);
|
|
908
|
+
const inspection = inspectScene(filtered);
|
|
909
|
+
const formats = {
|
|
910
|
+
svg: options.svg || !options.png && !options.pdf,
|
|
911
|
+
png: options.png || false,
|
|
912
|
+
pdf: options.pdf || false
|
|
913
|
+
};
|
|
914
|
+
const scale = parseFloat(options.scale);
|
|
915
|
+
if (scale !== 2 && formats.svg && !formats.png && !formats.pdf) {
|
|
916
|
+
ctx.warn({
|
|
917
|
+
code: "ERR_VALIDATION_SCALE_IGNORED",
|
|
918
|
+
message: "--scale has no effect on SVG output; it only applies to PNG",
|
|
919
|
+
retryable: false,
|
|
920
|
+
suggested_action: "fix_input"
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const appState = { ...filtered.appState };
|
|
924
|
+
if (options.darkMode) appState.exportWithDarkMode = true;
|
|
925
|
+
if (!options.background) appState.exportBackground = false;
|
|
926
|
+
const sceneForExport = { ...filtered, appState };
|
|
927
|
+
const baseName = loaded.source === "stdin" ? "scene" : basename(loaded.source, extname(loaded.source));
|
|
928
|
+
const shared = {
|
|
929
|
+
bridge: void 0,
|
|
930
|
+
scene: sceneForExport,
|
|
931
|
+
outDir: options.outDir,
|
|
932
|
+
baseName,
|
|
933
|
+
dryRun: options.dryRun,
|
|
934
|
+
exportPadding: parseInt(options.padding, 10)
|
|
935
|
+
};
|
|
936
|
+
const bridge = new RenderBridge();
|
|
937
|
+
shared.bridge = bridge;
|
|
938
|
+
try {
|
|
939
|
+
await bridge.initialize();
|
|
940
|
+
const artefacts = [];
|
|
941
|
+
if (formats.svg) {
|
|
942
|
+
artefacts.push(await exportSvg(shared));
|
|
943
|
+
}
|
|
944
|
+
if (formats.png) {
|
|
945
|
+
artefacts.push(
|
|
946
|
+
await exportPng({ ...shared, scale })
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (formats.pdf) {
|
|
950
|
+
artefacts.push(await exportPdf(shared));
|
|
951
|
+
}
|
|
952
|
+
return {
|
|
953
|
+
target: { file: loaded.source, fingerprint: loaded.fingerprint },
|
|
954
|
+
result: {
|
|
955
|
+
artefacts,
|
|
956
|
+
scene_summary: {
|
|
957
|
+
element_count: inspection.element_count,
|
|
958
|
+
bounding_box: inspection.bounding_box,
|
|
959
|
+
fingerprint: loaded.fingerprint
|
|
960
|
+
},
|
|
961
|
+
dry_run: options.dryRun || false
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
} finally {
|
|
965
|
+
await bridge.dispose();
|
|
966
|
+
}
|
|
967
|
+
})
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/guide/guide-schema.ts
|
|
972
|
+
function getGuideContent() {
|
|
973
|
+
return `# excal \u2014 CLI Guide
|
|
974
|
+
|
|
975
|
+
Agent-first CLI for Excalidraw scene inspection, validation, and rendering.
|
|
976
|
+
|
|
977
|
+
## Commands
|
|
978
|
+
|
|
979
|
+
### excal inspect <file|->
|
|
980
|
+
|
|
981
|
+
Inspect an Excalidraw scene and return element counts, bounds, and metadata.
|
|
982
|
+
|
|
983
|
+
| Flag | Description |
|
|
984
|
+
|------|-------------|
|
|
985
|
+
| \`--include-deleted\` | Include deleted elements |
|
|
986
|
+
| \`--frame <id\\|name>\` | Filter to a specific frame |
|
|
987
|
+
|
|
988
|
+
\`\`\`bash
|
|
989
|
+
excal inspect diagram.excalidraw
|
|
990
|
+
cat scene.json | excal inspect -
|
|
991
|
+
\`\`\`
|
|
992
|
+
|
|
993
|
+
### excal validate <file|->
|
|
994
|
+
|
|
995
|
+
Validate scene structure: frame refs, bound text, arrow bindings, assets.
|
|
996
|
+
|
|
997
|
+
| Flag | Description |
|
|
998
|
+
|------|-------------|
|
|
999
|
+
| \`--check-assets\` | Verify image file references exist in scene |
|
|
1000
|
+
|
|
1001
|
+
\`\`\`bash
|
|
1002
|
+
excal validate diagram.excalidraw
|
|
1003
|
+
excal validate diagram.excalidraw --check-assets
|
|
1004
|
+
\`\`\`
|
|
1005
|
+
|
|
1006
|
+
### excal render <file|->
|
|
1007
|
+
|
|
1008
|
+
Render scene to SVG, PNG, or PDF. PNG/PDF require Playwright.
|
|
1009
|
+
|
|
1010
|
+
| Flag | Description |
|
|
1011
|
+
|------|-------------|
|
|
1012
|
+
| \`--outDir <dir>\` | Output directory (default: .) |
|
|
1013
|
+
| \`--svg\` | Export SVG (default if no format specified) |
|
|
1014
|
+
| \`--png\` | Export PNG (requires Playwright) |
|
|
1015
|
+
| \`--pdf\` | Export PDF (requires Playwright) |
|
|
1016
|
+
| \`--dark-mode\` | Use dark theme |
|
|
1017
|
+
| \`--no-background\` | Transparent background |
|
|
1018
|
+
| \`--scale <n>\` | Scale factor for PNG output only (default: 2) |
|
|
1019
|
+
| \`--padding <n>\` | Padding in pixels (default: 20) |
|
|
1020
|
+
| \`--frame <id\\|name>\` | Export specific frame only |
|
|
1021
|
+
| \`--element <id>\` | Export specific element only |
|
|
1022
|
+
| \`--dry-run\` | Run pipeline but write no files |
|
|
1023
|
+
|
|
1024
|
+
\`\`\`bash
|
|
1025
|
+
excal render diagram.excalidraw --outDir ./out
|
|
1026
|
+
excal render diagram.excalidraw --outDir ./out --png --pdf
|
|
1027
|
+
excal render - --outDir ./out < scene.json
|
|
1028
|
+
\`\`\`
|
|
1029
|
+
|
|
1030
|
+
### excal guide
|
|
1031
|
+
|
|
1032
|
+
Output this CLI guide as Markdown.
|
|
1033
|
+
|
|
1034
|
+
### excal skill
|
|
1035
|
+
|
|
1036
|
+
Return Excalidraw domain knowledge for AI agents.
|
|
1037
|
+
|
|
1038
|
+
## Error Codes
|
|
1039
|
+
|
|
1040
|
+
| Code | Exit | Description |
|
|
1041
|
+
|------|------|-------------|
|
|
1042
|
+
| \`ERR_VALIDATION_INVALID_JSON\` | 10 | Input is not valid JSON |
|
|
1043
|
+
| \`ERR_VALIDATION_INVALID_SCENE\` | 10 | Scene structure validation failed |
|
|
1044
|
+
| \`ERR_VALIDATION_UNKNOWN_FORMAT\` | 10 | Input is not a recognized format |
|
|
1045
|
+
| \`ERR_RENDER_BROWSER_UNAVAILABLE\` | 20 | Playwright not installed |
|
|
1046
|
+
| \`ERR_RENDER_EXPORT_FAILED\` | 20 | Export failed in browser bridge |
|
|
1047
|
+
| \`ERR_IO_READ_FAILED\` | 50 | Failed to read input file |
|
|
1048
|
+
| \`ERR_IO_WRITE_FAILED\` | 50 | Failed to write output file |
|
|
1049
|
+
| \`ERR_INTERNAL_UNEXPECTED\` | 90 | Unexpected internal error |
|
|
1050
|
+
|
|
1051
|
+
## Response Envelope
|
|
1052
|
+
|
|
1053
|
+
Every command returns a JSON envelope on stdout:
|
|
1054
|
+
|
|
1055
|
+
\`\`\`jsonc
|
|
1056
|
+
{
|
|
1057
|
+
"schema_version": "1.0",
|
|
1058
|
+
"request_id": "req_20260302_143000_7f3a",
|
|
1059
|
+
"ok": true, // always present
|
|
1060
|
+
"command": "scene.inspect",
|
|
1061
|
+
"target": { ... }, // what was acted on (null for global commands)
|
|
1062
|
+
"result": { ... }, // command payload (null on failure)
|
|
1063
|
+
"warnings": [], // always an array
|
|
1064
|
+
"errors": [], // always an array
|
|
1065
|
+
"metrics": { "duration_ms": 42 }
|
|
1066
|
+
}
|
|
1067
|
+
\`\`\`
|
|
1068
|
+
|
|
1069
|
+
- \`errors\` and \`warnings\` are always arrays (possibly empty), never omitted.
|
|
1070
|
+
- \`result\` is always present; on failure it is \`null\`.
|
|
1071
|
+
- Each error carries \`code\`, \`message\`, \`retryable\`, and \`suggested_action\`.
|
|
1072
|
+
- **Note on \`validate\`**: \`ok: true\` means the command executed successfully, not that the scene is valid. Check \`result.valid\` (boolean) for scene validity, and \`result.issues\` / \`warnings\` for details.
|
|
1073
|
+
|
|
1074
|
+
## Concurrency
|
|
1075
|
+
|
|
1076
|
+
- **Reads** (inspect, validate): safe to run concurrently.
|
|
1077
|
+
- **Renders**: each invocation launches its own browser; safe to parallelize.
|
|
1078
|
+
`;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// src/cli/commands/guide.ts
|
|
1082
|
+
function registerGuide(program2) {
|
|
1083
|
+
program2.command("guide").description("Return CLI guide as Markdown for agent bootstrapping").action(
|
|
1084
|
+
wrapCommand("cli.guide", async () => {
|
|
1085
|
+
return { result: { content: getGuideContent(), format: "markdown" } };
|
|
1086
|
+
})
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// src/guide/skill-content.ts
|
|
1091
|
+
function getSkillContent() {
|
|
1092
|
+
return `# Excalidraw Scene Structure \u2014 Agent Guide
|
|
1093
|
+
|
|
1094
|
+
## File Format
|
|
1095
|
+
|
|
1096
|
+
Excalidraw scenes are JSON files (typically \`.excalidraw\`) with this structure:
|
|
1097
|
+
|
|
1098
|
+
\`\`\`json
|
|
1099
|
+
{
|
|
1100
|
+
"type": "excalidraw",
|
|
1101
|
+
"version": 2,
|
|
1102
|
+
"source": "https://excalidraw.com",
|
|
1103
|
+
"elements": [ ... ],
|
|
1104
|
+
"appState": { ... },
|
|
1105
|
+
"files": { ... }
|
|
1106
|
+
}
|
|
1107
|
+
\`\`\`
|
|
1108
|
+
|
|
1109
|
+
## Element Types
|
|
1110
|
+
|
|
1111
|
+
| Type | Description |
|
|
1112
|
+
|------|-------------|
|
|
1113
|
+
| rectangle | Box shape |
|
|
1114
|
+
| diamond | Diamond/rhombus shape |
|
|
1115
|
+
| ellipse | Circle/ellipse shape |
|
|
1116
|
+
| arrow | Arrow connector |
|
|
1117
|
+
| line | Line/polyline |
|
|
1118
|
+
| freedraw | Freehand drawing |
|
|
1119
|
+
| text | Text label |
|
|
1120
|
+
| image | Embedded image |
|
|
1121
|
+
| frame | Grouping frame |
|
|
1122
|
+
|
|
1123
|
+
## Key Element Properties
|
|
1124
|
+
|
|
1125
|
+
Every element has: \`id\`, \`type\`, \`x\`, \`y\`, \`width\`, \`height\`, \`isDeleted\`, \`opacity\`, \`groupIds\`, \`frameId\`.
|
|
1126
|
+
|
|
1127
|
+
## Frames
|
|
1128
|
+
|
|
1129
|
+
Frames group elements visually. Elements inside a frame have \`frameId\` set to the frame's \`id\`. Frames themselves have \`type: "frame"\` and an optional \`name\`.
|
|
1130
|
+
|
|
1131
|
+
To export a single frame: use \`--frame <id|name>\`.
|
|
1132
|
+
|
|
1133
|
+
## Bound Text
|
|
1134
|
+
|
|
1135
|
+
Text can be bound to a container (rectangle, diamond, ellipse). The text element has \`containerId\` pointing to the container, and the container has a \`boundElements\` entry with \`{ id, type: "text" }\`.
|
|
1136
|
+
|
|
1137
|
+
## Arrows & Bindings
|
|
1138
|
+
|
|
1139
|
+
Arrows connect elements via \`startBinding\` and \`endBinding\`:
|
|
1140
|
+
\`\`\`json
|
|
1141
|
+
{
|
|
1142
|
+
"startBinding": { "elementId": "target-id", "focus": 0, "gap": 1 },
|
|
1143
|
+
"endBinding": { "elementId": "target-id", "focus": 0, "gap": 1 }
|
|
1144
|
+
}
|
|
1145
|
+
\`\`\`
|
|
1146
|
+
|
|
1147
|
+
## Images & Binary Files
|
|
1148
|
+
|
|
1149
|
+
Image elements have \`fileId\` referencing an entry in \`files\`. The files object maps IDs to \`{ mimeType, dataURL }\` where dataURL is base64-encoded.
|
|
1150
|
+
|
|
1151
|
+
## Export Tips
|
|
1152
|
+
|
|
1153
|
+
- SVG export inlines fonts; no external dependencies
|
|
1154
|
+
- PNG/PDF require Playwright for headless browser rendering
|
|
1155
|
+
- Use \`--scale 2\` (default) for crisp PNG exports
|
|
1156
|
+
- Use \`--dark-mode\` for dark theme exports
|
|
1157
|
+
- Use \`--no-background\` for transparent backgrounds
|
|
1158
|
+
- \`--dry-run\` validates the full pipeline without writing files
|
|
1159
|
+
|
|
1160
|
+
## Common Patterns
|
|
1161
|
+
|
|
1162
|
+
1. **Inspect before modifying**: Always run \`excal inspect\` to understand scene structure
|
|
1163
|
+
2. **Validate after changes**: Run \`excal validate --check-assets\` to catch broken references
|
|
1164
|
+
3. **Frame-based export**: Use frames to organize sections, export individually with \`--frame\`
|
|
1165
|
+
4. **Deterministic output**: Same input + same options = same output (for CI/CD)
|
|
1166
|
+
`;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/cli/commands/skill.ts
|
|
1170
|
+
function registerSkill(program2) {
|
|
1171
|
+
program2.command("skill").description("Return Excalidraw domain knowledge for AI agents").action(
|
|
1172
|
+
wrapCommand("cli.skill", async () => {
|
|
1173
|
+
return { result: { content: getSkillContent(), format: "markdown" } };
|
|
1174
|
+
})
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// src/cli/index.ts
|
|
1179
|
+
var program = new Command();
|
|
1180
|
+
program.name("excal").description("Agent-first CLI for Excalidraw scene inspection, validation, and rendering").version("1.0.0");
|
|
1181
|
+
program.action(() => {
|
|
1182
|
+
const envelope = buildEnvelope({
|
|
1183
|
+
request_id: generateRequestId(),
|
|
1184
|
+
command: "",
|
|
1185
|
+
result: null,
|
|
1186
|
+
ok: false,
|
|
1187
|
+
errors: [
|
|
1188
|
+
{
|
|
1189
|
+
code: "ERR_VALIDATION_NO_COMMAND",
|
|
1190
|
+
message: "No command specified. Run `excal --help` for usage.",
|
|
1191
|
+
retryable: false,
|
|
1192
|
+
suggested_action: "fix_input"
|
|
1193
|
+
}
|
|
1194
|
+
],
|
|
1195
|
+
duration_ms: 0
|
|
1196
|
+
});
|
|
1197
|
+
process.exitCode = 10;
|
|
1198
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
1199
|
+
});
|
|
1200
|
+
program.exitOverride();
|
|
1201
|
+
program.configureOutput({
|
|
1202
|
+
writeOut: () => {
|
|
1203
|
+
},
|
|
1204
|
+
writeErr: () => {
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
registerInspect(program);
|
|
1208
|
+
registerValidate(program);
|
|
1209
|
+
registerRender(program);
|
|
1210
|
+
registerGuide(program);
|
|
1211
|
+
registerSkill(program);
|
|
1212
|
+
try {
|
|
1213
|
+
program.parse();
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
if (err instanceof CommanderError) {
|
|
1216
|
+
if (err.code === "commander.helpDisplayed") {
|
|
1217
|
+
process.stdout.write(program.helpInformation());
|
|
1218
|
+
process.exit(0);
|
|
1219
|
+
}
|
|
1220
|
+
if (err.code === "commander.version") {
|
|
1221
|
+
process.stdout.write(program.version() + "\n");
|
|
1222
|
+
process.exit(0);
|
|
1223
|
+
}
|
|
1224
|
+
const envelope = buildEnvelope({
|
|
1225
|
+
request_id: generateRequestId(),
|
|
1226
|
+
command: "",
|
|
1227
|
+
result: null,
|
|
1228
|
+
ok: false,
|
|
1229
|
+
errors: [
|
|
1230
|
+
{
|
|
1231
|
+
code: "ERR_VALIDATION_INVALID_ARGS",
|
|
1232
|
+
message: err.message,
|
|
1233
|
+
retryable: false,
|
|
1234
|
+
suggested_action: "fix_input"
|
|
1235
|
+
}
|
|
1236
|
+
],
|
|
1237
|
+
duration_ms: 0
|
|
1238
|
+
});
|
|
1239
|
+
process.exitCode = 10;
|
|
1240
|
+
process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
1241
|
+
} else {
|
|
1242
|
+
throw err;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
//# sourceMappingURL=index.js.map
|