@hardikg/mdview 0.7.1 → 0.9.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 (100) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +76 -9
  3. package/bin/mdview.mjs +986 -258
  4. package/dist/client/assets/abnfDiagram-VCTEODGH-d5-zUW3h.js +1 -0
  5. package/dist/client/assets/{arc-DYMLyL-_.js → arc-DIbSNhHb.js} +1 -1
  6. package/dist/client/assets/architectureDiagram-5GKGNRK7-Cq_KBCV9.js +36 -0
  7. package/dist/client/assets/blockDiagram-NRAW4CY4-CKgrJGzB.js +129 -0
  8. package/dist/client/assets/c4Diagram-UCG6FXSJ-CamsXU_i.js +38 -0
  9. package/dist/client/assets/channel-MJp4QxQY.js +1 -0
  10. package/dist/client/assets/{chunk-QZHKN3VN-Cd7CcXLC.js → chunk-2Q5K7J3B-DHB85OBA.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-BYGwnUQ4.js → chunk-5VM5RSS4-C3v07bfZ.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-TheCoD51.js → chunk-F27PBJKO-bDA5q_T9.js} +1 -1
  13. package/dist/client/assets/chunk-G27WJ6UU-DjS6mkGt.js +231 -0
  14. package/dist/client/assets/{chunk-4BX2VUAB-By-ok8D7.js → chunk-JWPE2WC7-ln94eTAD.js} +1 -1
  15. package/dist/client/assets/{chunk-727SXJPM-Bj1JFNtT.js → chunk-LCL6LL3I-SEXOnCPG.js} +4 -4
  16. package/dist/client/assets/chunk-POPQ4Y6H-Dpgmgmgq.js +1 -0
  17. package/dist/client/assets/chunk-SVP7TREG-C_qgSQCy.js +88 -0
  18. package/dist/client/assets/{chunk-55IACEB6-BDUDnCl9.js → chunk-XXDRQBXY-CguISEfl.js} +1 -1
  19. package/dist/client/assets/classDiagram-DTDB5LWJ-BlbBfPMZ.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-JRS7N3AN-BlbBfPMZ.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-S5V4N54A-Br-CySzr.js → cose-bilkent-JH36ORCC-CjtLn09K.js} +1 -1
  22. package/dist/client/assets/cynefin-OW5HDTMX-BgNmTgCR.js +178 -0
  23. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-BFfuQ7YY.js +62 -0
  24. package/dist/client/assets/cytoscape.esm-BjAbmLdb.js +331 -0
  25. package/dist/client/assets/dagre-3AP2YEHR-BDLHOy1Z.js +4 -0
  26. package/dist/client/assets/diagram-S7CK7UJ4-CfzUQiTU.js +30 -0
  27. package/dist/client/assets/diagram-UQ7AKVKN-Ds3lTKuY.js +41 -0
  28. package/dist/client/assets/{diagram-KO2AKTUF-mwoLS2TK.js → diagram-VSXAHHWV-lwAuvia3.js} +3 -3
  29. package/dist/client/assets/diagram-VX7I27RA-CsU4EPwU.js +24 -0
  30. package/dist/client/assets/{diagram-LMA3HP47-4o7ohGjJ.js → diagram-Z3DM3KII-DFieIgnM.js} +2 -2
  31. package/dist/client/assets/ebnfDiagram-PWID7BFC-BzR_CBzj.js +1 -0
  32. package/dist/client/assets/erDiagram-SSCWMZ5O-joYlyDrM.js +99 -0
  33. package/dist/client/assets/flowDiagram-A5DVABFB-DVyMxsTJ.js +168 -0
  34. package/dist/client/assets/ganttDiagram-EL5Y4UJY-Cf4FwZsI.js +292 -0
  35. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-eN40bRnz.js → gitGraphDiagram-WWUBYQGX-9grceorS.js} +7 -7
  36. package/dist/client/assets/index-7zMmn_PY.js +2 -0
  37. package/dist/client/assets/index-CcKPW3Oc.css +1 -0
  38. package/dist/client/assets/infoDiagram-RXCK75RN-ja-7F4cO.js +2 -0
  39. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-BDIzsJJZ.js → ishikawaDiagram-5VMMS53U-uRRSMmsi.js} +5 -5
  40. package/dist/client/assets/{journeyDiagram-JHISSGLW-BfWwpj1w.js → journeyDiagram-EYS64GPL-BdD_OYKc.js} +1 -1
  41. package/dist/client/assets/{kanban-definition-UN3LZRKU-Cuij1uoR.js → kanban-definition-3QL26DDD-3rMqnFFk.js} +8 -8
  42. package/dist/client/assets/katex-C5jXJg4s.js +257 -0
  43. package/dist/client/assets/katex.min-BYZTj3zW.css +1 -0
  44. package/dist/client/assets/katex.min-DXaO1tdm.js +1 -0
  45. package/dist/client/assets/layout-3dyJkKek.js +1 -0
  46. package/dist/client/assets/{linear-CZOgGL8m.js → linear-B49R1aTA.js} +1 -1
  47. package/dist/client/assets/mermaid.core-CVM43n3z.js +312 -0
  48. package/dist/client/assets/{mindmap-definition-RKZ34NQL-BbNc5LRE.js → mindmap-definition-FBJOCRG2-B8QesUuH.js} +1 -1
  49. package/dist/client/assets/pegDiagram-XKGWAZYB-BeO8X3By.js +1 -0
  50. package/dist/client/assets/pieDiagram-E7YTZNPT-DD3M93Je.js +39 -0
  51. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-CmJGRE1L.js → quadrantDiagram-AXDQQJYC-LxJ8Qxzg.js} +3 -3
  52. package/dist/client/assets/railroadDiagram-O6MQD6OU-Cc6-wuTB.js +1 -0
  53. package/dist/client/assets/{requirementDiagram-4Y6WPE33-Dp-nBLhC.js → requirementDiagram-EFPCY7ZU-D-vaSng7.js} +5 -5
  54. package/dist/client/assets/sankeyDiagram-P5KCCOFB-C-SrB4wj.js +40 -0
  55. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BpUTXTa1.js +162 -0
  56. package/dist/client/assets/sizeCapture-X5ZJPWSS-BG1rRkMg.js +1 -0
  57. package/dist/client/assets/stateDiagram-HBIQ2CUA-CAsJ9kP3.js +1 -0
  58. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-ssPfmTD6.js +1 -0
  59. package/dist/client/assets/swimlanes-XN3QIQJK-DH5txxCi.js +1 -0
  60. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-Cqi1auey.js +8 -0
  61. package/dist/client/assets/{timeline-definition-PNZ67QCA-uVQ-oiv8.js → timeline-definition-24CTP7MA-DPSR_k-5.js} +3 -3
  62. package/dist/client/assets/vennDiagram-4TSXK5OY-CRMSJqLq.js +34 -0
  63. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-BQMj40Ao.js → wardleyDiagram-VM6X3IG4-DRCnYQxB.js} +4 -4
  64. package/dist/client/assets/xychartDiagram-S5SC5T6Z-x5N3gqvJ.js +7 -0
  65. package/dist/client/bootstrap.js +13 -2
  66. package/dist/client/index.html +2 -2
  67. package/package.json +3 -3
  68. package/dist/client/assets/architectureDiagram-3BPJPVTR-iXijJkGs.js +0 -36
  69. package/dist/client/assets/blockDiagram-GPEHLZMM-Dmjj2Ajf.js +0 -132
  70. package/dist/client/assets/c4Diagram-AAUBKEIU-kJb6eQJJ.js +0 -10
  71. package/dist/client/assets/channel-DirF0LQF.js +0 -1
  72. package/dist/client/assets/chunk-2J33WTMH-T3vdO-20.js +0 -1
  73. package/dist/client/assets/chunk-AQP2D5EJ-BPZWXvxS.js +0 -231
  74. package/dist/client/assets/classDiagram-4FO5ZUOK-CdoMz3xi.js +0 -1
  75. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CdoMz3xi.js +0 -1
  76. package/dist/client/assets/cytoscape.esm-D_LviqZs.js +0 -331
  77. package/dist/client/assets/dagre-BM42HDAG-DcIxclFu.js +0 -4
  78. package/dist/client/assets/diagram-2AECGRRQ-C0xPdtMR.js +0 -43
  79. package/dist/client/assets/diagram-5GNKFQAL-B9i7DtoQ.js +0 -10
  80. package/dist/client/assets/diagram-OG6HWLK6-C6rhUBe2.js +0 -24
  81. package/dist/client/assets/erDiagram-TEJ5UH35-smNcu7Lp.js +0 -85
  82. package/dist/client/assets/flowDiagram-I6XJVG4X-CPuYTxJr.js +0 -162
  83. package/dist/client/assets/ganttDiagram-6RSMTGT7-xaV0Djok.js +0 -292
  84. package/dist/client/assets/graph-CAnANduQ.js +0 -1
  85. package/dist/client/assets/index-Cmcberpw.css +0 -1
  86. package/dist/client/assets/index-OFdfWE8x.js +0 -2
  87. package/dist/client/assets/infoDiagram-5YYISTIA-xm1BEpfJ.js +0 -2
  88. package/dist/client/assets/katex-DkKDou_j.js +0 -257
  89. package/dist/client/assets/katex.min-CfVKi3_s.css +0 -1
  90. package/dist/client/assets/katex.min-DBnXG_UH.js +0 -1
  91. package/dist/client/assets/layout-DGIYPm2g.js +0 -1
  92. package/dist/client/assets/mermaid.core-B1i-ZGkv.js +0 -309
  93. package/dist/client/assets/pieDiagram-4H26LBE5-oB2YhWwR.js +0 -30
  94. package/dist/client/assets/sankeyDiagram-5OEKKPKP-DB5dqk7n.js +0 -40
  95. package/dist/client/assets/sequenceDiagram-3UESZ5HK-jmqrpzCx.js +0 -162
  96. package/dist/client/assets/stateDiagram-AJRCARHV-U6m2A9Wk.js +0 -1
  97. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-00c0MW6J.js +0 -1
  98. package/dist/client/assets/vennDiagram-CIIHVFJN-By-tXl41.js +0 -34
  99. package/dist/client/assets/wardley-L42UT6IY-ByB1C5d8.js +0 -173
  100. package/dist/client/assets/xychartDiagram-2RQKCTM6-7czjL7-G.js +0 -7
