@zenuml/core 3.47.8 → 3.48.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.
Files changed (204) hide show
  1. package/dist/cloud-icons-eHuugVSv.js.map +1 -0
  2. package/dist/zenuml.esm.mjs +2153 -2156
  3. package/dist/zenuml.esm.mjs.map +1 -0
  4. package/dist/zenuml.js +82 -82
  5. package/dist/zenuml.js.map +1 -0
  6. package/package.json +11 -1
  7. package/src/cli/zenuml.ts +1164 -0
  8. package/.agents/skills/babysit-pr/SKILL.md +0 -223
  9. package/.agents/skills/babysit-pr/agents/openai.yaml +0 -7
  10. package/.agents/skills/dia-scoring/SKILL.md +0 -139
  11. package/.agents/skills/dia-scoring/agents/openai.yaml +0 -7
  12. package/.agents/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  13. package/.agents/skills/land-pr/SKILL.md +0 -120
  14. package/.agents/skills/propagate-core-release/SKILL.md +0 -205
  15. package/.agents/skills/propagate-core-release/agents/openai.yaml +0 -7
  16. package/.agents/skills/propagate-core-release/references/downstreams.md +0 -42
  17. package/.agents/skills/ship-branch/SKILL.md +0 -105
  18. package/.agents/skills/submit-branch/SKILL.md +0 -76
  19. package/.agents/skills/validate-branch/SKILL.md +0 -72
  20. package/.claude/commands/README.md +0 -162
  21. package/.claude/commands/analyze.md +0 -101
  22. package/.claude/commands/clarify.md +0 -158
  23. package/.claude/commands/code-review.md +0 -322
  24. package/.claude/commands/constitution.md +0 -73
  25. package/.claude/commands/create-docs.md +0 -309
  26. package/.claude/commands/full-context.md +0 -121
  27. package/.claude/commands/gemini-consult.md +0 -164
  28. package/.claude/commands/handoff.md +0 -146
  29. package/.claude/commands/implement.md +0 -56
  30. package/.claude/commands/plan.md +0 -43
  31. package/.claude/commands/refactor.md +0 -188
  32. package/.claude/commands/specify.md +0 -21
  33. package/.claude/commands/tasks.md +0 -62
  34. package/.claude/commands/update-docs.md +0 -314
  35. package/.claude/hooks/README.md +0 -270
  36. package/.claude/hooks/config/sensitive-patterns.json +0 -86
  37. package/.claude/hooks/gemini-context-injector.sh +0 -129
  38. package/.claude/hooks/mcp-security-scan.sh +0 -147
  39. package/.claude/hooks/notify.sh +0 -103
  40. package/.claude/hooks/setup/hook-setup.md +0 -96
  41. package/.claude/hooks/setup/settings.json.template +0 -63
  42. package/.claude/hooks/sounds/complete.wav +0 -0
  43. package/.claude/hooks/sounds/input-needed.wav +0 -0
  44. package/.claude/hooks/subagent-context-injector.sh +0 -65
  45. package/.claude/skills/babysit-pr/SKILL.md +0 -223
  46. package/.claude/skills/babysit-pr/agents/openai.yaml +0 -7
  47. package/.claude/skills/dia-scoring/SKILL.md +0 -139
  48. package/.claude/skills/dia-scoring/agents/openai.yaml +0 -7
  49. package/.claude/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  50. package/.claude/skills/emoji-eval/SKILL.md +0 -187
  51. package/.claude/skills/land-pr/SKILL.md +0 -120
  52. package/.claude/skills/propagate-core-release/SKILL.md +0 -205
  53. package/.claude/skills/propagate-core-release/agents/openai.yaml +0 -7
  54. package/.claude/skills/propagate-core-release/references/downstreams.md +0 -42
  55. package/.claude/skills/ship-branch/SKILL.md +0 -105
  56. package/.claude/skills/submit-branch/SKILL.md +0 -76
  57. package/.claude/skills/validate-branch/SKILL.md +0 -72
  58. package/.claude/skills/zenuml-ux-research/SKILL.md +0 -183
  59. package/.claude/skills/zenuml-ux-research/references/assertion-catalog.md +0 -261
  60. package/.claude/skills/zenuml-ux-research/references/best-practices-overview.md +0 -56
  61. package/.claude/skills/zenuml-ux-research/references/report-template.md +0 -89
  62. package/.claude/skills/zenuml-ux-research/references/scenarios/edit-message-label.md +0 -37
  63. package/.claude/skills/zenuml-ux-research/references/scenarios/insert-message.md +0 -36
  64. package/.claude/skills/zenuml-ux-research/references/scenarios/insert-participant.md +0 -31
  65. package/.claude/skills/zenuml-ux-research/references/scenarios/rename-participant.md +0 -33
  66. package/.claude/skills/zenuml-ux-research/references/scenarios/undo-insert.md +0 -35
  67. package/.devcontainer/devcontainer.json +0 -21
  68. package/.dockerignore +0 -19
  69. package/.eslintrc.js +0 -39
  70. package/.git-blame-ignore-revs +0 -6
  71. package/.kiro/hooks/README.md +0 -38
  72. package/.kiro/hooks/session-sound-notification.js +0 -44
  73. package/.kiro/hooks/session-sound-notification.json +0 -23
  74. package/.mcp.json.example +0 -17
  75. package/.nvmrc +0 -1
  76. package/.prettierignore +0 -4
  77. package/.prettierrc +0 -1
  78. package/.specify/memory/constitution.md +0 -33
  79. package/.specify/scripts/bash/check-prerequisites.sh +0 -166
  80. package/.specify/scripts/bash/common.sh +0 -113
  81. package/.specify/scripts/bash/create-new-feature.sh +0 -97
  82. package/.specify/scripts/bash/setup-plan.sh +0 -60
  83. package/.specify/scripts/bash/update-agent-context.sh +0 -728
  84. package/.specify/templates/agent-file-template.md +0 -23
  85. package/.specify/templates/plan-template.md +0 -219
  86. package/.specify/templates/spec-template.md +0 -116
  87. package/.specify/templates/tasks-template.md +0 -127
  88. package/.storybook/main.ts +0 -25
  89. package/.storybook/preview.ts +0 -29
  90. package/.watchmanconfig +0 -3
  91. package/AGENTS.md +0 -26
  92. package/CLAUDE.md +0 -124
  93. package/DEPLOYMENT.md +0 -62
  94. package/Dockerfile +0 -36
  95. package/IMPLEMENTATION_PLAN.md +0 -163
  96. package/Integration/vanilla-js/index.html +0 -42
  97. package/MCP-ASSISTANT-RULES.md +0 -85
  98. package/README_CN.md +0 -15
  99. package/TUTORIAL.md +0 -116
  100. package/antlr/antlr-4.11.1-complete.jar +0 -0
  101. package/bun.lock +0 -1544
  102. package/bunfig.toml +0 -52
  103. package/docs/UNICODE_SUPPORT.md +0 -179
  104. package/docs/ai-context/deployment-infrastructure.md +0 -21
  105. package/docs/ai-context/docs-overview.md +0 -89
  106. package/docs/ai-context/handoff.md +0 -174
  107. package/docs/ai-context/project-structure.md +0 -160
  108. package/docs/ai-context/system-integration.md +0 -21
  109. package/docs/asciidoc/contributor.adoc +0 -54
  110. package/docs/asciidoc/create-my-own-theme.adoc +0 -149
  111. package/docs/asciidoc/images/creation-component.png +0 -0
  112. package/docs/asciidoc/images/creation-rtl.png +0 -0
  113. package/docs/asciidoc/images/message-arrow-rtl.png +0 -0
  114. package/docs/asciidoc/images/occurrence.png +0 -0
  115. package/docs/asciidoc/images/return-message-conflict.png +0 -0
  116. package/docs/asciidoc/images/shift-up-half-the-height.png +0 -0
  117. package/docs/asciidoc/images/three-layer-info-arch.png +0 -0
  118. package/docs/asciidoc/images/vertical-alignment.svg +0 -1
  119. package/docs/asciidoc/images/vertically-aligning.png +0 -0
  120. package/docs/asciidoc/index.adoc +0 -277
  121. package/docs/asciidoc/theme-debug-web-app.png +0 -0
  122. package/docs/asciidoc/tutorial.adoc +0 -22
  123. package/docs/asciidoc/user-css.png +0 -0
  124. package/docs/async-vs-sync-parser-rules.md +0 -81
  125. package/docs/divider-parser-allow-spaces.md +0 -38
  126. package/docs/highlighting-messages.md +0 -52
  127. package/docs/images/editor-sample.png +0 -0
  128. package/docs/inherited-vs-provided-from.md +0 -64
  129. package/docs/parser/Assignment.md +0 -8
  130. package/docs/parser/PARSER_IMPROVEMENTS_CC.md +0 -425
  131. package/docs/parser/grammar_review_gemini.md +0 -116
  132. package/docs/participants-function.md +0 -25
  133. package/docs/responsive-participant-margin.md +0 -52
  134. package/docs/starter.md +0 -9
  135. package/docs/superpowers/plans/2026-03-27-e2e-test-reorg.md +0 -698
  136. package/docs/superpowers/plans/2026-03-30-emoji-support.md +0 -1220
  137. package/docs/superpowers/plans/2026-03-30-self-correcting-scoring.md +0 -206
  138. package/docs/superpowers/plans/2026-04-15-keyboard-editing-on-diagram.md +0 -1992
  139. package/docs/superpowers/plans/2026-04-15-zenuml-ux-research-skill.md +0 -1452
  140. package/docs/ux-research/.gitkeep +0 -0
  141. package/docs/ux-research/2026-04-15-rename-participant.md +0 -156
  142. package/docs/ux-research/2026-04-18-insert-participant.md +0 -151
  143. package/docs/width-translate-and-offsets.md +0 -62
  144. package/docs/xss.md +0 -59
  145. package/e2e/data/compare-cases.js +0 -1090
  146. package/e2e/data/diff-algorithm.js +0 -199
  147. package/e2e/fixtures/create-message.html +0 -26
  148. package/e2e/fixtures/editable-label.html +0 -35
  149. package/e2e/fixtures/editable-span.html +0 -122
  150. package/e2e/fixtures/empty-diagram.html +0 -23
  151. package/e2e/fixtures/fixture.html +0 -31
  152. package/e2e/fixtures/insert-participant.html +0 -23
  153. package/e2e/fixtures/reorder-cross-fragment.html +0 -31
  154. package/e2e/fixtures/reorder-fragment.html +0 -29
  155. package/e2e/fixtures/reorder-message.html +0 -27
  156. package/e2e/fixtures/svg-test.html +0 -21
  157. package/e2e/fixtures/type-switch.html +0 -29
  158. package/e2e/tools/canonical-history.html +0 -908
  159. package/e2e/tools/compare-case.html +0 -371
  160. package/e2e/tools/compare.html +0 -35
  161. package/e2e/tools/native-diff-ext/background.js +0 -60
  162. package/e2e/tools/native-diff-ext/bridge.js +0 -26
  163. package/e2e/tools/native-diff-ext/content.js +0 -194
  164. package/e2e/tools/svg-preview.html +0 -56
  165. package/embed.html +0 -193
  166. package/eslint.config.mjs +0 -35
  167. package/firebase-debug.log +0 -108
  168. package/iframe-container-demo/diagram.html +0 -124
  169. package/iframe-container-demo/host.html +0 -817
  170. package/index.html +0 -771
  171. package/mermaid-zenuml-async-spa-auth.png +0 -0
  172. package/mermaid-zenuml-async-spa-auth.snapshot.md +0 -96
  173. package/newsletter/unicode-support-announcement.md +0 -134
  174. package/playground/creation.html +0 -53
  175. package/playground/message.html +0 -63
  176. package/playwright.config.ts +0 -40
  177. package/renderer.html +0 -366
  178. package/scripts/analyze-compare-case/collect-data.mjs +0 -1134
  179. package/scripts/analyze-compare-case/config.mjs +0 -102
  180. package/scripts/analyze-compare-case/geometry.mjs +0 -101
  181. package/scripts/analyze-compare-case/native-diff.mjs +0 -224
  182. package/scripts/analyze-compare-case/output.mjs +0 -74
  183. package/scripts/analyze-compare-case/panel-diff.mjs +0 -114
  184. package/scripts/analyze-compare-case/report.mjs +0 -162
  185. package/scripts/analyze-compare-case/residual-scopes.mjs +0 -347
  186. package/scripts/analyze-compare-case/scoring.mjs +0 -829
  187. package/scripts/analyze-compare-case.mjs +0 -149
  188. package/scripts/bump-version.js +0 -117
  189. package/scripts/snapshot-dual.js +0 -173
  190. package/scripts/update-snapshots.js +0 -70
  191. package/skills/dia-scoring/SKILL.md +0 -129
  192. package/skills/dia-scoring/agents/openai.yaml +0 -7
  193. package/skills/dia-scoring/references/selectors-and-keys.md +0 -253
  194. package/tailwind.config.js +0 -126
  195. package/test-compression.html +0 -274
  196. package/test-mermaid-zenuml.html +0 -57
  197. package/test-setup.ts +0 -124
  198. package/test-url-params.html +0 -192
  199. package/tsconfig.app.json +0 -31
  200. package/tsconfig.node.json +0 -24
  201. package/tsconfig.test.json +0 -9
  202. package/vite.config.lib.ts +0 -93
  203. package/vite.config.ts +0 -84
  204. package/wrangler.toml +0 -18
@@ -0,0 +1,1164 @@
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 = `![${altText}](${imageFilename})`;
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 = `![${altText}](${imageFilename})`;
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
+ }