@zenuml/core 3.48.0 → 3.48.1
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/dist/cli/zenuml.mjs +13529 -0
- package/dist/cli/zenuml.mjs.map +1 -0
- package/dist/zenuml.esm.mjs +3 -3
- package/dist/zenuml.js +3 -3
- package/package.json +12 -9
- package/src/cli/zenuml.ts +0 -1164
package/src/cli/zenuml.ts
DELETED
|
@@ -1,1164 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* ZenUML CLI — renders ZenUML DSL text to SVG or PNG.
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* zenuml -i diagram.zenuml # writes diagram.svg
|
|
7
|
-
* zenuml -i diagram.zenuml -o out.svg # writes out.svg
|
|
8
|
-
* zenuml -i diagram.zenuml -o out.png # writes out.png (auto-detects format)
|
|
9
|
-
* zenuml -i diagram.zenuml -e png # writes diagram.png
|
|
10
|
-
* zenuml -i - -o - # stdin → stdout
|
|
11
|
-
* cat diagram.zenuml | zenuml -i - -o - # pipe mode
|
|
12
|
-
* zenuml --check -i diagram.zenuml # validate syntax (exit 0 = valid)
|
|
13
|
-
* zenuml --parse -i diagram.zenuml # output AST as JSON
|
|
14
|
-
*/
|
|
15
|
-
import { renderToSvg } from "@/svg/renderToSvg";
|
|
16
|
-
import type { RenderOptions } from "@/svg/renderToSvg";
|
|
17
|
-
import { setCanvasContext } from "@/positioning/WidthProviderFunc";
|
|
18
|
-
import Parser from "@/parser/index.js";
|
|
19
|
-
import { readFileSync, writeFileSync, mkdirSync, statSync, watch as fsWatch } from "node:fs";
|
|
20
|
-
import { resolve, basename, extname, dirname, join } from "node:path";
|
|
21
|
-
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Helpers
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
function readVersion(): string {
|
|
27
|
-
// Walk up from this file to find package.json at the project root.
|
|
28
|
-
// In development the file lives at src/cli/zenuml.ts, so ../../package.json.
|
|
29
|
-
// We use import.meta.dir which Bun resolves at runtime.
|
|
30
|
-
const pkgPath = resolve(import.meta.dir, "../../package.json");
|
|
31
|
-
try {
|
|
32
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
33
|
-
return pkg.version ?? "unknown";
|
|
34
|
-
} catch {
|
|
35
|
-
return "unknown";
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function printHelp(): void {
|
|
40
|
-
const help = `
|
|
41
|
-
Usage: zenuml [options]
|
|
42
|
-
|
|
43
|
-
Render ZenUML DSL text to SVG or PNG.
|
|
44
|
-
|
|
45
|
-
Rendering:
|
|
46
|
-
-i, --input <file> Input file (use "-" for stdin; repeatable)
|
|
47
|
-
-o, --output <file> Output file (use "-" for stdout; default: <input>.svg)
|
|
48
|
-
-e, --outputFormat <format> Output format: "svg" (default) or "png"
|
|
49
|
-
-s, --scale <factor> Pixel scale factor for PNG (default: 2; ignored for SVG)
|
|
50
|
-
-t, --theme <name> Theme name passed to renderer (e.g. "theme-default")
|
|
51
|
-
-c, --configFile <file> JSON config file with { theme, scale, outputFormat }
|
|
52
|
-
--md Markdown mode: render zenuml code blocks and produce output Markdown
|
|
53
|
-
|
|
54
|
-
Validation:
|
|
55
|
-
--check Validate syntax without rendering (exit 0 if valid, 1 if errors)
|
|
56
|
-
--parse Parse input and output AST as JSON (exit 0 if valid, 1 if errors)
|
|
57
|
-
--json Machine-readable JSON output for --check mode
|
|
58
|
-
|
|
59
|
-
Batch & Watch:
|
|
60
|
-
-w, --watch Watch input files and re-render on change (incompatible with --check, --parse, stdin)
|
|
61
|
-
|
|
62
|
-
General:
|
|
63
|
-
-q, --quiet Suppress non-error output
|
|
64
|
-
-h, --help Show this help message
|
|
65
|
-
-V, --version Show version number
|
|
66
|
-
|
|
67
|
-
Config file values are overridden by CLI flags.
|
|
68
|
-
Rendering flags (-o, -e, -t, -s) are silently ignored in --check and --parse modes.
|
|
69
|
-
|
|
70
|
-
Examples:
|
|
71
|
-
zenuml -i diagram.zenuml
|
|
72
|
-
zenuml -i diagram.zenuml -o output.svg
|
|
73
|
-
zenuml -i diagram.zenuml -o output.png
|
|
74
|
-
zenuml -i diagram.zenuml -e png -s 3
|
|
75
|
-
zenuml -i diagram.zenuml -c config.json
|
|
76
|
-
cat diagram.zenuml | zenuml -i - -o -
|
|
77
|
-
zenuml --check -i file1.zenuml -i file2.zenuml
|
|
78
|
-
zenuml --check --json -i file1.zenuml -i file2.zenuml
|
|
79
|
-
zenuml --parse -i diagram.zenuml
|
|
80
|
-
zenuml -i readme.md --md
|
|
81
|
-
zenuml -i readme.md --md -e png
|
|
82
|
-
`.trimStart();
|
|
83
|
-
process.stdout.write(help);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ---------------------------------------------------------------------------
|
|
87
|
-
// Markdown block extractor
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
export interface ZenumlBlock {
|
|
91
|
-
/** Zero-based index of this block among all zenuml blocks in the document */
|
|
92
|
-
index: number;
|
|
93
|
-
/** The ZenUML DSL code inside the fence (may be empty string for empty blocks) */
|
|
94
|
-
code: string;
|
|
95
|
-
/** Title extracted from the info string after "zenuml" (trimmed), or empty string */
|
|
96
|
-
title: string;
|
|
97
|
-
/** The full raw fence text including the opening and closing ``` lines */
|
|
98
|
-
raw: string;
|
|
99
|
-
/** True if the code (trimmed) is empty — these blocks should be excluded from rendering */
|
|
100
|
-
empty: boolean;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Extract all ```zenuml ... ``` fenced code blocks from Markdown text.
|
|
105
|
-
* Returns one ZenumlBlock per block, in document order.
|
|
106
|
-
*/
|
|
107
|
-
export function extractZenumlBlocks(md: string): ZenumlBlock[] {
|
|
108
|
-
const results: ZenumlBlock[] = [];
|
|
109
|
-
// Match fenced code blocks that start with ```zenuml (with optional title after)
|
|
110
|
-
// Handles both ``` and ~~~ fences but we only care about backtick fences for ZenUML
|
|
111
|
-
const fencePattern = /^(`{3,})zenuml([^\n]*)\n([\s\S]*?)\n?\1\s*$/gm;
|
|
112
|
-
let match: RegExpExecArray | null;
|
|
113
|
-
let blockIndex = 0;
|
|
114
|
-
while ((match = fencePattern.exec(md)) !== null) {
|
|
115
|
-
const raw = match[0];
|
|
116
|
-
const infoExtra = match[2]; // everything after "zenuml" on the opening line
|
|
117
|
-
const code = match[3]; // content between fences
|
|
118
|
-
const title = infoExtra.trim();
|
|
119
|
-
const empty = code.trim().length === 0;
|
|
120
|
-
results.push({ index: blockIndex++, code, title, raw, empty });
|
|
121
|
-
}
|
|
122
|
-
return results;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// Glob expansion
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
/** Check if a string contains glob metacharacters. */
|
|
130
|
-
function isGlobPattern(s: string): boolean {
|
|
131
|
-
return /[*?[]/.test(s);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Expand inputs: literal paths pass through; glob patterns are expanded.
|
|
135
|
-
* Returns the expanded list. Throws if a glob pattern matches zero files. */
|
|
136
|
-
function expandInputs(inputs: string[]): string[] {
|
|
137
|
-
const result: string[] = [];
|
|
138
|
-
for (const input of inputs) {
|
|
139
|
-
if (input === "-" || !isGlobPattern(input)) {
|
|
140
|
-
result.push(input);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
// Glob expansion
|
|
144
|
-
const glob = new Bun.Glob(input);
|
|
145
|
-
const matches: string[] = [];
|
|
146
|
-
for (const match of glob.scanSync({ cwd: process.cwd(), onlyFiles: true })) {
|
|
147
|
-
matches.push(match);
|
|
148
|
-
}
|
|
149
|
-
if (matches.length === 0) {
|
|
150
|
-
throw new Error(`Glob pattern "${input}" matched no files`);
|
|
151
|
-
}
|
|
152
|
-
// Sort for deterministic order
|
|
153
|
-
matches.sort();
|
|
154
|
-
result.push(...matches);
|
|
155
|
-
}
|
|
156
|
-
return result;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Check if a path is an existing directory. */
|
|
160
|
-
function isDirectory(p: string): boolean {
|
|
161
|
-
try {
|
|
162
|
-
return statSync(p).isDirectory();
|
|
163
|
-
} catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---------------------------------------------------------------------------
|
|
169
|
-
// Argument parsing
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
|
|
172
|
-
interface CliArgs {
|
|
173
|
-
inputs: string[];
|
|
174
|
-
output?: string;
|
|
175
|
-
outputFormat?: string;
|
|
176
|
-
scale?: number;
|
|
177
|
-
theme?: string;
|
|
178
|
-
configFile?: string;
|
|
179
|
-
check: boolean;
|
|
180
|
-
parse: boolean;
|
|
181
|
-
json: boolean;
|
|
182
|
-
quiet: boolean;
|
|
183
|
-
help: boolean;
|
|
184
|
-
version: boolean;
|
|
185
|
-
md: boolean;
|
|
186
|
-
watch: boolean;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function parseArgs(argv: string[]): CliArgs {
|
|
190
|
-
const args: CliArgs = { inputs: [], check: false, parse: false, json: false, quiet: false, help: false, version: false, md: false, watch: false };
|
|
191
|
-
let i = 0;
|
|
192
|
-
while (i < argv.length) {
|
|
193
|
-
const arg = argv[i];
|
|
194
|
-
switch (arg) {
|
|
195
|
-
case "-i":
|
|
196
|
-
case "--input":
|
|
197
|
-
i++;
|
|
198
|
-
args.inputs.push(argv[i]);
|
|
199
|
-
break;
|
|
200
|
-
case "-o":
|
|
201
|
-
case "--output":
|
|
202
|
-
i++;
|
|
203
|
-
args.output = argv[i];
|
|
204
|
-
break;
|
|
205
|
-
case "-e":
|
|
206
|
-
case "--outputFormat":
|
|
207
|
-
i++;
|
|
208
|
-
args.outputFormat = argv[i];
|
|
209
|
-
break;
|
|
210
|
-
case "-s":
|
|
211
|
-
case "--scale":
|
|
212
|
-
i++;
|
|
213
|
-
args.scale = Number(argv[i]);
|
|
214
|
-
break;
|
|
215
|
-
case "-t":
|
|
216
|
-
case "--theme":
|
|
217
|
-
i++;
|
|
218
|
-
args.theme = argv[i];
|
|
219
|
-
break;
|
|
220
|
-
case "-c":
|
|
221
|
-
case "--configFile":
|
|
222
|
-
i++;
|
|
223
|
-
args.configFile = argv[i];
|
|
224
|
-
break;
|
|
225
|
-
case "--md":
|
|
226
|
-
args.md = true;
|
|
227
|
-
break;
|
|
228
|
-
case "--check":
|
|
229
|
-
args.check = true;
|
|
230
|
-
break;
|
|
231
|
-
case "--parse":
|
|
232
|
-
args.parse = true;
|
|
233
|
-
break;
|
|
234
|
-
case "--json":
|
|
235
|
-
args.json = true;
|
|
236
|
-
break;
|
|
237
|
-
case "-q":
|
|
238
|
-
case "--quiet":
|
|
239
|
-
args.quiet = true;
|
|
240
|
-
break;
|
|
241
|
-
case "-h":
|
|
242
|
-
case "--help":
|
|
243
|
-
args.help = true;
|
|
244
|
-
break;
|
|
245
|
-
case "-V":
|
|
246
|
-
case "--version":
|
|
247
|
-
args.version = true;
|
|
248
|
-
break;
|
|
249
|
-
case "-w":
|
|
250
|
-
case "--watch":
|
|
251
|
-
args.watch = true;
|
|
252
|
-
break;
|
|
253
|
-
default:
|
|
254
|
-
process.stderr.write(`Unknown option: ${arg}\nRun "zenuml --help" for usage.\n`);
|
|
255
|
-
process.exit(1);
|
|
256
|
-
}
|
|
257
|
-
i++;
|
|
258
|
-
}
|
|
259
|
-
return args;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ---------------------------------------------------------------------------
|
|
263
|
-
// Main
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
|
|
266
|
-
/** Load and merge config file values (if any). CLI flags override config. */
|
|
267
|
-
function loadConfigFile(filePath: string): Record<string, unknown> {
|
|
268
|
-
const resolved = resolve(filePath);
|
|
269
|
-
let raw: string;
|
|
270
|
-
try {
|
|
271
|
-
raw = readFileSync(resolved, "utf-8");
|
|
272
|
-
} catch {
|
|
273
|
-
process.stderr.write(`Error: Cannot read config file: ${resolved}\n`);
|
|
274
|
-
process.exit(1);
|
|
275
|
-
}
|
|
276
|
-
try {
|
|
277
|
-
return JSON.parse(raw);
|
|
278
|
-
} catch {
|
|
279
|
-
process.stderr.write(`Error: Invalid JSON in config file: ${resolved}\n`);
|
|
280
|
-
process.exit(1);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Shared Playwright browser instance — launched once, reused across renders. */
|
|
285
|
-
let _browser: Awaited<ReturnType<typeof import("playwright-core")["chromium"]["launch"]>> | null = null;
|
|
286
|
-
|
|
287
|
-
async function getPlaywrightBrowser() {
|
|
288
|
-
if (_browser) return _browser;
|
|
289
|
-
const { chromium } = await import("playwright-core");
|
|
290
|
-
_browser = await chromium.launch();
|
|
291
|
-
return _browser;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Shut down the shared browser (call before process exit). */
|
|
295
|
-
async function closeBrowser() {
|
|
296
|
-
if (_browser) {
|
|
297
|
-
await _browser.close();
|
|
298
|
-
_browser = null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Rasterize an SVG string to PNG bytes using headless Chromium (Playwright). */
|
|
303
|
-
async function rasterizeToPng(
|
|
304
|
-
svgString: string,
|
|
305
|
-
svgWidth: number,
|
|
306
|
-
svgHeight: number,
|
|
307
|
-
scale: number,
|
|
308
|
-
): Promise<Buffer> {
|
|
309
|
-
const browser = await getPlaywrightBrowser();
|
|
310
|
-
const page = await browser.newPage({
|
|
311
|
-
viewport: {
|
|
312
|
-
width: Math.ceil(svgWidth),
|
|
313
|
-
height: Math.ceil(svgHeight),
|
|
314
|
-
},
|
|
315
|
-
deviceScaleFactor: scale,
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
const html = `<!DOCTYPE html>
|
|
320
|
-
<html><head><style>
|
|
321
|
-
html, body { margin: 0; padding: 0; background: white; overflow: hidden; }
|
|
322
|
-
svg { display: block; width: ${svgWidth}px; height: ${svgHeight}px; }
|
|
323
|
-
</style></head><body>${svgString}</body></html>`;
|
|
324
|
-
|
|
325
|
-
await page.setContent(html, { waitUntil: "load" });
|
|
326
|
-
const png = await page.screenshot({ type: "png" });
|
|
327
|
-
return Buffer.from(png);
|
|
328
|
-
} finally {
|
|
329
|
-
await page.close();
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
// AST Serializer — converts ANTLR parse tree to JSON-safe plain object
|
|
335
|
-
// ---------------------------------------------------------------------------
|
|
336
|
-
|
|
337
|
-
interface AstNode {
|
|
338
|
-
type: string;
|
|
339
|
-
ruleName?: string;
|
|
340
|
-
text?: string;
|
|
341
|
-
children?: AstNode[];
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/** Get rule names from the parser embedded in a context node. */
|
|
345
|
-
function getRuleNames(ctx: any): string[] | undefined {
|
|
346
|
-
return ctx?.parser?.ruleNames;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** Serialize an ANTLR parse tree node to a JSON-safe object. */
|
|
350
|
-
function serializeParseTree(node: any): AstNode {
|
|
351
|
-
if (!node) return { type: "null" };
|
|
352
|
-
|
|
353
|
-
// Terminal node (leaf token)
|
|
354
|
-
if (node.symbol !== undefined) {
|
|
355
|
-
return {
|
|
356
|
-
type: "terminal",
|
|
357
|
-
text: node.getText(),
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Parser rule context node
|
|
362
|
-
const ruleNames = getRuleNames(node);
|
|
363
|
-
const ruleName = ruleNames && node.ruleIndex !== undefined
|
|
364
|
-
? ruleNames[node.ruleIndex]
|
|
365
|
-
: undefined;
|
|
366
|
-
|
|
367
|
-
const result: AstNode = {
|
|
368
|
-
type: "rule",
|
|
369
|
-
};
|
|
370
|
-
if (ruleName) {
|
|
371
|
-
result.ruleName = ruleName;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const children = node.children;
|
|
375
|
-
if (children && children.length > 0) {
|
|
376
|
-
result.children = children.map((child: any) => serializeParseTree(child));
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return result;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
// Check mode — validate syntax without rendering
|
|
384
|
-
// ---------------------------------------------------------------------------
|
|
385
|
-
|
|
386
|
-
interface FileCheckResult {
|
|
387
|
-
file: string;
|
|
388
|
-
pass: boolean;
|
|
389
|
-
errors: Array<{ line: number; column: number; msg: string }>;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** Read code from a file path or stdin ("-"). */
|
|
393
|
-
async function readCode(input: string): Promise<string> {
|
|
394
|
-
if (input === "-") {
|
|
395
|
-
return readStdin();
|
|
396
|
-
}
|
|
397
|
-
const inputPath = resolve(input);
|
|
398
|
-
try {
|
|
399
|
-
return readFileSync(inputPath, "utf-8");
|
|
400
|
-
} catch {
|
|
401
|
-
throw new Error(`Cannot read input file: ${inputPath}`);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** Parse one file/input and return check result. Clears Parser.ErrorDetails before each parse. */
|
|
406
|
-
async function checkOne(input: string): Promise<FileCheckResult> {
|
|
407
|
-
const fileName = input === "-" ? "<stdin>" : input;
|
|
408
|
-
let code: string;
|
|
409
|
-
try {
|
|
410
|
-
code = await readCode(input);
|
|
411
|
-
} catch (err: any) {
|
|
412
|
-
return {
|
|
413
|
-
file: fileName,
|
|
414
|
-
pass: false,
|
|
415
|
-
errors: [{ line: 0, column: 0, msg: err.message }],
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Clear accumulated errors before parsing
|
|
420
|
-
Parser.Errors.length = 0;
|
|
421
|
-
Parser.ErrorDetails.length = 0;
|
|
422
|
-
|
|
423
|
-
Parser.RootContext(code);
|
|
424
|
-
|
|
425
|
-
const errors = Parser.ErrorDetails.map((e: any) => ({
|
|
426
|
-
line: e.line,
|
|
427
|
-
column: e.column,
|
|
428
|
-
msg: e.msg,
|
|
429
|
-
}));
|
|
430
|
-
|
|
431
|
-
return {
|
|
432
|
-
file: fileName,
|
|
433
|
-
pass: errors.length === 0,
|
|
434
|
-
errors,
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// ---------------------------------------------------------------------------
|
|
439
|
-
// Main
|
|
440
|
-
// ---------------------------------------------------------------------------
|
|
441
|
-
|
|
442
|
-
async function main(): Promise<void> {
|
|
443
|
-
// Inject @napi-rs/canvas context for accurate text measurement in renderToSvg.
|
|
444
|
-
// Without this, WidthProviderOnCanvas falls back to character estimates which
|
|
445
|
-
// produces incorrect layout (wrong participant spacing and message positioning).
|
|
446
|
-
if (!globalThis.OffscreenCanvas && typeof document === "undefined") {
|
|
447
|
-
try {
|
|
448
|
-
const { createCanvas } = await import("@napi-rs/canvas");
|
|
449
|
-
setCanvasContext(createCanvas(1, 1).getContext("2d") as any);
|
|
450
|
-
} catch {
|
|
451
|
-
// If @napi-rs/canvas is unavailable, fall back to character estimates
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Skip the first two entries (bun executable + script path).
|
|
456
|
-
const rawArgs = process.argv.slice(2);
|
|
457
|
-
const args = parseArgs(rawArgs);
|
|
458
|
-
|
|
459
|
-
// --help
|
|
460
|
-
if (args.help) {
|
|
461
|
-
printHelp();
|
|
462
|
-
process.exit(0);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// --version
|
|
466
|
-
if (args.version) {
|
|
467
|
-
process.stdout.write(readVersion() + "\n");
|
|
468
|
-
process.exit(0);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Require input
|
|
472
|
-
if (args.inputs.length === 0) {
|
|
473
|
-
process.stderr.write("Error: -i/--input is required. Use -h for help.\n");
|
|
474
|
-
process.exit(1);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// ---------------------------------------------------------------------------
|
|
478
|
-
// Glob expansion
|
|
479
|
-
// ---------------------------------------------------------------------------
|
|
480
|
-
let expandedInputs: string[];
|
|
481
|
-
try {
|
|
482
|
-
expandedInputs = expandInputs(args.inputs);
|
|
483
|
-
} catch (err: any) {
|
|
484
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
485
|
-
process.exit(1);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// ---------------------------------------------------------------------------
|
|
489
|
-
// --watch incompatibility checks
|
|
490
|
-
// ---------------------------------------------------------------------------
|
|
491
|
-
if (args.watch) {
|
|
492
|
-
if (args.check) {
|
|
493
|
-
process.stderr.write("Error: --watch is incompatible with --check.\n");
|
|
494
|
-
process.exit(1);
|
|
495
|
-
}
|
|
496
|
-
if (args.parse) {
|
|
497
|
-
process.stderr.write("Error: --watch is incompatible with --parse.\n");
|
|
498
|
-
process.exit(1);
|
|
499
|
-
}
|
|
500
|
-
if (expandedInputs.includes("-")) {
|
|
501
|
-
process.stderr.write("Error: --watch is incompatible with stdin input.\n");
|
|
502
|
-
process.exit(1);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// ---------------------------------------------------------------------------
|
|
507
|
-
// --check mode: validate syntax without rendering
|
|
508
|
-
// ---------------------------------------------------------------------------
|
|
509
|
-
if (args.check) {
|
|
510
|
-
const results: FileCheckResult[] = [];
|
|
511
|
-
for (const input of expandedInputs) {
|
|
512
|
-
results.push(await checkOne(input));
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (args.json) {
|
|
516
|
-
// Machine-readable JSON output to stdout
|
|
517
|
-
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
518
|
-
} else {
|
|
519
|
-
// Human-readable output to stderr
|
|
520
|
-
for (const r of results) {
|
|
521
|
-
if (!r.pass) {
|
|
522
|
-
if (expandedInputs.length > 1) {
|
|
523
|
-
process.stderr.write(`${r.file}:\n`);
|
|
524
|
-
}
|
|
525
|
-
for (const e of r.errors) {
|
|
526
|
-
const prefix = expandedInputs.length > 1 ? " " : "";
|
|
527
|
-
process.stderr.write(`${prefix}line ${e.line}, col ${e.column}: ${e.msg}\n`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const anyFailed = results.some((r) => !r.pass);
|
|
534
|
-
process.exit(anyFailed ? 1 : 0);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// ---------------------------------------------------------------------------
|
|
538
|
-
// --parse mode: output AST as JSON (single input only)
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
if (args.parse) {
|
|
541
|
-
if (expandedInputs.length > 1) {
|
|
542
|
-
process.stderr.write("Error: --parse supports only a single input file.\n");
|
|
543
|
-
process.exit(1);
|
|
544
|
-
}
|
|
545
|
-
const input = expandedInputs[0];
|
|
546
|
-
let code: string;
|
|
547
|
-
try {
|
|
548
|
-
code = await readCode(input);
|
|
549
|
-
} catch (err: any) {
|
|
550
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Clear accumulated errors before parsing
|
|
555
|
-
Parser.Errors.length = 0;
|
|
556
|
-
Parser.ErrorDetails.length = 0;
|
|
557
|
-
|
|
558
|
-
const tree = Parser.RootContext(code);
|
|
559
|
-
|
|
560
|
-
if (Parser.ErrorDetails.length > 0) {
|
|
561
|
-
for (const e of Parser.ErrorDetails) {
|
|
562
|
-
process.stderr.write(`line ${e.line}, col ${e.column}: ${e.msg}\n`);
|
|
563
|
-
}
|
|
564
|
-
process.exit(1);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
const ast = serializeParseTree(tree);
|
|
568
|
-
process.stdout.write(JSON.stringify(ast, null, 2) + "\n");
|
|
569
|
-
process.exit(0);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// ---------------------------------------------------------------------------
|
|
573
|
-
// --md mode: render Markdown with zenuml code blocks
|
|
574
|
-
// ---------------------------------------------------------------------------
|
|
575
|
-
// Determine if we're in Markdown mode: explicit --md flag or auto-detect from extension
|
|
576
|
-
const isMdMode = args.md || expandedInputs.some(
|
|
577
|
-
(f) => f !== "-" && /\.(?:md|markdown)$/i.test(f),
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
if (isMdMode) {
|
|
581
|
-
// Validate: --md with multiple inputs is an error
|
|
582
|
-
if (expandedInputs.length > 1) {
|
|
583
|
-
process.stderr.write("Error: --md mode supports only a single input file.\n");
|
|
584
|
-
process.exit(1);
|
|
585
|
-
}
|
|
586
|
-
const inputArg = expandedInputs[0];
|
|
587
|
-
|
|
588
|
-
// Validate: --md with non-.md input is an error (only when --md was explicitly passed)
|
|
589
|
-
if (args.md && inputArg !== "-") {
|
|
590
|
-
const ext = extname(inputArg).toLowerCase();
|
|
591
|
-
if (ext !== ".md" && ext !== ".markdown") {
|
|
592
|
-
process.stderr.write(`Error: --md flag requires a .md or .markdown input file, got: ${inputArg}\n`);
|
|
593
|
-
process.exit(1);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Read markdown content
|
|
598
|
-
let mdContent: string;
|
|
599
|
-
try {
|
|
600
|
-
mdContent = await readCode(inputArg);
|
|
601
|
-
} catch (err: any) {
|
|
602
|
-
process.stderr.write(`Error: ${err.message}\n`);
|
|
603
|
-
process.exit(1);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Effective format for diagram images
|
|
607
|
-
const effectiveFormat = args.outputFormat ?? "svg";
|
|
608
|
-
if (effectiveFormat !== "svg" && effectiveFormat !== "png") {
|
|
609
|
-
process.stderr.write(`Error: Unsupported output format: "${effectiveFormat}". Use "svg" or "png".\n`);
|
|
610
|
-
process.exit(1);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// Determine output dir for images:
|
|
614
|
-
// If -o is given (and not stdout), use that file's directory
|
|
615
|
-
// Otherwise if input is a file, use input's directory
|
|
616
|
-
// For -o - (stdout), images go adjacent to the input file (or cwd for stdin)
|
|
617
|
-
let imageDir: string;
|
|
618
|
-
let mdOutputPath: string;
|
|
619
|
-
|
|
620
|
-
if (args.output && args.output !== "-") {
|
|
621
|
-
const resolvedOutput = resolve(args.output);
|
|
622
|
-
imageDir = dirname(resolvedOutput);
|
|
623
|
-
mdOutputPath = resolvedOutput;
|
|
624
|
-
} else if (args.output === "-") {
|
|
625
|
-
// stdout: images go adjacent to input or cwd
|
|
626
|
-
if (inputArg !== "-") {
|
|
627
|
-
imageDir = dirname(resolve(inputArg));
|
|
628
|
-
} else {
|
|
629
|
-
imageDir = process.cwd();
|
|
630
|
-
}
|
|
631
|
-
mdOutputPath = "-";
|
|
632
|
-
} else {
|
|
633
|
-
// No -o: default output is {stem}-rendered.md adjacent to input
|
|
634
|
-
if (inputArg !== "-") {
|
|
635
|
-
const resolvedInput = resolve(inputArg);
|
|
636
|
-
const inputDir = dirname(resolvedInput);
|
|
637
|
-
const ext = extname(inputArg);
|
|
638
|
-
const stem = basename(inputArg, ext);
|
|
639
|
-
imageDir = inputDir;
|
|
640
|
-
mdOutputPath = join(inputDir, `${stem}-rendered.md`);
|
|
641
|
-
} else {
|
|
642
|
-
imageDir = process.cwd();
|
|
643
|
-
mdOutputPath = "-";
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Determine stem for image file names (from input or output path)
|
|
648
|
-
let imageStem: string;
|
|
649
|
-
if (inputArg !== "-") {
|
|
650
|
-
const ext = extname(inputArg);
|
|
651
|
-
imageStem = basename(inputArg, ext);
|
|
652
|
-
} else if (mdOutputPath !== "-") {
|
|
653
|
-
const ext = extname(mdOutputPath);
|
|
654
|
-
imageStem = basename(mdOutputPath, ext);
|
|
655
|
-
} else {
|
|
656
|
-
imageStem = "diagram";
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Extract zenuml blocks
|
|
660
|
-
const blocks = extractZenumlBlocks(mdContent);
|
|
661
|
-
|
|
662
|
-
// Effective render options
|
|
663
|
-
const effectiveScale = args.scale ?? 2;
|
|
664
|
-
const effectiveTheme = args.theme;
|
|
665
|
-
const renderOptions: RenderOptions = {};
|
|
666
|
-
if (effectiveTheme) {
|
|
667
|
-
renderOptions.theme = effectiveTheme as RenderOptions["theme"];
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Render each non-empty block and collect image paths
|
|
671
|
-
const imageFiles: Map<number, string> = new Map();
|
|
672
|
-
for (const block of blocks) {
|
|
673
|
-
if (block.empty) continue;
|
|
674
|
-
const imageFilename = `${imageStem}-zenuml-${block.index}.${effectiveFormat}`;
|
|
675
|
-
const imageFilePath = join(imageDir, imageFilename);
|
|
676
|
-
|
|
677
|
-
let svg: string;
|
|
678
|
-
let svgWidth: number;
|
|
679
|
-
let svgHeight: number;
|
|
680
|
-
try {
|
|
681
|
-
const result = renderToSvg(block.code, renderOptions);
|
|
682
|
-
svg = result.svg;
|
|
683
|
-
svgWidth = result.width;
|
|
684
|
-
svgHeight = result.height;
|
|
685
|
-
} catch (err: any) {
|
|
686
|
-
process.stderr.write(`Error: Failed to render zenuml block ${block.index}: ${err.message}\n`);
|
|
687
|
-
process.exit(1);
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Ensure image directory exists
|
|
691
|
-
mkdirSync(imageDir, { recursive: true });
|
|
692
|
-
|
|
693
|
-
if (effectiveFormat === "png") {
|
|
694
|
-
const pngBuffer = await rasterizeToPng(svg, svgWidth, svgHeight, effectiveScale);
|
|
695
|
-
writeFileSync(imageFilePath, pngBuffer);
|
|
696
|
-
} else {
|
|
697
|
-
writeFileSync(imageFilePath, svg, "utf-8");
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
if (!args.quiet) {
|
|
701
|
-
process.stderr.write(`Wrote ${imageFilePath}\n`);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
imageFiles.set(block.index, imageFilename);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Replace blocks in the Markdown
|
|
708
|
-
let outputMd = mdContent;
|
|
709
|
-
// Process blocks in reverse order so that string offsets remain valid
|
|
710
|
-
// We need to find and replace each raw block
|
|
711
|
-
// Re-scan in reverse order to replace correctly
|
|
712
|
-
const sortedBlocks = [...blocks].reverse();
|
|
713
|
-
for (const block of sortedBlocks) {
|
|
714
|
-
const altText = block.title || `diagram ${block.index + 1}`;
|
|
715
|
-
if (block.empty) {
|
|
716
|
-
// Remove empty blocks entirely
|
|
717
|
-
outputMd = outputMd.replace(block.raw, "");
|
|
718
|
-
} else {
|
|
719
|
-
const imageFilename = imageFiles.get(block.index)!;
|
|
720
|
-
const replacement = ``;
|
|
721
|
-
outputMd = outputMd.replace(block.raw, replacement);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Write output Markdown
|
|
726
|
-
if (mdOutputPath === "-") {
|
|
727
|
-
process.stdout.write(outputMd);
|
|
728
|
-
} else {
|
|
729
|
-
mkdirSync(dirname(mdOutputPath), { recursive: true });
|
|
730
|
-
writeFileSync(mdOutputPath, outputMd, "utf-8");
|
|
731
|
-
if (!args.quiet) {
|
|
732
|
-
process.stderr.write(`Wrote ${mdOutputPath}\n`);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
process.exit(0);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// ---------------------------------------------------------------------------
|
|
740
|
-
// Config file merging: config file < CLI flags
|
|
741
|
-
// ---------------------------------------------------------------------------
|
|
742
|
-
let configScale: number | undefined;
|
|
743
|
-
let configTheme: string | undefined;
|
|
744
|
-
let configOutputFormat: string | undefined;
|
|
745
|
-
|
|
746
|
-
if (args.configFile) {
|
|
747
|
-
const cfg = loadConfigFile(args.configFile);
|
|
748
|
-
if (typeof cfg.scale === "number") configScale = cfg.scale;
|
|
749
|
-
if (typeof cfg.theme === "string") configTheme = cfg.theme;
|
|
750
|
-
if (typeof cfg.outputFormat === "string") configOutputFormat = cfg.outputFormat;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// ---------------------------------------------------------------------------
|
|
754
|
-
// Validate: -o as single file + multiple inputs → error
|
|
755
|
-
// ---------------------------------------------------------------------------
|
|
756
|
-
const multipleInputs = expandedInputs.length > 1;
|
|
757
|
-
if (multipleInputs && args.output && args.output !== "-" && !isDirectory(resolve(args.output))) {
|
|
758
|
-
// -o is a file path (not a directory) with multiple inputs
|
|
759
|
-
process.stderr.write("Error: -o must be a directory when multiple input files are provided.\n");
|
|
760
|
-
process.exit(1);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// ---------------------------------------------------------------------------
|
|
764
|
-
// --watch mode
|
|
765
|
-
// ---------------------------------------------------------------------------
|
|
766
|
-
if (args.watch) {
|
|
767
|
-
const renderForWatch = async (inputArg: string): Promise<void> => {
|
|
768
|
-
await renderOneFile(inputArg, args, {
|
|
769
|
-
configScale,
|
|
770
|
-
configTheme,
|
|
771
|
-
configOutputFormat,
|
|
772
|
-
multipleInputs,
|
|
773
|
-
});
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
const renderMdForWatch = async (inputArg: string): Promise<void> => {
|
|
777
|
-
// Re-invoke main md render for this file by delegating back through renderOneFile
|
|
778
|
-
// but we need md-specific rendering — so call the same md render path.
|
|
779
|
-
// For simplicity, we re-read and re-run the md render inline.
|
|
780
|
-
let mdContent: string;
|
|
781
|
-
try {
|
|
782
|
-
mdContent = readFileSync(resolve(inputArg), "utf-8");
|
|
783
|
-
} catch {
|
|
784
|
-
throw new Error(`Cannot read input file: ${resolve(inputArg)}`);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const effectiveFormat = args.outputFormat ?? "svg";
|
|
788
|
-
if (effectiveFormat !== "svg" && effectiveFormat !== "png") {
|
|
789
|
-
throw new Error(`Unsupported output format: "${effectiveFormat}". Use "svg" or "png".`);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
let mdOutputPath: string;
|
|
793
|
-
let imageDir: string;
|
|
794
|
-
|
|
795
|
-
if (args.output && args.output !== "-") {
|
|
796
|
-
const resolvedOutput = resolve(args.output);
|
|
797
|
-
imageDir = dirname(resolvedOutput);
|
|
798
|
-
mdOutputPath = resolvedOutput;
|
|
799
|
-
} else if (args.output === "-") {
|
|
800
|
-
imageDir = dirname(resolve(inputArg));
|
|
801
|
-
mdOutputPath = "-";
|
|
802
|
-
} else {
|
|
803
|
-
const resolvedInput = resolve(inputArg);
|
|
804
|
-
const inputDir = dirname(resolvedInput);
|
|
805
|
-
const ext = extname(inputArg);
|
|
806
|
-
const stem = basename(inputArg, ext);
|
|
807
|
-
imageDir = inputDir;
|
|
808
|
-
mdOutputPath = join(inputDir, `${stem}-rendered.md`);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const ext = extname(inputArg);
|
|
812
|
-
const imageStem = basename(inputArg, ext);
|
|
813
|
-
const blocks = extractZenumlBlocks(mdContent);
|
|
814
|
-
|
|
815
|
-
const effectiveScale = args.scale ?? configScale ?? 2;
|
|
816
|
-
const effectiveTheme = args.theme ?? configTheme;
|
|
817
|
-
const renderOptions: RenderOptions = {};
|
|
818
|
-
if (effectiveTheme) {
|
|
819
|
-
renderOptions.theme = effectiveTheme as RenderOptions["theme"];
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
const imageFiles: Map<number, string> = new Map();
|
|
823
|
-
for (const block of blocks) {
|
|
824
|
-
if (block.empty) continue;
|
|
825
|
-
const imageFilename = `${imageStem}-zenuml-${block.index}.${effectiveFormat}`;
|
|
826
|
-
const imageFilePath = join(imageDir, imageFilename);
|
|
827
|
-
|
|
828
|
-
const result = renderToSvg(block.code, renderOptions);
|
|
829
|
-
const { svg, width: svgWidth, height: svgHeight } = result;
|
|
830
|
-
|
|
831
|
-
mkdirSync(imageDir, { recursive: true });
|
|
832
|
-
|
|
833
|
-
if (effectiveFormat === "png") {
|
|
834
|
-
const pngBuffer = await rasterizeToPng(svg, svgWidth, svgHeight, effectiveScale);
|
|
835
|
-
writeFileSync(imageFilePath, pngBuffer);
|
|
836
|
-
} else {
|
|
837
|
-
writeFileSync(imageFilePath, svg, "utf-8");
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
imageFiles.set(block.index, imageFilename);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
let outputMd = mdContent;
|
|
844
|
-
const sortedBlocks = [...blocks].reverse();
|
|
845
|
-
for (const block of sortedBlocks) {
|
|
846
|
-
const altText = block.title || `diagram ${block.index + 1}`;
|
|
847
|
-
if (block.empty) {
|
|
848
|
-
outputMd = outputMd.replace(block.raw, "");
|
|
849
|
-
} else {
|
|
850
|
-
const imageFilename = imageFiles.get(block.index)!;
|
|
851
|
-
const replacement = ``;
|
|
852
|
-
outputMd = outputMd.replace(block.raw, replacement);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
if (mdOutputPath === "-") {
|
|
857
|
-
process.stdout.write(outputMd);
|
|
858
|
-
} else {
|
|
859
|
-
mkdirSync(dirname(mdOutputPath), { recursive: true });
|
|
860
|
-
writeFileSync(mdOutputPath, outputMd, "utf-8");
|
|
861
|
-
}
|
|
862
|
-
};
|
|
863
|
-
|
|
864
|
-
// Detect md inputs
|
|
865
|
-
const hasMdInputs = expandedInputs.some((f) => /\.(?:md|markdown)$/i.test(f));
|
|
866
|
-
const renderMdFnForWatch = hasMdInputs ? renderMdForWatch : undefined;
|
|
867
|
-
|
|
868
|
-
const watchHandle = await startWatchMode(
|
|
869
|
-
expandedInputs,
|
|
870
|
-
renderForWatch,
|
|
871
|
-
undefined,
|
|
872
|
-
undefined,
|
|
873
|
-
undefined,
|
|
874
|
-
renderMdFnForWatch,
|
|
875
|
-
);
|
|
876
|
-
|
|
877
|
-
process.on("SIGINT", () => {
|
|
878
|
-
watchHandle.shutdown();
|
|
879
|
-
process.exit(0);
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
// Keep the process alive (watch mode runs indefinitely)
|
|
883
|
-
await new Promise<void>(() => {});
|
|
884
|
-
return;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
// ---------------------------------------------------------------------------
|
|
888
|
-
// Render mode — iterate all expanded inputs
|
|
889
|
-
// ---------------------------------------------------------------------------
|
|
890
|
-
let rendered = 0;
|
|
891
|
-
let errors = 0;
|
|
892
|
-
|
|
893
|
-
for (const inputArg of expandedInputs) {
|
|
894
|
-
// Progress reporting
|
|
895
|
-
if (!args.quiet) {
|
|
896
|
-
const displayName = inputArg === "-" ? "<stdin>" : inputArg;
|
|
897
|
-
process.stderr.write(`Rendering ${displayName}...\n`);
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
try {
|
|
901
|
-
await renderOneFile(inputArg, args, {
|
|
902
|
-
configScale,
|
|
903
|
-
configTheme,
|
|
904
|
-
configOutputFormat,
|
|
905
|
-
multipleInputs,
|
|
906
|
-
});
|
|
907
|
-
rendered++;
|
|
908
|
-
} catch (err: any) {
|
|
909
|
-
errors++;
|
|
910
|
-
process.stderr.write(`Error: ${inputArg}: ${err.message}\n`);
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// Summary line
|
|
915
|
-
if (!args.quiet && expandedInputs.length > 1) {
|
|
916
|
-
process.stderr.write(`Rendered ${rendered} files (${errors} errors)\n`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
if (errors > 0) {
|
|
920
|
-
process.exit(1);
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/** Render a single input file to its output. Throws on failure. */
|
|
925
|
-
async function renderOneFile(
|
|
926
|
-
inputArg: string,
|
|
927
|
-
args: CliArgs,
|
|
928
|
-
config: {
|
|
929
|
-
configScale?: number;
|
|
930
|
-
configTheme?: string;
|
|
931
|
-
configOutputFormat?: string;
|
|
932
|
-
multipleInputs: boolean;
|
|
933
|
-
},
|
|
934
|
-
): Promise<void> {
|
|
935
|
-
// Resolve effective values: CLI flag > config > default
|
|
936
|
-
const outputPath = resolveOutput(inputArg, args.output, ".svg");
|
|
937
|
-
const autoFormatFromExt = outputPath !== "-" && extname(outputPath).toLowerCase() === ".png" ? "png" : undefined;
|
|
938
|
-
const effectiveFormat = args.outputFormat ?? config.configOutputFormat ?? autoFormatFromExt ?? "svg";
|
|
939
|
-
|
|
940
|
-
// Validate format
|
|
941
|
-
if (effectiveFormat !== "svg" && effectiveFormat !== "png") {
|
|
942
|
-
throw new Error(`Unsupported output format: "${effectiveFormat}". Use "svg" or "png".`);
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
const effectiveScale = args.scale ?? config.configScale ?? 2;
|
|
946
|
-
const effectiveTheme = args.theme ?? config.configTheme;
|
|
947
|
-
|
|
948
|
-
// Read input
|
|
949
|
-
let code: string;
|
|
950
|
-
if (inputArg === "-") {
|
|
951
|
-
code = await readStdin();
|
|
952
|
-
} else {
|
|
953
|
-
const inputPath = resolve(inputArg);
|
|
954
|
-
try {
|
|
955
|
-
code = readFileSync(inputPath, "utf-8");
|
|
956
|
-
} catch {
|
|
957
|
-
throw new Error(`Cannot read input file: ${inputPath}`);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// Build render options
|
|
962
|
-
const renderOptions: RenderOptions = {};
|
|
963
|
-
if (effectiveTheme) {
|
|
964
|
-
renderOptions.theme = effectiveTheme as RenderOptions["theme"];
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
// Render SVG
|
|
968
|
-
let svg: string;
|
|
969
|
-
let svgWidth: number;
|
|
970
|
-
let svgHeight: number;
|
|
971
|
-
try {
|
|
972
|
-
const result = renderToSvg(code, renderOptions);
|
|
973
|
-
svg = result.svg;
|
|
974
|
-
svgWidth = result.width;
|
|
975
|
-
svgHeight = result.height;
|
|
976
|
-
} catch (err: any) {
|
|
977
|
-
throw new Error(`Failed to render diagram: ${err.message}`);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// Determine final output path (adjust extension if format is png and no explicit -o)
|
|
981
|
-
let finalOutputPath = outputPath;
|
|
982
|
-
if (effectiveFormat === "png" && finalOutputPath !== "-") {
|
|
983
|
-
if (!args.output || isDirectory(resolve(args.output!))) {
|
|
984
|
-
// Auto-generated or directory-based path: swap extension for .png
|
|
985
|
-
const ext = extname(finalOutputPath);
|
|
986
|
-
finalOutputPath = finalOutputPath.slice(0, -ext.length) + ".png";
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// Ensure output directory exists
|
|
991
|
-
if (finalOutputPath !== "-") {
|
|
992
|
-
const dir = dirname(finalOutputPath);
|
|
993
|
-
mkdirSync(dir, { recursive: true });
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Write output
|
|
997
|
-
if (effectiveFormat === "png") {
|
|
998
|
-
const pngBuffer = await rasterizeToPng(svg, svgWidth, svgHeight, effectiveScale);
|
|
999
|
-
if (finalOutputPath === "-") {
|
|
1000
|
-
process.stdout.write(pngBuffer);
|
|
1001
|
-
} else {
|
|
1002
|
-
try {
|
|
1003
|
-
writeFileSync(finalOutputPath, pngBuffer);
|
|
1004
|
-
if (!args.quiet) {
|
|
1005
|
-
process.stderr.write(`Wrote ${finalOutputPath}\n`);
|
|
1006
|
-
}
|
|
1007
|
-
} catch {
|
|
1008
|
-
throw new Error(`Cannot write output file: ${finalOutputPath}`);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
} else {
|
|
1012
|
-
// SVG output
|
|
1013
|
-
if (finalOutputPath === "-") {
|
|
1014
|
-
process.stdout.write(svg);
|
|
1015
|
-
} else {
|
|
1016
|
-
try {
|
|
1017
|
-
writeFileSync(finalOutputPath, svg, "utf-8");
|
|
1018
|
-
if (!args.quiet) {
|
|
1019
|
-
process.stderr.write(`Wrote ${finalOutputPath}\n`);
|
|
1020
|
-
}
|
|
1021
|
-
} catch {
|
|
1022
|
-
throw new Error(`Cannot write output file: ${finalOutputPath}`);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
function resolveOutput(input: string, output: string | undefined, defaultExt: string = ".svg"): string {
|
|
1029
|
-
if (output !== undefined) {
|
|
1030
|
-
if (output === "-") return "-";
|
|
1031
|
-
const resolvedOutput = resolve(output);
|
|
1032
|
-
if (isDirectory(resolvedOutput)) {
|
|
1033
|
-
// -o is a directory: output inside it using just the input's basename
|
|
1034
|
-
if (input === "-") return "-"; // stdin + directory doesn't make sense, fall to stdout
|
|
1035
|
-
const ext = extname(input);
|
|
1036
|
-
const base = basename(input, ext);
|
|
1037
|
-
return join(resolvedOutput, `${base}${defaultExt}`);
|
|
1038
|
-
}
|
|
1039
|
-
return resolvedOutput;
|
|
1040
|
-
}
|
|
1041
|
-
if (input === "-") return "-"; // stdin without -o → stdout
|
|
1042
|
-
// No -o: output adjacent to input with swapped extension
|
|
1043
|
-
const ext = extname(input);
|
|
1044
|
-
const base = basename(input, ext);
|
|
1045
|
-
const dir = dirname(resolve(input));
|
|
1046
|
-
return join(dir, `${base}${defaultExt}`);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
async function readStdin(): Promise<string> {
|
|
1050
|
-
const chunks: Uint8Array[] = [];
|
|
1051
|
-
for await (const chunk of Bun.stdin.stream()) {
|
|
1052
|
-
chunks.push(chunk as Uint8Array);
|
|
1053
|
-
}
|
|
1054
|
-
return Buffer.concat(chunks).toString("utf-8");
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// ---------------------------------------------------------------------------
|
|
1058
|
-
// Watch mode
|
|
1059
|
-
// ---------------------------------------------------------------------------
|
|
1060
|
-
|
|
1061
|
-
/** Return a timestamp string in [HH:MM:SS] format using local time. */
|
|
1062
|
-
function watchTimestamp(): string {
|
|
1063
|
-
const now = new Date();
|
|
1064
|
-
const hh = String(now.getHours()).padStart(2, "0");
|
|
1065
|
-
const mm = String(now.getMinutes()).padStart(2, "0");
|
|
1066
|
-
const ss = String(now.getSeconds()).padStart(2, "0");
|
|
1067
|
-
return `[${hh}:${mm}:${ss}]`;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
export interface WatchHandle {
|
|
1071
|
-
shutdown: () => void;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Start watch mode.
|
|
1076
|
-
*
|
|
1077
|
-
* @param inputs Resolved file paths to watch and render.
|
|
1078
|
-
* @param renderFn Called with a file path to re-render it. Must return a Promise.
|
|
1079
|
-
* @param watchFn Factory for a watcher. Defaults to node:fs.watch. Must return { close() }.
|
|
1080
|
-
* @param log Log function for render messages. Defaults to process.stderr.write.
|
|
1081
|
-
* @param delayMs Debounce delay in milliseconds. Defaults to 100.
|
|
1082
|
-
* @param renderMdFn Optional alternative render function for .md files. When provided,
|
|
1083
|
-
* .md inputs are rendered with this function instead of renderFn.
|
|
1084
|
-
*/
|
|
1085
|
-
export async function startWatchMode(
|
|
1086
|
-
inputs: string[],
|
|
1087
|
-
renderFn: (path: string) => Promise<void>,
|
|
1088
|
-
watchFn?: (path: string, handler: () => void) => { close(): void },
|
|
1089
|
-
log?: (msg: string) => void,
|
|
1090
|
-
delayMs?: number,
|
|
1091
|
-
renderMdFn?: (path: string) => Promise<void>,
|
|
1092
|
-
): Promise<WatchHandle> {
|
|
1093
|
-
const logFn = log ?? ((msg: string) => process.stderr.write(msg + "\n"));
|
|
1094
|
-
const delay = delayMs ?? 100;
|
|
1095
|
-
|
|
1096
|
-
// Default watcher using node:fs.watch
|
|
1097
|
-
const watchFactory = watchFn ?? ((path: string, handler: () => void) => {
|
|
1098
|
-
const watcher = fsWatch(path, () => handler());
|
|
1099
|
-
return { close: () => watcher.close() };
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
// Pick which render function to use for a given file
|
|
1103
|
-
function pickRender(p: string): (path: string) => Promise<void> {
|
|
1104
|
-
if (renderMdFn && /\.(?:md|markdown)$/i.test(p)) {
|
|
1105
|
-
return renderMdFn;
|
|
1106
|
-
}
|
|
1107
|
-
return renderFn;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
// Perform initial render for all inputs
|
|
1111
|
-
for (const input of inputs) {
|
|
1112
|
-
const rf = pickRender(input);
|
|
1113
|
-
const ts = watchTimestamp();
|
|
1114
|
-
try {
|
|
1115
|
-
await rf(input);
|
|
1116
|
-
logFn(`${ts} Rendered ${input} -> done`);
|
|
1117
|
-
} catch (err: any) {
|
|
1118
|
-
logFn(`${ts} Error: ${input}: ${err.message}`);
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
// Set up per-file debounced watchers
|
|
1123
|
-
const handles: Array<{ close(): void }> = [];
|
|
1124
|
-
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1125
|
-
|
|
1126
|
-
for (const input of inputs) {
|
|
1127
|
-
const rf = pickRender(input);
|
|
1128
|
-
const handle = watchFactory(input, () => {
|
|
1129
|
-
// Debounce: clear any pending timer for this file
|
|
1130
|
-
const existing = timers.get(input);
|
|
1131
|
-
if (existing !== undefined) clearTimeout(existing);
|
|
1132
|
-
const t = setTimeout(async () => {
|
|
1133
|
-
timers.delete(input);
|
|
1134
|
-
const ts = watchTimestamp();
|
|
1135
|
-
try {
|
|
1136
|
-
await rf(input);
|
|
1137
|
-
logFn(`${ts} Rendered ${input} -> done`);
|
|
1138
|
-
} catch (err: any) {
|
|
1139
|
-
logFn(`${ts} Error: ${input}: ${err.message}`);
|
|
1140
|
-
}
|
|
1141
|
-
}, delay);
|
|
1142
|
-
timers.set(input, t);
|
|
1143
|
-
});
|
|
1144
|
-
handles.push(handle);
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
function shutdown(): void {
|
|
1148
|
-
// Cancel pending debounce timers
|
|
1149
|
-
for (const t of timers.values()) {
|
|
1150
|
-
clearTimeout(t);
|
|
1151
|
-
}
|
|
1152
|
-
timers.clear();
|
|
1153
|
-
// Close all watchers
|
|
1154
|
-
for (const h of handles) {
|
|
1155
|
-
h.close();
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
return { shutdown };
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
if (import.meta.main) {
|
|
1163
|
-
main().finally(() => closeBrowser());
|
|
1164
|
-
}
|