package/bin/mdview.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync, readFileSync, realpathSync, statSync } from "fs";
5
- import path11 from "path";
4
+ import dns from "dns/promises";
5
+ import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync4 } from "fs";
6
+ import path14 from "path";
6
7
  import { fileURLToPath } from "url";
8
+ import chokidar3 from "chokidar";
7
9
  import openBrowser from "open";
8
10
 
9
11
  // src/server/config.ts
@@ -12,7 +14,17 @@ import os from "os";
12
14
  import path from "path";
13
15
 
14
16
  // src/shared/types.ts
15
- var PALETTES = ["classic", "paper", "nord", "solarized", "high-contrast"];
17
+ var DEFAULT_PALETTE = "flexoki";
18
+ var PALETTES = [
19
+ "flexoki",
20
+ "paper",
21
+ "solarized",
22
+ "everforest",
23
+ "rose-pine",
24
+ "kanagawa",
25
+ "catppuccin",
26
+ "high-contrast"
27
+ ];
16
28
 
17
29
  // src/server/config.ts
18
30
  var CONFIG_FILENAME = ".mdview.json";
@@ -329,13 +341,430 @@ async function runIgnoreSubcommand(argv) {
329
341
  }
330
342
  }
331
343
 
344
+ // src/daemon/client.ts
345
+ import { spawn } from "child_process";
346
+ import { closeSync, openSync as openSync2, statSync as statSync2, truncateSync } from "fs";
347
+
348
+ // src/daemon/state.ts
349
+ import { mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
350
+ import path3 from "path";
351
+ function stateDir() {
352
+ return path3.dirname(globalConfigPath());
353
+ }
354
+ function daemonStatePath() {
355
+ return path3.join(stateDir(), "daemon.json");
356
+ }
357
+ function workspaceFilePath() {
358
+ return path3.join(stateDir(), "workspace.json");
359
+ }
360
+ function daemonLogPath() {
361
+ return path3.join(stateDir(), "daemon.log");
362
+ }
363
+ function daemonLockPath() {
364
+ return path3.join(stateDir(), "daemon.lock");
365
+ }
366
+ function writeJsonAtomic(file, value) {
367
+ mkdirSync(path3.dirname(file), { recursive: true, mode: 448 });
368
+ const tmp = `${file}.${process.pid}.tmp`;
369
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
370
+ `, { mode: 384 });
371
+ renameSync(tmp, file);
372
+ }
373
+ function readJsonSafe(file) {
374
+ try {
375
+ return JSON.parse(readFileSync(file, "utf8"));
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+ function isPositiveInt(v) {
381
+ return typeof v === "number" && Number.isInteger(v) && v > 0;
382
+ }
383
+ function readDaemonState() {
384
+ const raw = readJsonSafe(daemonStatePath());
385
+ if (!raw || typeof raw !== "object") return null;
386
+ const { pid, port, startedAt } = raw;
387
+ if (!isPositiveInt(pid) || !isPositiveInt(port) || !isPositiveInt(startedAt)) return null;
388
+ if (port > 65535) return null;
389
+ return { pid, port, startedAt };
390
+ }
391
+ function writeDaemonState(state) {
392
+ writeJsonAtomic(daemonStatePath(), state);
393
+ }
394
+ function clearDaemonState() {
395
+ rmSync(daemonStatePath(), { force: true });
396
+ }
397
+ function tryTakeSpawnLock(staleAfterMs = 1e4) {
398
+ mkdirSync(stateDir(), { recursive: true, mode: 448 });
399
+ const file = daemonLockPath();
400
+ try {
401
+ openSync(file, "wx");
402
+ return true;
403
+ } catch (err) {
404
+ if (err.code !== "EEXIST") throw err;
405
+ }
406
+ try {
407
+ if (Date.now() - statSync(file).mtimeMs < staleAfterMs) return false;
408
+ rmSync(file, { force: true });
409
+ openSync(file, "wx");
410
+ return true;
411
+ } catch {
412
+ return false;
413
+ }
414
+ }
415
+ function releaseSpawnLock() {
416
+ rmSync(daemonLockPath(), { force: true });
417
+ }
418
+
419
+ // src/daemon/client.ts
420
+ var HEALTH_TIMEOUT_MS = 500;
421
+ var START_TIMEOUT_MS = 8e3;
422
+ var POLL_INTERVAL_MS = 50;
423
+ var LOG_TRUNCATE_BYTES = 1024 * 1024;
424
+ function sleep(ms) {
425
+ return new Promise((resolve) => setTimeout(resolve, ms));
426
+ }
427
+ async function probeHealth(port) {
428
+ try {
429
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
430
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
431
+ });
432
+ if (!res.ok) return null;
433
+ const body = await res.json();
434
+ if (typeof body.pid !== "number" || typeof body.startedAt !== "number") return null;
435
+ return {
436
+ pid: body.pid,
437
+ startedAt: body.startedAt,
438
+ roots: Array.isArray(body.roots) ? body.roots.filter((r) => typeof r === "string") : []
439
+ };
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+ async function findLiveDaemon() {
445
+ const state = readDaemonState();
446
+ if (!state) return null;
447
+ const health = await probeHealth(state.port);
448
+ if (!health) return null;
449
+ if (health.pid !== state.pid || health.startedAt !== state.startedAt) return null;
450
+ return { state, health };
451
+ }
452
+ function spawnDaemon(cliEntry) {
453
+ try {
454
+ if (statSync2(daemonLogPath()).size > LOG_TRUNCATE_BYTES) {
455
+ truncateSync(daemonLogPath(), 0);
456
+ }
457
+ } catch {
458
+ }
459
+ const log = openSync2(daemonLogPath(), "a");
460
+ try {
461
+ const child = spawn(process.execPath, [cliEntry, "--daemon"], {
462
+ detached: true,
463
+ // Nothing may hold the terminal: stdout and stderr go to the log so the
464
+ // parent can exit while the daemon keeps running.
465
+ stdio: ["ignore", log, log]
466
+ });
467
+ child.unref();
468
+ return child;
469
+ } finally {
470
+ closeSync(log);
471
+ }
472
+ }
473
+ async function ensureDaemon(cliEntry) {
474
+ const existing = await findLiveDaemon();
475
+ if (existing) return existing.state;
476
+ if (!tryTakeSpawnLock()) {
477
+ const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
478
+ if (found) return found.state;
479
+ throw new Error("Timed out waiting for the mdview background server to start.");
480
+ }
481
+ try {
482
+ clearDaemonState();
483
+ spawnDaemon(cliEntry);
484
+ const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
485
+ if (!found) {
486
+ throw new Error(
487
+ `The mdview background server did not start. See ${daemonLogPath()} for why.`
488
+ );
489
+ }
490
+ return found.state;
491
+ } finally {
492
+ releaseSpawnLock();
493
+ }
494
+ }
495
+ async function waitFor(attempt, timeoutMs) {
496
+ const deadline = Date.now() + timeoutMs;
497
+ for (; ; ) {
498
+ const result = await attempt();
499
+ if (result) return result;
500
+ if (Date.now() >= deadline) return null;
501
+ await sleep(POLL_INTERVAL_MS);
502
+ }
503
+ }
504
+ async function waitForRoot(port, rootId, timeoutMs = 3e3) {
505
+ const found = await waitFor(async () => {
506
+ const health = await probeHealth(port);
507
+ return health?.roots.includes(rootId) ? health : null;
508
+ }, timeoutMs);
509
+ return found !== null;
510
+ }
511
+ async function stopDaemon() {
512
+ const state = readDaemonState();
513
+ if (!state) return { kind: "not-running" };
514
+ const live = await findLiveDaemon();
515
+ if (!live) {
516
+ clearDaemonState();
517
+ return { kind: "stale" };
518
+ }
519
+ try {
520
+ if (process.platform === "win32") {
521
+ process.kill(live.state.pid);
522
+ } else {
523
+ process.kill(live.state.pid, "SIGTERM");
524
+ }
525
+ } catch {
526
+ clearDaemonState();
527
+ return { kind: "stale" };
528
+ }
529
+ await waitFor(async () => await probeHealth(live.state.port) === null ? true : null, 3e3);
530
+ clearDaemonState();
531
+ return { kind: "stopped", pid: live.state.pid };
532
+ }
533
+
534
+ // src/daemon/workspace-file.ts
535
+ import { existsSync, statSync as statSync3 } from "fs";
536
+ import path5 from "path";
537
+
538
+ // src/server/workspace.ts
539
+ import path4 from "path";
540
+ var ROOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
541
+ function isValidRootId(id) {
542
+ return id !== "." && id !== ".." && ROOT_ID_PATTERN.test(id);
543
+ }
544
+ function deriveRootId(absPath, taken) {
545
+ const slug = path4.basename(absPath).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-.]+$/, "").slice(0, 64);
546
+ const stem = isValidRootId(slug) ? slug : "root";
547
+ if (!taken.has(stem)) return stem;
548
+ for (let n = 2; ; n++) {
549
+ const candidate = `${stem.slice(0, 60)}-${n}`;
550
+ if (!taken.has(candidate)) return candidate;
551
+ }
552
+ }
553
+ function resolveRootId(spec, taken) {
554
+ if (spec.id && isValidRootId(spec.id) && !taken.has(spec.id)) return spec.id;
555
+ return deriveRootId(spec.absPath, taken);
556
+ }
557
+ function describeRoot(spec, id) {
558
+ return {
559
+ id,
560
+ name: spec.name ?? path4.basename(spec.absPath),
561
+ kind: spec.kind,
562
+ filePath: spec.kind === "file" ? spec.filePath ?? "" : ""
563
+ };
564
+ }
565
+ function parseWorkspacePath(roots, wsPath) {
566
+ const primary = roots[0];
567
+ if (!primary) return null;
568
+ const trimmed = wsPath.replace(/^\/+/, "");
569
+ const slash = trimmed.indexOf("/");
570
+ const head = slash === -1 ? trimmed : trimmed.slice(0, slash);
571
+ const match = roots.find((r) => r.root.id === head);
572
+ if (!match) return { state: primary, rel: trimmed };
573
+ return { state: match, rel: slash === -1 ? "" : trimmed.slice(slash + 1) };
574
+ }
575
+ function toWorkspacePath(rootId, rel) {
576
+ return rel ? `${rootId}/${rel}` : rootId;
577
+ }
578
+
579
+ // src/daemon/workspace-file.ts
580
+ var MAX_ROOTS = 8;
581
+ function validateRecord(raw) {
582
+ if (!raw || typeof raw !== "object") return null;
583
+ const r = raw;
584
+ if (typeof r.id !== "string" || !isValidRootId(r.id)) return null;
585
+ if (typeof r.path !== "string" || !path5.isAbsolute(r.path)) return null;
586
+ if (r.kind !== "file" && r.kind !== "dir") return null;
587
+ const out = { id: r.id, path: r.path, kind: r.kind };
588
+ if (typeof r.name === "string" && r.name.length > 0 && r.name.length <= 128) out.name = r.name;
589
+ if (r.kind === "file") {
590
+ if (typeof r.filePath !== "string" || r.filePath.length === 0) return null;
591
+ if (r.filePath.includes("/") || r.filePath.includes("\\")) return null;
592
+ out.filePath = r.filePath;
593
+ }
594
+ return out;
595
+ }
596
+ function readWorkspace() {
597
+ const raw = readJsonSafe(workspaceFilePath());
598
+ if (!raw || typeof raw !== "object") return { roots: [] };
599
+ const list = raw.roots;
600
+ if (!Array.isArray(list)) return { roots: [] };
601
+ const seen = /* @__PURE__ */ new Set();
602
+ const roots = [];
603
+ for (const entry of list.slice(0, MAX_ROOTS)) {
604
+ const rec = validateRecord(entry);
605
+ if (!rec || seen.has(rec.id)) continue;
606
+ seen.add(rec.id);
607
+ roots.push(rec);
608
+ }
609
+ return { roots };
610
+ }
611
+ function writeWorkspace(record) {
612
+ writeJsonAtomic(workspaceFilePath(), record);
613
+ }
614
+ function toRootSpecs(record) {
615
+ return record.roots.map((r) => ({
616
+ absPath: r.path,
617
+ id: r.id,
618
+ ...r.name === void 0 ? {} : { name: r.name },
619
+ kind: r.kind,
620
+ ...r.filePath === void 0 ? {} : { filePath: r.filePath }
621
+ }));
622
+ }
623
+ function resolveTarget(target) {
624
+ const abs = path5.resolve(target);
625
+ if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
626
+ const st = statSync3(abs);
627
+ if (st.isDirectory()) {
628
+ return { absPath: abs, kind: "dir", filePath: "", name: path5.basename(abs) };
629
+ }
630
+ if (st.isFile()) {
631
+ const dir = path5.dirname(abs);
632
+ return { absPath: dir, kind: "file", filePath: path5.basename(abs), name: path5.basename(abs) };
633
+ }
634
+ throw new Error(`Unsupported path type: ${abs}`);
635
+ }
636
+ function findCovering(roots, abs) {
637
+ let best = null;
638
+ for (const root of roots) {
639
+ if (root.kind !== "dir" && root.path !== abs) continue;
640
+ const rel = path5.relative(root.path, abs);
641
+ if (rel.startsWith("..") || path5.isAbsolute(rel)) continue;
642
+ if (!best || root.path.length > best.root.path.length) {
643
+ best = { root, rel: rel.split(path5.sep).join("/") };
644
+ }
645
+ }
646
+ return best;
647
+ }
648
+ function addRoot(target) {
649
+ const resolved = resolveTarget(target);
650
+ const record = readWorkspace();
651
+ const fullPath = resolved.kind === "file" ? path5.join(resolved.absPath, resolved.filePath) : resolved.absPath;
652
+ const covering = findCovering(record.roots, fullPath);
653
+ if (covering) {
654
+ const wsPath = covering.rel ? `${covering.root.id}/${covering.rel}` : covering.root.id;
655
+ return {
656
+ root: covering.root,
657
+ filePath: resolved.kind === "file" ? wsPath : "",
658
+ focusPath: resolved.kind === "file" ? "" : wsPath,
659
+ changed: false
660
+ };
661
+ }
662
+ if (record.roots.length >= MAX_ROOTS) {
663
+ throw new Error(
664
+ `Workspace is full (${MAX_ROOTS} folders). Close one with 'mdview rm <name>' first.`
665
+ );
666
+ }
667
+ const id = deriveRootId(resolved.absPath, new Set(record.roots.map((r) => r.id)));
668
+ const root = {
669
+ id,
670
+ path: resolved.absPath,
671
+ kind: resolved.kind,
672
+ ...resolved.kind === "file" ? { name: resolved.name, filePath: resolved.filePath } : {}
673
+ };
674
+ record.roots.push(root);
675
+ writeWorkspace(record);
676
+ return {
677
+ root,
678
+ filePath: resolved.kind === "file" ? `${id}/${resolved.filePath}` : "",
679
+ focusPath: resolved.kind === "file" ? "" : id,
680
+ changed: true
681
+ };
682
+ }
683
+ function removeRoot(idOrPath) {
684
+ const record = readWorkspace();
685
+ const abs = path5.resolve(idOrPath);
686
+ const idx = record.roots.findIndex((r) => r.id === idOrPath || r.path === abs);
687
+ if (idx === -1) return null;
688
+ const [removed] = record.roots.splice(idx, 1);
689
+ writeWorkspace(record);
690
+ return removed ?? null;
691
+ }
692
+
693
+ // src/cli-workspace.ts
694
+ function tilde(abs) {
695
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
696
+ return home && abs.startsWith(home) ? `~${abs.slice(home.length)}` : abs;
697
+ }
698
+ async function runListCommand() {
699
+ const { roots } = readWorkspace();
700
+ const live = await findLiveDaemon();
701
+ if (roots.length === 0) {
702
+ console.log("No folders open. Run `mdview <path>` to add one.");
703
+ } else {
704
+ const open = new Set(live?.health.roots ?? []);
705
+ const idWidth = Math.max(...roots.map((r) => r.id.length));
706
+ for (const [i, r] of roots.entries()) {
707
+ const mark = live ? open.has(r.id) ? "\u25CF" : "\u25CB" : " ";
708
+ const label = i === 0 ? " (primary)" : "";
709
+ const suffix = r.kind === "file" ? ` \u2192 ${r.filePath}` : "";
710
+ console.log(`${mark} ${r.id.padEnd(idWidth)} ${tilde(r.path)}${suffix}${label}`);
711
+ }
712
+ if (roots.length >= MAX_ROOTS) {
713
+ console.log(`
714
+ Workspace is full (${MAX_ROOTS}). Remove one with \`mdview rm <name>\`.`);
715
+ }
716
+ }
717
+ console.log("");
718
+ if (live) {
719
+ console.log(`server: http://mdview.localhost:${live.state.port}/ (pid ${live.state.pid})`);
720
+ console.log(`logs: ${daemonLogPath()}`);
721
+ } else {
722
+ console.log("server: not running \u2014 the next `mdview <path>` will start it");
723
+ }
724
+ return 0;
725
+ }
726
+ async function runRemoveCommand(argv) {
727
+ const targets = argv.filter((a) => !a.startsWith("-"));
728
+ if (targets.length === 0) {
729
+ console.error("Usage: mdview rm <name|path>\u2026");
730
+ return 2;
731
+ }
732
+ let removedAny = false;
733
+ for (const target of targets) {
734
+ const removed = removeRoot(target);
735
+ if (removed) {
736
+ removedAny = true;
737
+ console.log(`Removed ${removed.id} (${tilde(removed.path)})`);
738
+ } else {
739
+ console.error(`Not open: ${target}`);
740
+ }
741
+ }
742
+ if (!removedAny) return 2;
743
+ console.log("Open tabs will update on their own.");
744
+ return 0;
745
+ }
746
+ async function runStopCommand() {
747
+ const outcome = await stopDaemon();
748
+ switch (outcome.kind) {
749
+ case "stopped":
750
+ console.log(`mdview: stopped (pid ${outcome.pid})`);
751
+ return 0;
752
+ case "not-running":
753
+ console.log("mdview: not running");
754
+ return 0;
755
+ case "stale":
756
+ console.log("mdview: not running (cleared a stale record)");
757
+ return 0;
758
+ }
759
+ }
760
+
332
761
  // src/server/index.ts
333
- import path10 from "path";
762
+ import path13 from "path";
334
763
  import chokidar2 from "chokidar";
335
764
  import Fastify from "fastify";
336
765
 
337
766
  // src/server/fs/ignore.ts
338
- import path3 from "path";
767
+ import path6 from "path";
339
768
  var DEFAULT_IGNORED_DIRS2 = new Set(DEFAULT_IGNORED_DIRS);
340
769
  function buildIgnoreSet(extra = []) {
341
770
  return /* @__PURE__ */ new Set([...DEFAULT_IGNORED_DIRS2, ...extra]);
@@ -344,9 +773,9 @@ function isDirIgnored(name, set) {
344
773
  return name.startsWith(".") || set.has(name);
345
774
  }
346
775
  function isPathIgnored(abs, root, set) {
347
- const rel = path3.relative(root, abs);
776
+ const rel = path6.relative(root, abs);
348
777
  if (rel === "" || rel.startsWith("..")) return false;
349
- for (const seg of rel.split(path3.sep)) {
778
+ for (const seg of rel.split(path6.sep)) {
350
779
  if (!seg) continue;
351
780
  if (seg.startsWith(".")) return true;
352
781
  if (set.has(seg)) return true;
@@ -354,21 +783,41 @@ function isPathIgnored(abs, root, set) {
354
783
  return false;
355
784
  }
356
785
 
786
+ // src/server/hosts.ts
787
+ var LOOPBACK_NAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
788
+ var MAX_HOST_LEN = 253;
789
+ function hostOnly(value) {
790
+ const h = value.trim().toLowerCase();
791
+ if (h.startsWith("[")) {
792
+ const end = h.indexOf("]");
793
+ return end === -1 ? h : h.slice(0, end + 1);
794
+ }
795
+ if (h.indexOf(":") !== h.lastIndexOf(":")) return h;
796
+ const colon = h.indexOf(":");
797
+ return colon === -1 ? h : h.slice(0, colon);
798
+ }
799
+ function isAllowedHost(value) {
800
+ if (!value || value.length > MAX_HOST_LEN) return false;
801
+ if (!/^[\x21-\x7e]+$/.test(value)) return false;
802
+ const host = hostOnly(value);
803
+ return LOOPBACK_NAMES.has(host) || /^[a-z0-9][a-z0-9.-]*\.localhost$/.test(host);
804
+ }
805
+
357
806
  // src/server/routes/api-asset.ts
358
807
  import { createReadStream } from "fs";
359
808
  import { stat } from "fs/promises";
360
- import path5 from "path";
809
+ import path8 from "path";
361
810
 
362
811
  // src/server/fs/resolve.ts
363
- import path4 from "path";
812
+ import path7 from "path";
364
813
  function resolveSafePath(root, relPath) {
365
- if (path4.isAbsolute(relPath)) {
814
+ if (path7.isAbsolute(relPath)) {
366
815
  throw new Error(`Refusing absolute path: ${relPath}`);
367
816
  }
368
- const absRoot = path4.resolve(root);
369
- const candidate = path4.resolve(absRoot, relPath);
370
- const rel = path4.relative(absRoot, candidate);
371
- if (rel.startsWith("..") || path4.isAbsolute(rel)) {
817
+ const absRoot = path7.resolve(root);
818
+ const candidate = path7.resolve(absRoot, relPath);
819
+ const rel = path7.relative(absRoot, candidate);
820
+ if (rel.startsWith("..") || path7.isAbsolute(rel)) {
372
821
  throw new Error(`Path resolves outside root: ${relPath}`);
373
822
  }
374
823
  return candidate;
@@ -393,12 +842,16 @@ var MIME = {
393
842
  ".pdf": "application/pdf",
394
843
  ".txt": "text/plain; charset=utf-8"
395
844
  };
396
- function registerApiAsset(app, rootAbsPath) {
845
+ function registerApiAsset(app, roots) {
397
846
  app.get("/__asset/*", async (req, reply) => {
398
- const rel = (req.params["*"] ?? "").replace(/^\/+/, "");
847
+ const wsPath = (req.params["*"] ?? "").replace(/^\/+/, "");
848
+ const resolved = parseWorkspacePath(roots, wsPath);
849
+ if (!resolved) {
850
+ return reply.code(404).send({ error: "Asset not found" });
851
+ }
399
852
  let abs;
400
853
  try {
401
- abs = resolveSafePath(rootAbsPath, rel);
854
+ abs = resolveSafePath(resolved.state.absPath, resolved.rel);
402
855
  } catch (err) {
403
856
  return reply.code(400).send({ error: err.message });
404
857
  }
@@ -411,7 +864,7 @@ function registerApiAsset(app, rootAbsPath) {
411
864
  if (!st.isFile()) {
412
865
  return reply.code(404).send({ error: "Not a file" });
413
866
  }
414
- const ext = path5.extname(abs).toLowerCase();
867
+ const ext = path8.extname(abs).toLowerCase();
415
868
  const mime = MIME[ext];
416
869
  if (!mime) {
417
870
  return reply.code(404).send({ error: "Asset type not allowed" });
@@ -464,18 +917,18 @@ function computeBodyStartLine(raw) {
464
917
  }
465
918
 
466
919
  // src/render/links.ts
467
- import path6 from "path/posix";
920
+ import path9 from "path/posix";
468
921
  var MD_EXT_RE = /\.(md|markdown|mdx)$/i;
469
922
  var HREF_RE = /<a\s+([^>]*?)href="([^"#?]*)(\?[^"#]*)?(#[^"]*)?"([^>]*)>/gi;
470
923
  var IMG_SRC_RE = /<img\s+([^>]*?)src="([^"]+)"([^>]*)>/gi;
471
924
  function tagInternalLinks(html, currentRelPath) {
472
- const currentDir = path6.dirname(currentRelPath);
925
+ const currentDir = path9.dirname(currentRelPath);
473
926
  return html.replace(HREF_RE, (full, pre, target, query, hash, post) => {
474
927
  if (!target || /^https?:\/\//i.test(target) || target.startsWith("mailto:")) {
475
928
  return full;
476
929
  }
477
930
  if (!MD_EXT_RE.test(target)) return full;
478
- const resolved = path6.normalize(path6.join(currentDir, target));
931
+ const resolved = path9.normalize(path9.join(currentDir, target));
479
932
  let decoded;
480
933
  try {
481
934
  decoded = decodeURIComponent(resolved);
@@ -488,12 +941,12 @@ function tagInternalLinks(html, currentRelPath) {
488
941
  });
489
942
  }
490
943
  function rewriteImageSrc(html, currentRelPath) {
491
- const currentDir = path6.dirname(currentRelPath);
944
+ const currentDir = path9.dirname(currentRelPath);
492
945
  return html.replace(IMG_SRC_RE, (full, pre, src, post) => {
493
946
  if (!src) return full;
494
947
  if (/^(https?:\/\/|data:|\/__asset\/)/i.test(src)) return full;
495
948
  const trimmed = src.replace(/^\/+/, "");
496
- const resolved = path6.normalize(path6.join(currentDir, trimmed));
949
+ const resolved = path9.normalize(path9.join(currentDir, trimmed));
497
950
  if (resolved.startsWith("..")) return full;
498
951
  return `<img ${pre}src="/__asset/${resolved}"${post}>`;
499
952
  });
@@ -605,20 +1058,21 @@ var COMMON_LANGUAGES = [
605
1058
  "diff",
606
1059
  "dockerfile"
607
1060
  ];
608
- var PALETTE_THEME_MAP = {
609
- "classic-light": "github-light",
610
- "classic-dark": "github-dark",
611
- "paper-light": "min-light",
612
- "paper-dark": "vitesse-dark",
613
- "nord-light": "min-light",
614
- "nord-dark": "nord",
615
- "solarized-light": "solarized-light",
616
- "solarized-dark": "solarized-dark",
617
- "high-contrast-light": "github-light-high-contrast",
618
- "high-contrast-dark": "github-dark-high-contrast"
1061
+ var PALETTE_THEMES = {
1062
+ // Flexoki has no upstream TextMate theme. `min-light` and `vesper` are the
1063
+ // closest bundled stand-ins: both are deliberately low-chroma, which is the
1064
+ // one property of Flexoki that a louder theme would contradict.
1065
+ flexoki: { light: "min-light", dark: "vesper" },
1066
+ paper: { light: "min-light", dark: "vitesse-dark" },
1067
+ solarized: { light: "solarized-light", dark: "solarized-dark" },
1068
+ everforest: { light: "everforest-light", dark: "everforest-dark" },
1069
+ "rose-pine": { light: "rose-pine-dawn", dark: "rose-pine" },
1070
+ kanagawa: { light: "kanagawa-lotus", dark: "kanagawa-wave" },
1071
+ catppuccin: { light: "catppuccin-latte", dark: "catppuccin-mocha" },
1072
+ "high-contrast": { light: "github-light-high-contrast", dark: "github-dark-high-contrast" }
619
1073
  };
620
1074
  var UNDERLYING_THEMES = Array.from(
621
- new Set(Object.values(PALETTE_THEME_MAP))
1075
+ new Set(Object.values(PALETTE_THEMES).flatMap((t) => [t.light, t.dark]))
622
1076
  );
623
1077
  var highlighterPromise = null;
624
1078
  function getHighlighter() {
@@ -633,7 +1087,7 @@ function getHighlighter() {
633
1087
  function escapeHtml(s) {
634
1088
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
635
1089
  }
636
- async function highlightCode(code, lang) {
1090
+ async function highlightCode(code, lang, palette = DEFAULT_PALETTE) {
637
1091
  const highlighter = await getHighlighter();
638
1092
  const loaded = highlighter.getLoadedLanguages();
639
1093
  let effectiveLang = lang;
@@ -647,7 +1101,7 @@ async function highlightCode(code, lang) {
647
1101
  try {
648
1102
  return highlighter.codeToHtml(code, {
649
1103
  lang: effectiveLang || "text",
650
- themes: PALETTE_THEME_MAP,
1104
+ themes: PALETTE_THEMES[palette],
651
1105
  defaultColor: false
652
1106
  });
653
1107
  } catch {
@@ -669,7 +1123,7 @@ md.use(anchor, {
669
1123
  });
670
1124
  md.use(taskLists, { enabled: false, label: false });
671
1125
  md.use(mathPlugin);
672
- async function renderMarkdown(source, bodyStartLine = 0) {
1126
+ async function renderMarkdown(source, bodyStartLine = 0, palette = DEFAULT_PALETTE) {
673
1127
  const tokens = md.parse(source, {});
674
1128
  for (const token of tokens) {
675
1129
  if (token.type === "heading_open" && token.map) {
@@ -682,7 +1136,7 @@ async function renderMarkdown(source, bodyStartLine = 0) {
682
1136
  token.content = `<div class="mermaid-block" data-source="${encodeURIComponent(token.content)}"></div>
683
1137
  `;
684
1138
  } else {
685
- const highlighted = await highlightCode(token.content, lang);
1139
+ const highlighted = await highlightCode(token.content, lang, palette);
686
1140
  token.type = "html_block";
687
1141
  token.content = highlighted + "\n";
688
1142
  }
@@ -725,57 +1179,70 @@ function nest(flat) {
725
1179
  }
726
1180
 
727
1181
  // src/server/routes/api-file.ts
728
- function registerApiFile(app, rootAbsPath, rootInfo) {
729
- app.get("/api/file", async (req, reply) => {
730
- const requested = req.query.path?.trim() ?? "";
731
- let relPath;
732
- if (rootInfo.rootKind === "file") {
733
- if (requested && requested !== rootInfo.rootRelPath) {
1182
+ function registerApiFile(app, roots) {
1183
+ app.get(
1184
+ "/api/file",
1185
+ async (req, reply) => {
1186
+ const requested = req.query.path?.trim() ?? "";
1187
+ const rawPalette = req.query.palette?.trim() ?? "";
1188
+ const palette = PALETTES.includes(rawPalette) ? rawPalette : DEFAULT_PALETTE;
1189
+ const primary = roots[0];
1190
+ if (!primary) {
1191
+ return reply.code(404).send({ error: "No folders are open" });
1192
+ }
1193
+ let wsPath = requested;
1194
+ if (!wsPath) {
1195
+ if (primary.root.kind !== "file") {
1196
+ return reply.code(400).send({ error: "Missing ?path" });
1197
+ }
1198
+ wsPath = toWorkspacePath(primary.root.id, primary.root.filePath);
1199
+ }
1200
+ const resolved = parseWorkspacePath(roots, wsPath);
1201
+ if (!resolved) {
1202
+ return reply.code(404).send({ error: "File not found" });
1203
+ }
1204
+ const { state, rel } = resolved;
1205
+ if (state.root.kind === "file" && rel !== state.root.filePath) {
734
1206
  return reply.code(404).send({ error: "File not found in single-file mode" });
735
1207
  }
736
- relPath = rootInfo.rootRelPath;
737
- } else {
738
- if (!requested) {
739
- return reply.code(400).send({ error: "Missing ?path" });
1208
+ if (!/\.(md|markdown|mdx)$/i.test(rel)) {
1209
+ return reply.code(400).send({ error: "Only markdown files are supported" });
740
1210
  }
741
- relPath = requested;
742
- }
743
- if (!/\.(md|markdown|mdx)$/i.test(relPath)) {
744
- return reply.code(400).send({ error: "Only markdown files are supported" });
745
- }
746
- let absPath;
747
- try {
748
- absPath = resolveSafePath(rootAbsPath, relPath);
749
- } catch (err) {
750
- return reply.code(400).send({ error: err.message });
1211
+ let absPath;
1212
+ try {
1213
+ absPath = resolveSafePath(state.absPath, rel);
1214
+ } catch (err) {
1215
+ return reply.code(400).send({ error: err.message });
1216
+ }
1217
+ let raw;
1218
+ let mtimeMs;
1219
+ try {
1220
+ const [content, st] = await Promise.all([
1221
+ readFile3(absPath, "utf8"),
1222
+ stat2(absPath)
1223
+ ]);
1224
+ raw = content;
1225
+ mtimeMs = st.mtimeMs;
1226
+ } catch {
1227
+ return reply.code(404).send({ error: "File not found" });
1228
+ }
1229
+ const { data, body, bodyStartLine } = parseFrontmatter(raw);
1230
+ const { html: rawHtml, tokens } = await renderMarkdown(body, bodyStartLine, palette);
1231
+ const wsRel = toWorkspacePath(state.root.id, rel);
1232
+ const html = rewriteImageSrc(tagInternalLinks(rawHtml, wsRel), wsRel);
1233
+ const outline = extractOutline(tokens);
1234
+ const title = (typeof data?.title === "string" ? data.title : null) ?? outline[0]?.text ?? null;
1235
+ const result = {
1236
+ relPath: wsRel,
1237
+ html,
1238
+ outline,
1239
+ frontmatter: data,
1240
+ title,
1241
+ lastModified: mtimeMs
1242
+ };
1243
+ return reply.send(result);
751
1244
  }
752
- let raw;
753
- let mtimeMs;
754
- try {
755
- const [content, st] = await Promise.all([
756
- readFile3(absPath, "utf8"),
757
- stat2(absPath)
758
- ]);
759
- raw = content;
760
- mtimeMs = st.mtimeMs;
761
- } catch {
762
- return reply.code(404).send({ error: "File not found" });
763
- }
764
- const { data, body, bodyStartLine } = parseFrontmatter(raw);
765
- const { html: rawHtml, tokens } = await renderMarkdown(body, bodyStartLine);
766
- const html = rewriteImageSrc(tagInternalLinks(rawHtml, relPath), relPath);
767
- const outline = extractOutline(tokens);
768
- const title = (typeof data?.title === "string" ? data.title : null) ?? outline[0]?.text ?? null;
769
- const result = {
770
- relPath,
771
- html,
772
- outline,
773
- frontmatter: data,
774
- title,
775
- lastModified: mtimeMs
776
- };
777
- return reply.send(result);
778
- });
1245
+ );
779
1246
  }
780
1247
 
781
1248
  // src/server/fs/grep.ts
@@ -865,13 +1332,12 @@ function flattenMdRelPaths(nodes, out = []) {
865
1332
 
866
1333
  // src/server/fs/tree.ts
867
1334
  import { readdir } from "fs/promises";
868
- import path7 from "path";
1335
+ import path10 from "path";
869
1336
  async function walkFolder(root, opts = {}) {
870
1337
  const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
871
- return walkInner(root, "", ignore);
1338
+ return walkInner(root, opts.relBase ?? "", ignore);
872
1339
  }
873
- async function walkInner(root, relBase, ignore) {
874
- const absDir = path7.join(root, relBase);
1340
+ async function walkInner(absDir, relBase, ignore) {
875
1341
  const entries = await readdir(absDir, { withFileTypes: true });
876
1342
  const out = [];
877
1343
  for (const entry of entries) {
@@ -882,7 +1348,7 @@ async function walkInner(root, relBase, ignore) {
882
1348
  }
883
1349
  const childRel = relBase ? `${relBase}/${entry.name}` : entry.name;
884
1350
  if (entry.isDirectory()) {
885
- const children = await walkInner(root, childRel, ignore);
1351
+ const children = await walkInner(path10.join(absDir, entry.name), childRel, ignore);
886
1352
  out.push({
887
1353
  name: entry.name,
888
1354
  relPath: childRel,
@@ -890,7 +1356,7 @@ async function walkInner(root, relBase, ignore) {
890
1356
  children
891
1357
  });
892
1358
  } else if (entry.isFile()) {
893
- const ext = path7.extname(entry.name).toLowerCase();
1359
+ const ext = path10.extname(entry.name).toLowerCase();
894
1360
  out.push({
895
1361
  name: entry.name,
896
1362
  relPath: childRel,
@@ -920,7 +1386,7 @@ function buildSnippet(line, start, end, radius) {
920
1386
  highlight: [hlStart, hlEnd]
921
1387
  };
922
1388
  }
923
- async function grepFiles(rootAbsPath, query, opts = {}) {
1389
+ async function grepFiles(roots, query, opts = {}) {
924
1390
  const perFileCap = opts.perFileCap ?? 20;
925
1391
  const globalCap = opts.globalCap ?? 200;
926
1392
  const snippetRadius = opts.snippetRadius ?? 60;
@@ -936,64 +1402,75 @@ async function grepFiles(rootAbsPath, query, opts = {}) {
936
1402
  regex: opts.regex ?? false
937
1403
  });
938
1404
  if (!pattern.valid) return { query, results: [], truncated: false };
939
- const tree = await walkFolder(rootAbsPath, { ignore: opts.ignore });
940
- const files = flattenMdRelPaths(tree);
941
1405
  const results = [];
942
1406
  let totalHits = 0;
943
1407
  let truncated = false;
944
- for (const relPath of files) {
1408
+ for (const root of roots) {
945
1409
  if (totalHits >= globalCap) {
946
1410
  truncated = true;
947
1411
  break;
948
1412
  }
949
- let absPath;
950
- try {
951
- absPath = resolveSafePath(rootAbsPath, relPath);
952
- } catch {
953
- continue;
954
- }
955
- let raw;
956
- try {
957
- raw = await readFile4(absPath, "utf8");
958
- } catch {
959
- continue;
960
- }
961
- const { body } = parseFrontmatter(raw);
962
- const lines = body.split(/\r?\n/);
963
- const fileHits = [];
964
- let fileTotal = 0;
965
- let fileTruncated = false;
966
- for (let i = 0; i < lines.length; i++) {
967
- const line = lines[i];
968
- if (regexMode && line.length > maxLineLenForRegex) {
969
- fileTruncated = true;
1413
+ const tree = await walkFolder(root.absPath, { ignore: root.ignore ?? opts.ignore });
1414
+ const files = flattenMdRelPaths(tree);
1415
+ for (const relPath of files) {
1416
+ if (totalHits >= globalCap) {
1417
+ truncated = true;
1418
+ break;
1419
+ }
1420
+ let absPath;
1421
+ try {
1422
+ absPath = resolveSafePath(root.absPath, relPath);
1423
+ } catch {
970
1424
  continue;
971
1425
  }
972
- const { matches, truncated: lineTruncated } = regexMode ? pattern.matchAllWithBudget(line, perLineBudgetMs) : { matches: pattern.matchAll(line), truncated: false };
973
- if (lineTruncated) fileTruncated = true;
974
- for (const m of matches) {
975
- fileTotal++;
976
- if (fileHits.length < perFileCap) {
977
- const { snippet, highlight } = buildSnippet(
978
- line,
979
- m.index,
980
- m.index + m.length,
981
- snippetRadius
982
- );
983
- fileHits.push({ line: i + 1, col: m.index, snippet, highlight });
984
- totalHits++;
985
- } else {
1426
+ let raw;
1427
+ try {
1428
+ raw = await readFile4(absPath, "utf8");
1429
+ } catch {
1430
+ continue;
1431
+ }
1432
+ const { body } = parseFrontmatter(raw);
1433
+ const lines = body.split(/\r?\n/);
1434
+ const fileHits = [];
1435
+ let fileTotal = 0;
1436
+ let fileTruncated = false;
1437
+ for (let i = 0; i < lines.length; i++) {
1438
+ const line = lines[i];
1439
+ if (regexMode && line.length > maxLineLenForRegex) {
986
1440
  fileTruncated = true;
1441
+ continue;
987
1442
  }
988
- if (totalHits >= globalCap) {
989
- truncated = true;
990
- break;
1443
+ const { matches, truncated: lineTruncated } = regexMode ? pattern.matchAllWithBudget(line, perLineBudgetMs) : { matches: pattern.matchAll(line), truncated: false };
1444
+ if (lineTruncated) fileTruncated = true;
1445
+ for (const m of matches) {
1446
+ fileTotal++;
1447
+ if (fileHits.length < perFileCap) {
1448
+ const { snippet, highlight } = buildSnippet(
1449
+ line,
1450
+ m.index,
1451
+ m.index + m.length,
1452
+ snippetRadius
1453
+ );
1454
+ fileHits.push({ line: i + 1, col: m.index, snippet, highlight });
1455
+ totalHits++;
1456
+ } else {
1457
+ fileTruncated = true;
1458
+ }
1459
+ if (totalHits >= globalCap) {
1460
+ truncated = true;
1461
+ break;
1462
+ }
991
1463
  }
1464
+ if (totalHits >= globalCap) break;
1465
+ }
1466
+ if (fileHits.length > 0) {
1467
+ results.push({
1468
+ relPath: `${root.id}/${relPath}`,
1469
+ hits: fileHits,
1470
+ total: fileTotal,
1471
+ truncated: fileTruncated
1472
+ });
992
1473
  }
993
- if (totalHits >= globalCap) break;
994
- }
995
- if (fileHits.length > 0) {
996
- results.push({ relPath, hits: fileHits, total: fileTotal, truncated: fileTruncated });
997
1474
  }
998
1475
  }
999
1476
  return { query, results, truncated };
@@ -1004,11 +1481,8 @@ var MAX_QUERY_LEN = 200;
1004
1481
  function isTrue(v) {
1005
1482
  return v === "1" || v === "true";
1006
1483
  }
1007
- function registerApiSearch(app, rootAbsPath, rootInfo, configState) {
1484
+ function registerApiSearch(app, roots) {
1008
1485
  app.get("/api/search", async (req, reply) => {
1009
- if (rootInfo.rootKind === "file") {
1010
- return reply.send({ query: req.query.q ?? "", results: [], truncated: false });
1011
- }
1012
1486
  const q = (req.query.q ?? "").trim();
1013
1487
  if (q.length === 0) {
1014
1488
  return reply.send({ query: "", results: [], truncated: false });
@@ -1016,43 +1490,76 @@ function registerApiSearch(app, rootAbsPath, rootInfo, configState) {
1016
1490
  if (q.length > MAX_QUERY_LEN) {
1017
1491
  return reply.code(400).send({ error: "Query too long" });
1018
1492
  }
1019
- const out = await grepFiles(rootAbsPath, q, {
1493
+ const searchable = roots.filter((r) => r.root.kind === "dir").map((r) => ({ absPath: r.absPath, id: r.root.id, ignore: r.ignoreSet }));
1494
+ const out = await grepFiles(searchable, q, {
1020
1495
  caseSensitive: isTrue(req.query.case),
1021
1496
  wholeWord: isTrue(req.query.word),
1022
- regex: isTrue(req.query.regex),
1023
- ignore: configState.ignoreSet
1497
+ regex: isTrue(req.query.regex)
1024
1498
  });
1025
1499
  return reply.send(out);
1026
1500
  });
1027
1501
  }
1028
1502
 
1029
1503
  // src/server/routes/api-tree.ts
1030
- import path8 from "path";
1504
+ import path11 from "path";
1031
1505
  function applyOverrides(config, paletteOverride) {
1032
1506
  if (!paletteOverride) return config;
1033
1507
  return { ...config ?? {}, palette: paletteOverride };
1034
1508
  }
1035
- function registerApiTree(app, rootAbsPath, rootInfo, configState) {
1509
+ async function rootChildren(state) {
1510
+ const { id, kind, filePath } = state.root;
1511
+ if (kind === "file") {
1512
+ return [
1513
+ {
1514
+ name: path11.basename(filePath),
1515
+ relPath: toWorkspacePath(id, filePath),
1516
+ type: "file",
1517
+ isMarkdown: true
1518
+ }
1519
+ ];
1520
+ }
1521
+ return walkFolder(state.absPath, { ignore: state.ignoreSet, relBase: id });
1522
+ }
1523
+ function registerApiTree(app, roots, paletteOverride) {
1036
1524
  app.get("/api/tree", async (_req, reply) => {
1037
- const config = applyOverrides(configState.current, configState.paletteOverride);
1038
- if (rootInfo.rootKind === "file") {
1039
- const single = [
1040
- {
1041
- name: path8.basename(rootInfo.rootRelPath),
1042
- relPath: rootInfo.rootRelPath,
1043
- type: "file",
1044
- isMarkdown: true
1045
- }
1046
- ];
1047
- return reply.send({ root: rootInfo, tree: single, config });
1525
+ const config = applyOverrides(roots[0]?.config ?? null, paletteOverride);
1526
+ if (roots.length === 1) {
1527
+ const only = roots[0];
1528
+ return reply.send({
1529
+ roots: [only.root],
1530
+ tree: await rootChildren(only),
1531
+ config
1532
+ });
1048
1533
  }
1049
- const tree = await walkFolder(rootAbsPath, { ignore: configState.ignoreSet });
1050
- return reply.send({ root: rootInfo, tree, config });
1534
+ const tree = [];
1535
+ for (const state of roots) {
1536
+ tree.push({
1537
+ name: state.root.name,
1538
+ relPath: state.root.id,
1539
+ type: "dir",
1540
+ children: await rootChildren(state)
1541
+ });
1542
+ }
1543
+ return reply.send({ roots: roots.map((r) => r.root), tree, config });
1051
1544
  });
1052
1545
  }
1053
1546
 
1547
+ // src/server/routes/health.ts
1548
+ function registerHealth(app, identity, roots) {
1549
+ app.get(
1550
+ "/api/health",
1551
+ async (_req, reply) => reply.send({
1552
+ ok: true,
1553
+ pid: identity.pid,
1554
+ startedAt: identity.startedAt,
1555
+ roots: roots.map((r) => r.root.id)
1556
+ })
1557
+ );
1558
+ }
1559
+
1054
1560
  // src/server/routes/sse.ts
1055
- function registerSse(app, watcher) {
1561
+ function registerSse(app, hub, hooks = {}) {
1562
+ let clients = 0;
1056
1563
  app.get("/api/watch", (req, reply) => {
1057
1564
  reply.raw.writeHead(200, {
1058
1565
  "Content-Type": "text/event-stream",
@@ -1062,11 +1569,15 @@ function registerSse(app, watcher) {
1062
1569
  });
1063
1570
  reply.raw.write(": connected\n\n");
1064
1571
  let closed = false;
1572
+ clients++;
1573
+ hooks.onClientsChanged?.(clients);
1065
1574
  const cleanup = () => {
1066
1575
  if (closed) return;
1067
1576
  closed = true;
1068
1577
  clearInterval(heartbeat);
1069
- watcher.off("event", send);
1578
+ hub.off("event", send);
1579
+ clients--;
1580
+ hooks.onClientsChanged?.(clients);
1070
1581
  try {
1071
1582
  reply.raw.end();
1072
1583
  } catch {
@@ -1092,7 +1603,7 @@ function registerSse(app, watcher) {
1092
1603
  cleanup();
1093
1604
  }
1094
1605
  }, 15e3);
1095
- watcher.on("event", send);
1606
+ hub.on("event", send);
1096
1607
  req.raw.on("close", cleanup);
1097
1608
  req.raw.on("error", cleanup);
1098
1609
  reply.raw.on("error", cleanup);
@@ -1102,12 +1613,25 @@ function registerSse(app, watcher) {
1102
1613
 
1103
1614
  // src/server/watcher.ts
1104
1615
  import { EventEmitter } from "events";
1105
- import path9 from "path";
1616
+ import path12 from "path";
1106
1617
  import chokidar from "chokidar";
1618
+ function createEventHub() {
1619
+ const emitter = new EventEmitter();
1620
+ emitter.setMaxListeners(0);
1621
+ return {
1622
+ on: (event, listener) => emitter.on(event, listener),
1623
+ off: (event, listener) => emitter.off(event, listener),
1624
+ emitSynthetic: (e) => emitter.emit("event", e),
1625
+ close: async () => {
1626
+ emitter.removeAllListeners();
1627
+ }
1628
+ };
1629
+ }
1107
1630
  function createWatcher(rootAbsPath, opts = {}) {
1108
1631
  const emitter = new EventEmitter();
1109
1632
  emitter.setMaxListeners(0);
1110
1633
  const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
1634
+ const prefix = opts.prefix ?? "";
1111
1635
  const watcher = chokidar.watch(rootAbsPath, {
1112
1636
  ignoreInitial: true,
1113
1637
  ignored: (p) => isPathIgnored(p, rootAbsPath, ignore),
@@ -1130,9 +1654,10 @@ function createWatcher(rootAbsPath, opts = {}) {
1130
1654
  }
1131
1655
  });
1132
1656
  function emit(kind, abs) {
1133
- const rel = path9.relative(rootAbsPath, abs).split(path9.sep).join("/");
1657
+ const rel = path12.relative(rootAbsPath, abs).split(path12.sep).join("/");
1134
1658
  if (!rel) return;
1135
- emitter.emit("event", { kind, relPath: rel });
1659
+ const relPath = prefix ? `${prefix}/${rel}` : rel;
1660
+ emitter.emit("event", { kind, relPath });
1136
1661
  }
1137
1662
  watcher.on("change", (p) => emit("change", p));
1138
1663
  watcher.on("add", (p) => emit("add", p));
@@ -1165,6 +1690,10 @@ function buildCspHtml(embedMode) {
1165
1690
  async function createServer(opts) {
1166
1691
  const app = Fastify({ logger: false });
1167
1692
  const cspHtml = buildCspHtml(opts.embedMode ?? false);
1693
+ app.addHook("onRequest", async (req, reply) => {
1694
+ if (isAllowedHost(req.headers.host)) return;
1695
+ return reply.code(403).type("text/plain; charset=utf-8").send("Forbidden");
1696
+ });
1168
1697
  app.addHook("onSend", async (_req, reply, payload) => {
1169
1698
  const ct = String(reply.getHeader("content-type") ?? "");
1170
1699
  if (ct.startsWith("text/html")) {
@@ -1174,33 +1703,76 @@ async function createServer(opts) {
1174
1703
  }
1175
1704
  return payload;
1176
1705
  });
1177
- const initialConfig = await loadEffectiveConfig(opts.rootAbsPath);
1178
- const ignoreSet = buildIgnoreSet(initialConfig?.ignore ?? []);
1179
- const configState = {
1180
- current: initialConfig,
1181
- ignoreSet,
1182
- paletteOverride: opts.paletteOverride
1183
- };
1184
- const watcher = createWatcher(opts.rootAbsPath, { ignore: ignoreSet });
1185
- const configPath = path10.join(opts.rootAbsPath, CONFIG_FILENAME);
1186
- const configWatcher = chokidar2.watch(configPath, {
1187
- ignoreInitial: true,
1188
- persistent: true,
1189
- awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
1190
- });
1191
- const reloadConfig = async () => {
1192
- configState.current = await loadEffectiveConfig(opts.rootAbsPath);
1193
- const event = { kind: "config", relPath: CONFIG_FILENAME };
1194
- watcher.emitSynthetic?.(event);
1195
- };
1196
- configWatcher.on("add", () => void reloadConfig());
1197
- configWatcher.on("change", () => void reloadConfig());
1198
- configWatcher.on("unlink", () => void reloadConfig());
1199
- registerApiFile(app, opts.rootAbsPath, opts.rootInfo);
1200
- registerApiTree(app, opts.rootAbsPath, opts.rootInfo, configState);
1201
- registerApiAsset(app, opts.rootAbsPath);
1202
- registerApiSearch(app, opts.rootAbsPath, opts.rootInfo, configState);
1203
- registerSse(app, watcher);
1706
+ const roots = [];
1707
+ const configWatchers = /* @__PURE__ */ new Map();
1708
+ const hub = createEventHub();
1709
+ async function openRoot(spec, id) {
1710
+ const config = await loadEffectiveConfig(spec.absPath);
1711
+ const ignoreSet = buildIgnoreSet(config?.ignore ?? []);
1712
+ const state = {
1713
+ root: describeRoot(spec, id),
1714
+ absPath: spec.absPath,
1715
+ ignoreSet,
1716
+ config,
1717
+ watcher: createWatcher(spec.absPath, { ignore: ignoreSet, prefix: id })
1718
+ };
1719
+ state.watcher.on("event", (e) => hub.emitSynthetic(e));
1720
+ const configWatcher = chokidar2.watch(path13.join(spec.absPath, CONFIG_FILENAME), {
1721
+ ignoreInitial: true,
1722
+ persistent: true,
1723
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
1724
+ });
1725
+ const reloadConfig = async () => {
1726
+ state.config = await loadEffectiveConfig(spec.absPath);
1727
+ const event = { kind: "config", relPath: `${id}/${CONFIG_FILENAME}` };
1728
+ hub.emitSynthetic(event);
1729
+ };
1730
+ configWatcher.on("add", () => void reloadConfig());
1731
+ configWatcher.on("change", () => void reloadConfig());
1732
+ configWatcher.on("unlink", () => void reloadConfig());
1733
+ configWatchers.set(id, configWatcher);
1734
+ return state;
1735
+ }
1736
+ async function closeRoot(state) {
1737
+ const cw = configWatchers.get(state.root.id);
1738
+ configWatchers.delete(state.root.id);
1739
+ await Promise.all([state.watcher.close(), cw?.close()]);
1740
+ }
1741
+ async function setRoots(specs) {
1742
+ const taken = /* @__PURE__ */ new Set();
1743
+ const wanted = specs.map((spec) => {
1744
+ const id = resolveRootId(spec, taken);
1745
+ taken.add(id);
1746
+ return { id, spec };
1747
+ });
1748
+ const keep = new Map(
1749
+ roots.filter((r) => wanted.some((w) => w.id === r.root.id && w.spec.absPath === r.absPath)).map((r) => [r.root.id, r])
1750
+ );
1751
+ const dropped = roots.filter((r) => !keep.has(r.root.id));
1752
+ const next = [];
1753
+ for (const { id, spec } of wanted) {
1754
+ const existing = keep.get(id);
1755
+ next.push(existing ?? await openRoot(spec, id));
1756
+ }
1757
+ roots.length = 0;
1758
+ roots.push(...next);
1759
+ await Promise.all(dropped.map(closeRoot));
1760
+ hub.emitSynthetic({ kind: "workspace" });
1761
+ }
1762
+ {
1763
+ const taken = /* @__PURE__ */ new Set();
1764
+ for (const spec of opts.roots) {
1765
+ const id = resolveRootId(spec, taken);
1766
+ taken.add(id);
1767
+ roots.push(await openRoot(spec, id));
1768
+ }
1769
+ }
1770
+ registerApiFile(app, roots);
1771
+ registerApiTree(app, roots, opts.paletteOverride);
1772
+ registerApiAsset(app, roots);
1773
+ registerApiSearch(app, roots);
1774
+ registerSse(app, hub, { ...opts.onSseClientsChanged ? { onClientsChanged: opts.onSseClientsChanged } : {} });
1775
+ if (opts.identity) registerHealth(app, opts.identity, roots);
1204
1776
  await app.register(import("@fastify/static"), {
1205
1777
  root: opts.clientDir,
1206
1778
  prefix: "/"
@@ -1225,10 +1797,13 @@ async function createServer(opts) {
1225
1797
  return reply.sendFile("index.html");
1226
1798
  });
1227
1799
  app.addHook("onClose", async () => {
1228
- await watcher.close();
1229
- await configWatcher.close();
1800
+ await Promise.all([
1801
+ ...roots.map((r) => r.watcher.close()),
1802
+ ...[...configWatchers.values()].map((w) => w.close())
1803
+ ]);
1804
+ await hub.close();
1230
1805
  });
1231
- return app;
1806
+ return { app, setRoots };
1232
1807
  }
1233
1808
 
1234
1809
  // src/cli.ts
@@ -1237,33 +1812,48 @@ function printUsage() {
1237
1812
  mdview \u2014 local markdown viewer
1238
1813
 
1239
1814
  Usage:
1240
- mdview [path] View file or folder (default: cwd)
1815
+ mdview [path] Open a file or folder and return the shell (default: cwd)
1816
+ mdview ls List open folders and the background server
1817
+ mdview rm <name|path>\u2026 Stop serving a folder
1818
+ mdview stop Stop the background server
1241
1819
  mdview config <\u2026> Read or edit the global config (run 'mdview config -h')
1242
1820
 
1821
+ One background server serves every folder you open, so 'mdview .' in a second
1822
+ repo adds it alongside the first instead of starting a rival server.
1823
+
1243
1824
  Options:
1244
- --port <n> Port to listen on (default: 7331; auto-fallback)
1825
+ --foreground, -f Run a server in this terminal instead (Ctrl-C to stop)
1245
1826
  --no-open Don't auto-launch the browser
1246
- --palette <name> Override the palette for this run (one of: ${PALETTES.join(", ")})
1827
+ --port <n> Port to listen on; implies --foreground
1828
+ --palette <name> Palette for this run; implies --foreground (one of: ${PALETTES.join(", ")})
1247
1829
  --version, -v Print version and exit
1248
1830
  --help, -h Show this help
1249
1831
 
1832
+ Environment:
1833
+ PORT Port for the background server (default: 7331)
1834
+ MDVIEW_IDLE_TIMEOUT Minutes with no open tab after which the background
1835
+ server exits (default: off \u2014 use 'mdview stop')
1836
+ MDVIEW_DEBUG=1 Print full stack traces on unexpected errors
1837
+
1250
1838
  Examples:
1251
- mdview ./docs Browse a folder
1839
+ mdview ./docs Browse a folder, shell stays free
1252
1840
  mdview README.md View a single file
1253
- mdview --port 9000 Use a specific port
1841
+ mdview ls What's open, and where
1842
+ mdview --foreground ./docs Keep it in this terminal
1843
+ mdview --port 9000 ./docs One-off server on a specific port
1254
1844
  mdview config ignore add deps _site Hide extra build dirs globally
1255
1845
  mdview config path Print global config path
1256
1846
  `.trim());
1257
1847
  }
1258
1848
  function readVersion() {
1259
- const here = path11.dirname(fileURLToPath(import.meta.url));
1849
+ const here = path14.dirname(fileURLToPath(import.meta.url));
1260
1850
  const candidates = [
1261
- path11.resolve(here, "../package.json"),
1262
- path11.resolve(here, "../../package.json")
1851
+ path14.resolve(here, "../package.json"),
1852
+ path14.resolve(here, "../../package.json")
1263
1853
  ];
1264
1854
  for (const p of candidates) {
1265
1855
  try {
1266
- const raw = readFileSync(p, "utf8");
1856
+ const raw = readFileSync2(p, "utf8");
1267
1857
  const v = JSON.parse(raw).version;
1268
1858
  if (typeof v === "string") return v;
1269
1859
  } catch {
@@ -1271,8 +1861,38 @@ function readVersion() {
1271
1861
  }
1272
1862
  return "unknown";
1273
1863
  }
1274
- function parseArgs(argv) {
1275
- const args = { target: ".", port: 7331, portExplicit: false, open: true, embedMode: false };
1864
+ var DEFAULT_PORT = 7331;
1865
+ var FRIENDLY_HOST = "mdview.localhost";
1866
+ function isLoopbackAddress(addr) {
1867
+ return addr === "::1" || addr.startsWith("127.");
1868
+ }
1869
+ async function resolveDisplayHost() {
1870
+ try {
1871
+ const addrs = await dns.lookup(FRIENDLY_HOST, { all: true });
1872
+ if (addrs.length > 0 && addrs.every((a) => isLoopbackAddress(a.address))) {
1873
+ return FRIENDLY_HOST;
1874
+ }
1875
+ } catch {
1876
+ }
1877
+ return "127.0.0.1";
1878
+ }
1879
+ function envPort() {
1880
+ const raw = process.env.PORT;
1881
+ if (!raw) return void 0;
1882
+ const n = Number(raw);
1883
+ if (!Number.isInteger(n) || n < 0 || n > 65535) return void 0;
1884
+ return n;
1885
+ }
1886
+ function parseArgs(argv, defaultPort = DEFAULT_PORT) {
1887
+ const args = {
1888
+ target: ".",
1889
+ port: defaultPort,
1890
+ portExplicit: false,
1891
+ open: true,
1892
+ embedMode: false,
1893
+ foreground: false,
1894
+ daemon: false
1895
+ };
1276
1896
  let targetSet = false;
1277
1897
  for (let i = 0; i < argv.length; i++) {
1278
1898
  const a = argv[i];
@@ -1286,6 +1906,14 @@ function parseArgs(argv) {
1286
1906
  args.embedMode = true;
1287
1907
  continue;
1288
1908
  }
1909
+ if (a === "-f" || a === "--foreground") {
1910
+ args.foreground = true;
1911
+ continue;
1912
+ }
1913
+ if (a === "--daemon") {
1914
+ args.daemon = true;
1915
+ continue;
1916
+ }
1289
1917
  if (a === "--port") {
1290
1918
  const v = argv[++i];
1291
1919
  if (!v) throw new Error("--port requires a number");
@@ -1309,26 +1937,24 @@ function parseArgs(argv) {
1309
1937
  targetSet = true;
1310
1938
  }
1311
1939
  if (args.embedMode) args.open = false;
1940
+ if (args.portExplicit || args.palette !== void 0 || args.embedMode) args.foreground = true;
1312
1941
  return { kind: "run", args };
1313
1942
  }
1314
1943
  function detectRoot(target) {
1315
- const abs = path11.resolve(target);
1316
- if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
1317
- const st = statSync(abs);
1944
+ const abs = path14.resolve(target);
1945
+ if (!existsSync2(abs)) throw new Error(`Path does not exist: ${abs}`);
1946
+ const st = statSync4(abs);
1318
1947
  if (st.isDirectory()) {
1319
- return {
1320
- rootAbsPath: abs,
1321
- rootInfo: { rootKind: "dir", rootRelPath: "", rootName: path11.basename(abs) }
1322
- };
1948
+ return { absPath: abs, id: deriveRootId(abs, /* @__PURE__ */ new Set()), kind: "dir" };
1323
1949
  }
1324
1950
  if (st.isFile()) {
1951
+ const dir = path14.dirname(abs);
1325
1952
  return {
1326
- rootAbsPath: path11.dirname(abs),
1327
- rootInfo: {
1328
- rootKind: "file",
1329
- rootRelPath: path11.basename(abs),
1330
- rootName: path11.basename(abs)
1331
- }
1953
+ absPath: dir,
1954
+ id: deriveRootId(dir, /* @__PURE__ */ new Set()),
1955
+ name: path14.basename(abs),
1956
+ kind: "file",
1957
+ filePath: path14.basename(abs)
1332
1958
  };
1333
1959
  }
1334
1960
  throw new Error(`Unsupported path type: ${abs}`);
@@ -1377,48 +2003,41 @@ Find the process with: ${portFinderHint(port)}`
1377
2003
  }
1378
2004
  throw new Error("Could not bind a port (tried 10 in a row).");
1379
2005
  }
1380
- async function main() {
1381
- const argv = process.argv.slice(2);
1382
- if (argv[0] === "config") {
1383
- const code = await runConfigSubcommand(argv.slice(1));
1384
- process.exit(code);
1385
- }
1386
- let parsed;
1387
- try {
1388
- parsed = parseArgs(argv);
1389
- } catch (err) {
1390
- console.error(err.message);
1391
- printUsage();
1392
- process.exit(2);
1393
- }
1394
- if (parsed.kind === "help") {
1395
- printUsage();
1396
- process.exit(0);
1397
- }
1398
- if (parsed.kind === "version") {
1399
- console.log(readVersion());
1400
- process.exit(0);
1401
- }
1402
- const args = parsed.args;
1403
- const { rootAbsPath, rootInfo } = detectRoot(args.target);
1404
- const here = path11.dirname(fileURLToPath(import.meta.url));
1405
- const clientDirCandidates = [
1406
- path11.resolve(here, "../dist/client"),
1407
- path11.resolve(here, "../../dist/client")
2006
+ function requireClientDir() {
2007
+ const here = path14.dirname(fileURLToPath(import.meta.url));
2008
+ const candidates = [
2009
+ path14.resolve(here, "../dist/client"),
2010
+ path14.resolve(here, "../../dist/client")
1408
2011
  ];
1409
- const clientDir = clientDirCandidates.find(existsSync);
1410
- if (!clientDir) {
1411
- console.error("Client bundle not found. Run `npm run build:client` first.");
1412
- process.exit(1);
1413
- }
1414
- const app = await createServer({ rootAbsPath, rootInfo, clientDir, paletteOverride: args.palette, embedMode: args.embedMode });
2012
+ const dir = candidates.find(existsSync2);
2013
+ if (!dir) throw new Error("Client bundle not found. Run `npm run build:client` first.");
2014
+ return dir;
2015
+ }
2016
+ function idleTimeoutMs() {
2017
+ const raw = process.env.MDVIEW_IDLE_TIMEOUT;
2018
+ if (raw === void 0) return 0;
2019
+ const n = Number(raw);
2020
+ if (!Number.isFinite(n) || n < 0) return 0;
2021
+ return Math.round(n * 6e4);
2022
+ }
2023
+ async function runForeground(args) {
2024
+ const root = detectRoot(args.target);
2025
+ const clientDir = requireClientDir();
2026
+ const { app } = await createServer({
2027
+ roots: [root],
2028
+ clientDir,
2029
+ ...args.palette === void 0 ? {} : { paletteOverride: args.palette },
2030
+ embedMode: args.embedMode
2031
+ });
1415
2032
  const boundPort = await listen(app, args.port, args.portExplicit);
1416
- const url = rootInfo.rootKind === "file" ? `http://127.0.0.1:${boundPort}/?file=${encodeURIComponent(rootInfo.rootRelPath)}` : `http://127.0.0.1:${boundPort}/`;
2033
+ const host = args.embedMode ? "127.0.0.1" : await resolveDisplayHost();
2034
+ const url = root.kind === "file" ? `http://${host}:${boundPort}/?file=${encodeURIComponent(`${root.id}/${root.filePath}`)}` : `http://${host}:${boundPort}/`;
1417
2035
  if (args.embedMode) {
1418
- process.stdout.write(JSON.stringify({ event: "ready", url, port: boundPort }) + "\n");
2036
+ process.stdout.write(`${JSON.stringify({ event: "ready", url, port: boundPort })}
2037
+ `);
1419
2038
  } else {
1420
2039
  console.log(`mdview \u2192 ${url}`);
1421
- console.log(`watching: ${rootAbsPath}`);
2040
+ console.log(`watching: ${root.absPath}`);
1422
2041
  }
1423
2042
  if (args.open) await openBrowser(url);
1424
2043
  let shuttingDown = false;
@@ -1448,6 +2067,104 @@ async function main() {
1448
2067
  process.stdin.resume();
1449
2068
  }
1450
2069
  }
2070
+ async function runDaemon(args) {
2071
+ const clientDir = requireClientDir();
2072
+ const startedAt = Date.now();
2073
+ let idleTimer;
2074
+ const server = await createServer({
2075
+ roots: toRootSpecs(readWorkspace()),
2076
+ clientDir,
2077
+ identity: { pid: process.pid, startedAt },
2078
+ onSseClientsChanged: (count) => {
2079
+ if (count > 0) {
2080
+ if (idleTimer) clearTimeout(idleTimer);
2081
+ idleTimer = void 0;
2082
+ } else {
2083
+ armIdle();
2084
+ }
2085
+ }
2086
+ });
2087
+ const boundPort = await listen(server.app, args.port, args.portExplicit);
2088
+ writeDaemonState({ pid: process.pid, port: boundPort, startedAt });
2089
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon pid ${process.pid} on port ${boundPort}`);
2090
+ const wsWatcher = chokidar3.watch(workspaceFilePath(), {
2091
+ ignoreInitial: true,
2092
+ persistent: true,
2093
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
2094
+ });
2095
+ const reload = () => {
2096
+ void server.setRoots(toRootSpecs(readWorkspace())).catch((err) => {
2097
+ console.error(`[mdview] failed to apply workspace change: ${String(err)}`);
2098
+ });
2099
+ };
2100
+ wsWatcher.on("add", reload);
2101
+ wsWatcher.on("change", reload);
2102
+ wsWatcher.on("unlink", reload);
2103
+ let shuttingDown = false;
2104
+ const shutdown = async (why) => {
2105
+ if (shuttingDown) process.exit(130);
2106
+ shuttingDown = true;
2107
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon stopping (${why})`);
2108
+ clearDaemonState();
2109
+ const timeout = new Promise((resolve) => setTimeout(resolve, 1500));
2110
+ await Promise.race([
2111
+ Promise.all([server.app.close().catch(() => void 0), wsWatcher.close()]).then(
2112
+ () => void 0
2113
+ ),
2114
+ timeout
2115
+ ]);
2116
+ process.exit(0);
2117
+ };
2118
+ function armIdle() {
2119
+ const ms = idleTimeoutMs();
2120
+ if (ms <= 0) return;
2121
+ if (idleTimer) clearTimeout(idleTimer);
2122
+ idleTimer = setTimeout(() => void shutdown("idle"), ms);
2123
+ }
2124
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2125
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2126
+ if (process.platform !== "win32") process.on("SIGHUP", () => void shutdown("SIGHUP"));
2127
+ armIdle();
2128
+ }
2129
+ async function runAttach(args) {
2130
+ const added = addRoot(args.target);
2131
+ const state = await ensureDaemon(fileURLToPath(import.meta.url));
2132
+ await waitForRoot(state.port, added.root.id);
2133
+ const host = await resolveDisplayHost();
2134
+ const base = `http://${host}:${state.port}/`;
2135
+ const url = added.filePath ? `${base}?file=${encodeURIComponent(added.filePath)}` : `${base}?root=${encodeURIComponent(added.focusPath)}`;
2136
+ console.log(`mdview \u2192 ${url}`);
2137
+ console.log(`${added.changed ? "added" : "open"}: ${added.root.path} (${added.root.id})`);
2138
+ if (args.open) await openBrowser(url);
2139
+ }
2140
+ async function main() {
2141
+ const argv = process.argv.slice(2);
2142
+ const sub = argv[0];
2143
+ if (sub === "config") process.exit(await runConfigSubcommand(argv.slice(1)));
2144
+ if (sub === "ls" || sub === "status") process.exit(await runListCommand());
2145
+ if (sub === "rm") process.exit(await runRemoveCommand(argv.slice(1)));
2146
+ if (sub === "stop") process.exit(await runStopCommand());
2147
+ let parsed;
2148
+ try {
2149
+ parsed = parseArgs(argv, envPort() ?? DEFAULT_PORT);
2150
+ } catch (err) {
2151
+ console.error(err.message);
2152
+ printUsage();
2153
+ process.exit(2);
2154
+ }
2155
+ if (parsed.kind === "help") {
2156
+ printUsage();
2157
+ process.exit(0);
2158
+ }
2159
+ if (parsed.kind === "version") {
2160
+ console.log(readVersion());
2161
+ process.exit(0);
2162
+ }
2163
+ const args = parsed.args;
2164
+ if (args.daemon) return runDaemon(args);
2165
+ if (args.foreground) return runForeground(args);
2166
+ return runAttach(args);
2167
+ }
1451
2168
  function formatError(err) {
1452
2169
  if (!(err instanceof Error)) return null;
1453
2170
  const errno = err;
@@ -1460,7 +2177,16 @@ function formatError(err) {
1460
2177
  const msg = err.message;
1461
2178
  if (msg.startsWith("Port ")) return { message: msg, code: 1 };
1462
2179
  if (msg.startsWith("Client bundle not found")) return { message: `mdview: ${msg}`, code: 1 };
1463
- if (msg.startsWith("Path does not exist") || msg.startsWith("Unsupported path type") || msg.startsWith("Could not bind a port") || msg.startsWith("Unexpected argument")) {
2180
+ const EXPECTED = [
2181
+ "Path does not exist",
2182
+ "Unsupported path type",
2183
+ "Could not bind a port",
2184
+ "Unexpected argument",
2185
+ "Workspace is full",
2186
+ "Timed out waiting for the mdview background server",
2187
+ "The mdview background server did not start"
2188
+ ];
2189
+ if (EXPECTED.some((prefix) => msg.startsWith(prefix))) {
1464
2190
  return { message: `mdview: ${msg}`, code: 1 };
1465
2191
  }
1466
2192
  return null;
@@ -1492,6 +2218,8 @@ if (isEntryPoint) {
1492
2218
  });
1493
2219
  }
1494
2220
  export {
2221
+ DEFAULT_PORT,
2222
+ idleTimeoutMs,
1495
2223
  parseArgs
1496
2224
  };
1497
2225
  //# sourceMappingURL=mdview.mjs.map