@hardikg/mdview 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +72 -6
  3. package/bin/mdview.mjs +924 -212
  4. package/dist/client/assets/abnfDiagram-VCTEODGH-DafRYfx3.js +1 -0
  5. package/dist/client/assets/{arc-ycQp8V_W.js → arc-BqKZ1e2b.js} +1 -1
  6. package/dist/client/assets/architectureDiagram-5GKGNRK7-_94hPNdL.js +36 -0
  7. package/dist/client/assets/blockDiagram-NRAW4CY4-BegJqAzx.js +129 -0
  8. package/dist/client/assets/c4Diagram-UCG6FXSJ-DYBJe_j-.js +38 -0
  9. package/dist/client/assets/channel-CzMcbnuI.js +1 -0
  10. package/dist/client/assets/{chunk-QZHKN3VN-OHnkQvdC.js → chunk-2Q5K7J3B-CarNPuoG.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-IM3YKAF3.js → chunk-5VM5RSS4-Bmb8JM_j.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-UF3KQYdE.js → chunk-F27PBJKO-BhLIUyGL.js} +1 -1
  13. package/dist/client/assets/chunk-G27WJ6UU-B_7t7iym.js +231 -0
  14. package/dist/client/assets/{chunk-4BX2VUAB-BXnUK3rw.js → chunk-JWPE2WC7-6WDfZVE5.js} +1 -1
  15. package/dist/client/assets/{chunk-727SXJPM-DKPIb06m.js → chunk-LCL6LL3I-BnM13fhY.js} +4 -4
  16. package/dist/client/assets/chunk-POPQ4Y6H-DQMWRZKQ.js +1 -0
  17. package/dist/client/assets/chunk-SVP7TREG-DPpw4ke1.js +88 -0
  18. package/dist/client/assets/{chunk-55IACEB6-BkF4Ba-V.js → chunk-XXDRQBXY-ByC-zeqG.js} +1 -1
  19. package/dist/client/assets/classDiagram-DTDB5LWJ-RvAHOW66.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-JRS7N3AN-RvAHOW66.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-S5V4N54A-D0XjEe4_.js → cose-bilkent-JH36ORCC-CiRnFlQp.js} +1 -1
  22. package/dist/client/assets/cynefin-OW5HDTMX-DqTFYole.js +178 -0
  23. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-B1WAhR06.js +62 -0
  24. package/dist/client/assets/cytoscape.esm-BjAbmLdb.js +331 -0
  25. package/dist/client/assets/dagre-3AP2YEHR-DQK5a43-.js +4 -0
  26. package/dist/client/assets/diagram-S7CK7UJ4-BMQ8dao_.js +30 -0
  27. package/dist/client/assets/diagram-UQ7AKVKN-CT7KIFG4.js +41 -0
  28. package/dist/client/assets/{diagram-KO2AKTUF-Zu0g_DOV.js → diagram-VSXAHHWV-By0SbfXs.js} +3 -3
  29. package/dist/client/assets/diagram-VX7I27RA-DwshpqNi.js +24 -0
  30. package/dist/client/assets/{diagram-LMA3HP47-bPbOm4sL.js → diagram-Z3DM3KII-CG7wA7Kr.js} +2 -2
  31. package/dist/client/assets/ebnfDiagram-PWID7BFC-XlNRbCZw.js +1 -0
  32. package/dist/client/assets/erDiagram-SSCWMZ5O-BtDUPrpL.js +99 -0
  33. package/dist/client/assets/flowDiagram-A5DVABFB-DKJ5hkO-.js +168 -0
  34. package/dist/client/assets/ganttDiagram-EL5Y4UJY-B8rnbHA1.js +292 -0
  35. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-Du_KX2ik.js → gitGraphDiagram-WWUBYQGX-Conc20s7.js} +7 -7
  36. package/dist/client/assets/index-CCiGr1hz.js +2 -0
  37. package/dist/client/assets/index-CJ5_G3gC.css +1 -0
  38. package/dist/client/assets/infoDiagram-RXCK75RN-DNuHlrdu.js +2 -0
  39. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-DwkHFgb8.js → ishikawaDiagram-5VMMS53U-DkYaQbSy.js} +5 -5
  40. package/dist/client/assets/{journeyDiagram-JHISSGLW-nEajRNFZ.js → journeyDiagram-EYS64GPL-BdoQruin.js} +1 -1
  41. package/dist/client/assets/{kanban-definition-UN3LZRKU-Dvn5P58B.js → kanban-definition-3QL26DDD-BD3QQjwp.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-DKu9QfZ6.js +1 -0
  46. package/dist/client/assets/{linear-BYNRmGYt.js → linear-DMmTRDu2.js} +1 -1
  47. package/dist/client/assets/mermaid.core-2ESgyZ95.js +312 -0
  48. package/dist/client/assets/{mindmap-definition-RKZ34NQL-WIFgsLLz.js → mindmap-definition-FBJOCRG2-CokK9_yg.js} +1 -1
  49. package/dist/client/assets/pegDiagram-XKGWAZYB-CUAn26Uv.js +1 -0
  50. package/dist/client/assets/pieDiagram-E7YTZNPT-BT-XLIyl.js +39 -0
  51. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-B0Gh1bMy.js → quadrantDiagram-AXDQQJYC-dDr-ZgjR.js} +3 -3
  52. package/dist/client/assets/railroadDiagram-O6MQD6OU-B5rCSICc.js +1 -0
  53. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DcIINxfV.js → requirementDiagram-EFPCY7ZU-DyI3FGB5.js} +5 -5
  54. package/dist/client/assets/sankeyDiagram-P5KCCOFB-CGXj8eCe.js +40 -0
  55. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-C3yr2FVq.js +162 -0
  56. package/dist/client/assets/sizeCapture-X5ZJPWSS-CCnkSxko.js +1 -0
  57. package/dist/client/assets/stateDiagram-HBIQ2CUA-BG5Dshx6.js +1 -0
  58. package/dist/client/assets/stateDiagram-v2-4QOOHH4V-BdraFugH.js +1 -0
  59. package/dist/client/assets/swimlanes-XN3QIQJK-MGfvzMlQ.js +1 -0
  60. package/dist/client/assets/swimlanesDiagram-VK2B7HYN-LQu07Za2.js +8 -0
  61. package/dist/client/assets/{timeline-definition-PNZ67QCA-DYWt9asA.js → timeline-definition-24CTP7MA-Bj3gMrOq.js} +3 -3
  62. package/dist/client/assets/vennDiagram-4TSXK5OY-CiIW_j8g.js +34 -0
  63. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Ch7IIKOr.js → wardleyDiagram-VM6X3IG4-DQTIyknP.js} +4 -4
  64. package/dist/client/assets/xychartDiagram-S5SC5T6Z-Bg0tzmhJ.js +7 -0
  65. package/dist/client/index.html +2 -2
  66. package/package.json +2 -2
  67. package/dist/client/assets/architectureDiagram-3BPJPVTR-CGT8FovT.js +0 -36
  68. package/dist/client/assets/blockDiagram-GPEHLZMM-N8A4M5KF.js +0 -132
  69. package/dist/client/assets/c4Diagram-AAUBKEIU-GOIrZjrm.js +0 -10
  70. package/dist/client/assets/channel-DsJAWOzB.js +0 -1
  71. package/dist/client/assets/chunk-2J33WTMH-DMiX8NYo.js +0 -1
  72. package/dist/client/assets/chunk-AQP2D5EJ-BFOc87IV.js +0 -231
  73. package/dist/client/assets/classDiagram-4FO5ZUOK-BD7T43t0.js +0 -1
  74. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-BD7T43t0.js +0 -1
  75. package/dist/client/assets/cytoscape.esm-D_LviqZs.js +0 -331
  76. package/dist/client/assets/dagre-BM42HDAG-1wR5aSmA.js +0 -4
  77. package/dist/client/assets/diagram-2AECGRRQ-B8L1OTOq.js +0 -43
  78. package/dist/client/assets/diagram-5GNKFQAL-BPGoGDNx.js +0 -10
  79. package/dist/client/assets/diagram-OG6HWLK6-DWW4aJ4C.js +0 -24
  80. package/dist/client/assets/erDiagram-TEJ5UH35-CUsMs05D.js +0 -85
  81. package/dist/client/assets/flowDiagram-I6XJVG4X-DZCURXAw.js +0 -162
  82. package/dist/client/assets/ganttDiagram-6RSMTGT7-CzMfUd_H.js +0 -292
  83. package/dist/client/assets/graph-CAnANduQ.js +0 -1
  84. package/dist/client/assets/index-BCdIdRge.js +0 -2
  85. package/dist/client/assets/index-_GInkOX2.css +0 -1
  86. package/dist/client/assets/infoDiagram-5YYISTIA-D81JEsUe.js +0 -2
  87. package/dist/client/assets/katex-DkKDou_j.js +0 -257
  88. package/dist/client/assets/katex.min-CfVKi3_s.css +0 -1
  89. package/dist/client/assets/katex.min-DBnXG_UH.js +0 -1
  90. package/dist/client/assets/layout-DGIYPm2g.js +0 -1
  91. package/dist/client/assets/mermaid.core-BJg17JjK.js +0 -308
  92. package/dist/client/assets/pieDiagram-4H26LBE5-DMgTQVGJ.js +0 -30
  93. package/dist/client/assets/sankeyDiagram-5OEKKPKP-CDnHepHs.js +0 -40
  94. package/dist/client/assets/sequenceDiagram-3UESZ5HK-D7MMEHjm.js +0 -162
  95. package/dist/client/assets/stateDiagram-AJRCARHV-Dvdvjjk8.js +0 -1
  96. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-I2g3TqMC.js +0 -1
  97. package/dist/client/assets/vennDiagram-CIIHVFJN-Bt0VUni_.js +0 -34
  98. package/dist/client/assets/wardley-L42UT6IY-DVy2v9KS.js +0 -173
  99. package/dist/client/assets/xychartDiagram-2RQKCTM6-Byds5kce.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
@@ -329,13 +331,430 @@ async function runIgnoreSubcommand(argv) {
329
331
  }
330
332
  }
331
333
 
334
+ // src/daemon/client.ts
335
+ import { spawn } from "child_process";
336
+ import { closeSync, openSync as openSync2, statSync as statSync2, truncateSync } from "fs";
337
+
338
+ // src/daemon/state.ts
339
+ import { mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
340
+ import path3 from "path";
341
+ function stateDir() {
342
+ return path3.dirname(globalConfigPath());
343
+ }
344
+ function daemonStatePath() {
345
+ return path3.join(stateDir(), "daemon.json");
346
+ }
347
+ function workspaceFilePath() {
348
+ return path3.join(stateDir(), "workspace.json");
349
+ }
350
+ function daemonLogPath() {
351
+ return path3.join(stateDir(), "daemon.log");
352
+ }
353
+ function daemonLockPath() {
354
+ return path3.join(stateDir(), "daemon.lock");
355
+ }
356
+ function writeJsonAtomic(file, value) {
357
+ mkdirSync(path3.dirname(file), { recursive: true, mode: 448 });
358
+ const tmp = `${file}.${process.pid}.tmp`;
359
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
360
+ `, { mode: 384 });
361
+ renameSync(tmp, file);
362
+ }
363
+ function readJsonSafe(file) {
364
+ try {
365
+ return JSON.parse(readFileSync(file, "utf8"));
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+ function isPositiveInt(v) {
371
+ return typeof v === "number" && Number.isInteger(v) && v > 0;
372
+ }
373
+ function readDaemonState() {
374
+ const raw = readJsonSafe(daemonStatePath());
375
+ if (!raw || typeof raw !== "object") return null;
376
+ const { pid, port, startedAt } = raw;
377
+ if (!isPositiveInt(pid) || !isPositiveInt(port) || !isPositiveInt(startedAt)) return null;
378
+ if (port > 65535) return null;
379
+ return { pid, port, startedAt };
380
+ }
381
+ function writeDaemonState(state) {
382
+ writeJsonAtomic(daemonStatePath(), state);
383
+ }
384
+ function clearDaemonState() {
385
+ rmSync(daemonStatePath(), { force: true });
386
+ }
387
+ function tryTakeSpawnLock(staleAfterMs = 1e4) {
388
+ mkdirSync(stateDir(), { recursive: true, mode: 448 });
389
+ const file = daemonLockPath();
390
+ try {
391
+ openSync(file, "wx");
392
+ return true;
393
+ } catch (err) {
394
+ if (err.code !== "EEXIST") throw err;
395
+ }
396
+ try {
397
+ if (Date.now() - statSync(file).mtimeMs < staleAfterMs) return false;
398
+ rmSync(file, { force: true });
399
+ openSync(file, "wx");
400
+ return true;
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+ function releaseSpawnLock() {
406
+ rmSync(daemonLockPath(), { force: true });
407
+ }
408
+
409
+ // src/daemon/client.ts
410
+ var HEALTH_TIMEOUT_MS = 500;
411
+ var START_TIMEOUT_MS = 8e3;
412
+ var POLL_INTERVAL_MS = 50;
413
+ var LOG_TRUNCATE_BYTES = 1024 * 1024;
414
+ function sleep(ms) {
415
+ return new Promise((resolve) => setTimeout(resolve, ms));
416
+ }
417
+ async function probeHealth(port) {
418
+ try {
419
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
420
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS)
421
+ });
422
+ if (!res.ok) return null;
423
+ const body = await res.json();
424
+ if (typeof body.pid !== "number" || typeof body.startedAt !== "number") return null;
425
+ return {
426
+ pid: body.pid,
427
+ startedAt: body.startedAt,
428
+ roots: Array.isArray(body.roots) ? body.roots.filter((r) => typeof r === "string") : []
429
+ };
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+ async function findLiveDaemon() {
435
+ const state = readDaemonState();
436
+ if (!state) return null;
437
+ const health = await probeHealth(state.port);
438
+ if (!health) return null;
439
+ if (health.pid !== state.pid || health.startedAt !== state.startedAt) return null;
440
+ return { state, health };
441
+ }
442
+ function spawnDaemon(cliEntry) {
443
+ try {
444
+ if (statSync2(daemonLogPath()).size > LOG_TRUNCATE_BYTES) {
445
+ truncateSync(daemonLogPath(), 0);
446
+ }
447
+ } catch {
448
+ }
449
+ const log = openSync2(daemonLogPath(), "a");
450
+ try {
451
+ const child = spawn(process.execPath, [cliEntry, "--daemon"], {
452
+ detached: true,
453
+ // Nothing may hold the terminal: stdout and stderr go to the log so the
454
+ // parent can exit while the daemon keeps running.
455
+ stdio: ["ignore", log, log]
456
+ });
457
+ child.unref();
458
+ return child;
459
+ } finally {
460
+ closeSync(log);
461
+ }
462
+ }
463
+ async function ensureDaemon(cliEntry) {
464
+ const existing = await findLiveDaemon();
465
+ if (existing) return existing.state;
466
+ if (!tryTakeSpawnLock()) {
467
+ const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
468
+ if (found) return found.state;
469
+ throw new Error("Timed out waiting for the mdview background server to start.");
470
+ }
471
+ try {
472
+ clearDaemonState();
473
+ spawnDaemon(cliEntry);
474
+ const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
475
+ if (!found) {
476
+ throw new Error(
477
+ `The mdview background server did not start. See ${daemonLogPath()} for why.`
478
+ );
479
+ }
480
+ return found.state;
481
+ } finally {
482
+ releaseSpawnLock();
483
+ }
484
+ }
485
+ async function waitFor(attempt, timeoutMs) {
486
+ const deadline = Date.now() + timeoutMs;
487
+ for (; ; ) {
488
+ const result = await attempt();
489
+ if (result) return result;
490
+ if (Date.now() >= deadline) return null;
491
+ await sleep(POLL_INTERVAL_MS);
492
+ }
493
+ }
494
+ async function waitForRoot(port, rootId, timeoutMs = 3e3) {
495
+ const found = await waitFor(async () => {
496
+ const health = await probeHealth(port);
497
+ return health?.roots.includes(rootId) ? health : null;
498
+ }, timeoutMs);
499
+ return found !== null;
500
+ }
501
+ async function stopDaemon() {
502
+ const state = readDaemonState();
503
+ if (!state) return { kind: "not-running" };
504
+ const live = await findLiveDaemon();
505
+ if (!live) {
506
+ clearDaemonState();
507
+ return { kind: "stale" };
508
+ }
509
+ try {
510
+ if (process.platform === "win32") {
511
+ process.kill(live.state.pid);
512
+ } else {
513
+ process.kill(live.state.pid, "SIGTERM");
514
+ }
515
+ } catch {
516
+ clearDaemonState();
517
+ return { kind: "stale" };
518
+ }
519
+ await waitFor(async () => await probeHealth(live.state.port) === null ? true : null, 3e3);
520
+ clearDaemonState();
521
+ return { kind: "stopped", pid: live.state.pid };
522
+ }
523
+
524
+ // src/daemon/workspace-file.ts
525
+ import { existsSync, statSync as statSync3 } from "fs";
526
+ import path5 from "path";
527
+
528
+ // src/server/workspace.ts
529
+ import path4 from "path";
530
+ var ROOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
531
+ function isValidRootId(id) {
532
+ return id !== "." && id !== ".." && ROOT_ID_PATTERN.test(id);
533
+ }
534
+ function deriveRootId(absPath, taken) {
535
+ const slug = path4.basename(absPath).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-.]+$/, "").slice(0, 64);
536
+ const stem = isValidRootId(slug) ? slug : "root";
537
+ if (!taken.has(stem)) return stem;
538
+ for (let n = 2; ; n++) {
539
+ const candidate = `${stem.slice(0, 60)}-${n}`;
540
+ if (!taken.has(candidate)) return candidate;
541
+ }
542
+ }
543
+ function resolveRootId(spec, taken) {
544
+ if (spec.id && isValidRootId(spec.id) && !taken.has(spec.id)) return spec.id;
545
+ return deriveRootId(spec.absPath, taken);
546
+ }
547
+ function describeRoot(spec, id) {
548
+ return {
549
+ id,
550
+ name: spec.name ?? path4.basename(spec.absPath),
551
+ kind: spec.kind,
552
+ filePath: spec.kind === "file" ? spec.filePath ?? "" : ""
553
+ };
554
+ }
555
+ function parseWorkspacePath(roots, wsPath) {
556
+ const primary = roots[0];
557
+ if (!primary) return null;
558
+ const trimmed = wsPath.replace(/^\/+/, "");
559
+ const slash = trimmed.indexOf("/");
560
+ const head = slash === -1 ? trimmed : trimmed.slice(0, slash);
561
+ const match = roots.find((r) => r.root.id === head);
562
+ if (!match) return { state: primary, rel: trimmed };
563
+ return { state: match, rel: slash === -1 ? "" : trimmed.slice(slash + 1) };
564
+ }
565
+ function toWorkspacePath(rootId, rel) {
566
+ return rel ? `${rootId}/${rel}` : rootId;
567
+ }
568
+
569
+ // src/daemon/workspace-file.ts
570
+ var MAX_ROOTS = 8;
571
+ function validateRecord(raw) {
572
+ if (!raw || typeof raw !== "object") return null;
573
+ const r = raw;
574
+ if (typeof r.id !== "string" || !isValidRootId(r.id)) return null;
575
+ if (typeof r.path !== "string" || !path5.isAbsolute(r.path)) return null;
576
+ if (r.kind !== "file" && r.kind !== "dir") return null;
577
+ const out = { id: r.id, path: r.path, kind: r.kind };
578
+ if (typeof r.name === "string" && r.name.length > 0 && r.name.length <= 128) out.name = r.name;
579
+ if (r.kind === "file") {
580
+ if (typeof r.filePath !== "string" || r.filePath.length === 0) return null;
581
+ if (r.filePath.includes("/") || r.filePath.includes("\\")) return null;
582
+ out.filePath = r.filePath;
583
+ }
584
+ return out;
585
+ }
586
+ function readWorkspace() {
587
+ const raw = readJsonSafe(workspaceFilePath());
588
+ if (!raw || typeof raw !== "object") return { roots: [] };
589
+ const list = raw.roots;
590
+ if (!Array.isArray(list)) return { roots: [] };
591
+ const seen = /* @__PURE__ */ new Set();
592
+ const roots = [];
593
+ for (const entry of list.slice(0, MAX_ROOTS)) {
594
+ const rec = validateRecord(entry);
595
+ if (!rec || seen.has(rec.id)) continue;
596
+ seen.add(rec.id);
597
+ roots.push(rec);
598
+ }
599
+ return { roots };
600
+ }
601
+ function writeWorkspace(record) {
602
+ writeJsonAtomic(workspaceFilePath(), record);
603
+ }
604
+ function toRootSpecs(record) {
605
+ return record.roots.map((r) => ({
606
+ absPath: r.path,
607
+ id: r.id,
608
+ ...r.name === void 0 ? {} : { name: r.name },
609
+ kind: r.kind,
610
+ ...r.filePath === void 0 ? {} : { filePath: r.filePath }
611
+ }));
612
+ }
613
+ function resolveTarget(target) {
614
+ const abs = path5.resolve(target);
615
+ if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
616
+ const st = statSync3(abs);
617
+ if (st.isDirectory()) {
618
+ return { absPath: abs, kind: "dir", filePath: "", name: path5.basename(abs) };
619
+ }
620
+ if (st.isFile()) {
621
+ const dir = path5.dirname(abs);
622
+ return { absPath: dir, kind: "file", filePath: path5.basename(abs), name: path5.basename(abs) };
623
+ }
624
+ throw new Error(`Unsupported path type: ${abs}`);
625
+ }
626
+ function findCovering(roots, abs) {
627
+ let best = null;
628
+ for (const root of roots) {
629
+ if (root.kind !== "dir" && root.path !== abs) continue;
630
+ const rel = path5.relative(root.path, abs);
631
+ if (rel.startsWith("..") || path5.isAbsolute(rel)) continue;
632
+ if (!best || root.path.length > best.root.path.length) {
633
+ best = { root, rel: rel.split(path5.sep).join("/") };
634
+ }
635
+ }
636
+ return best;
637
+ }
638
+ function addRoot(target) {
639
+ const resolved = resolveTarget(target);
640
+ const record = readWorkspace();
641
+ const fullPath = resolved.kind === "file" ? path5.join(resolved.absPath, resolved.filePath) : resolved.absPath;
642
+ const covering = findCovering(record.roots, fullPath);
643
+ if (covering) {
644
+ const wsPath = covering.rel ? `${covering.root.id}/${covering.rel}` : covering.root.id;
645
+ return {
646
+ root: covering.root,
647
+ filePath: resolved.kind === "file" ? wsPath : "",
648
+ focusPath: resolved.kind === "file" ? "" : wsPath,
649
+ changed: false
650
+ };
651
+ }
652
+ if (record.roots.length >= MAX_ROOTS) {
653
+ throw new Error(
654
+ `Workspace is full (${MAX_ROOTS} folders). Close one with 'mdview rm <name>' first.`
655
+ );
656
+ }
657
+ const id = deriveRootId(resolved.absPath, new Set(record.roots.map((r) => r.id)));
658
+ const root = {
659
+ id,
660
+ path: resolved.absPath,
661
+ kind: resolved.kind,
662
+ ...resolved.kind === "file" ? { name: resolved.name, filePath: resolved.filePath } : {}
663
+ };
664
+ record.roots.push(root);
665
+ writeWorkspace(record);
666
+ return {
667
+ root,
668
+ filePath: resolved.kind === "file" ? `${id}/${resolved.filePath}` : "",
669
+ focusPath: resolved.kind === "file" ? "" : id,
670
+ changed: true
671
+ };
672
+ }
673
+ function removeRoot(idOrPath) {
674
+ const record = readWorkspace();
675
+ const abs = path5.resolve(idOrPath);
676
+ const idx = record.roots.findIndex((r) => r.id === idOrPath || r.path === abs);
677
+ if (idx === -1) return null;
678
+ const [removed] = record.roots.splice(idx, 1);
679
+ writeWorkspace(record);
680
+ return removed ?? null;
681
+ }
682
+
683
+ // src/cli-workspace.ts
684
+ function tilde(abs) {
685
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
686
+ return home && abs.startsWith(home) ? `~${abs.slice(home.length)}` : abs;
687
+ }
688
+ async function runListCommand() {
689
+ const { roots } = readWorkspace();
690
+ const live = await findLiveDaemon();
691
+ if (roots.length === 0) {
692
+ console.log("No folders open. Run `mdview <path>` to add one.");
693
+ } else {
694
+ const open = new Set(live?.health.roots ?? []);
695
+ const idWidth = Math.max(...roots.map((r) => r.id.length));
696
+ for (const [i, r] of roots.entries()) {
697
+ const mark = live ? open.has(r.id) ? "\u25CF" : "\u25CB" : " ";
698
+ const label = i === 0 ? " (primary)" : "";
699
+ const suffix = r.kind === "file" ? ` \u2192 ${r.filePath}` : "";
700
+ console.log(`${mark} ${r.id.padEnd(idWidth)} ${tilde(r.path)}${suffix}${label}`);
701
+ }
702
+ if (roots.length >= MAX_ROOTS) {
703
+ console.log(`
704
+ Workspace is full (${MAX_ROOTS}). Remove one with \`mdview rm <name>\`.`);
705
+ }
706
+ }
707
+ console.log("");
708
+ if (live) {
709
+ console.log(`server: http://mdview.localhost:${live.state.port}/ (pid ${live.state.pid})`);
710
+ console.log(`logs: ${daemonLogPath()}`);
711
+ } else {
712
+ console.log("server: not running \u2014 the next `mdview <path>` will start it");
713
+ }
714
+ return 0;
715
+ }
716
+ async function runRemoveCommand(argv) {
717
+ const targets = argv.filter((a) => !a.startsWith("-"));
718
+ if (targets.length === 0) {
719
+ console.error("Usage: mdview rm <name|path>\u2026");
720
+ return 2;
721
+ }
722
+ let removedAny = false;
723
+ for (const target of targets) {
724
+ const removed = removeRoot(target);
725
+ if (removed) {
726
+ removedAny = true;
727
+ console.log(`Removed ${removed.id} (${tilde(removed.path)})`);
728
+ } else {
729
+ console.error(`Not open: ${target}`);
730
+ }
731
+ }
732
+ if (!removedAny) return 2;
733
+ console.log("Open tabs will update on their own.");
734
+ return 0;
735
+ }
736
+ async function runStopCommand() {
737
+ const outcome = await stopDaemon();
738
+ switch (outcome.kind) {
739
+ case "stopped":
740
+ console.log(`mdview: stopped (pid ${outcome.pid})`);
741
+ return 0;
742
+ case "not-running":
743
+ console.log("mdview: not running");
744
+ return 0;
745
+ case "stale":
746
+ console.log("mdview: not running (cleared a stale record)");
747
+ return 0;
748
+ }
749
+ }
750
+
332
751
  // src/server/index.ts
333
- import path10 from "path";
752
+ import path13 from "path";
334
753
  import chokidar2 from "chokidar";
335
754
  import Fastify from "fastify";
336
755
 
337
756
  // src/server/fs/ignore.ts
338
- import path3 from "path";
757
+ import path6 from "path";
339
758
  var DEFAULT_IGNORED_DIRS2 = new Set(DEFAULT_IGNORED_DIRS);
340
759
  function buildIgnoreSet(extra = []) {
341
760
  return /* @__PURE__ */ new Set([...DEFAULT_IGNORED_DIRS2, ...extra]);
@@ -344,9 +763,9 @@ function isDirIgnored(name, set) {
344
763
  return name.startsWith(".") || set.has(name);
345
764
  }
346
765
  function isPathIgnored(abs, root, set) {
347
- const rel = path3.relative(root, abs);
766
+ const rel = path6.relative(root, abs);
348
767
  if (rel === "" || rel.startsWith("..")) return false;
349
- for (const seg of rel.split(path3.sep)) {
768
+ for (const seg of rel.split(path6.sep)) {
350
769
  if (!seg) continue;
351
770
  if (seg.startsWith(".")) return true;
352
771
  if (set.has(seg)) return true;
@@ -354,21 +773,41 @@ function isPathIgnored(abs, root, set) {
354
773
  return false;
355
774
  }
356
775
 
776
+ // src/server/hosts.ts
777
+ var LOOPBACK_NAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
778
+ var MAX_HOST_LEN = 253;
779
+ function hostOnly(value) {
780
+ const h = value.trim().toLowerCase();
781
+ if (h.startsWith("[")) {
782
+ const end = h.indexOf("]");
783
+ return end === -1 ? h : h.slice(0, end + 1);
784
+ }
785
+ if (h.indexOf(":") !== h.lastIndexOf(":")) return h;
786
+ const colon = h.indexOf(":");
787
+ return colon === -1 ? h : h.slice(0, colon);
788
+ }
789
+ function isAllowedHost(value) {
790
+ if (!value || value.length > MAX_HOST_LEN) return false;
791
+ if (!/^[\x21-\x7e]+$/.test(value)) return false;
792
+ const host = hostOnly(value);
793
+ return LOOPBACK_NAMES.has(host) || /^[a-z0-9][a-z0-9.-]*\.localhost$/.test(host);
794
+ }
795
+
357
796
  // src/server/routes/api-asset.ts
358
797
  import { createReadStream } from "fs";
359
798
  import { stat } from "fs/promises";
360
- import path5 from "path";
799
+ import path8 from "path";
361
800
 
362
801
  // src/server/fs/resolve.ts
363
- import path4 from "path";
802
+ import path7 from "path";
364
803
  function resolveSafePath(root, relPath) {
365
- if (path4.isAbsolute(relPath)) {
804
+ if (path7.isAbsolute(relPath)) {
366
805
  throw new Error(`Refusing absolute path: ${relPath}`);
367
806
  }
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)) {
807
+ const absRoot = path7.resolve(root);
808
+ const candidate = path7.resolve(absRoot, relPath);
809
+ const rel = path7.relative(absRoot, candidate);
810
+ if (rel.startsWith("..") || path7.isAbsolute(rel)) {
372
811
  throw new Error(`Path resolves outside root: ${relPath}`);
373
812
  }
374
813
  return candidate;
@@ -393,12 +832,16 @@ var MIME = {
393
832
  ".pdf": "application/pdf",
394
833
  ".txt": "text/plain; charset=utf-8"
395
834
  };
396
- function registerApiAsset(app, rootAbsPath) {
835
+ function registerApiAsset(app, roots) {
397
836
  app.get("/__asset/*", async (req, reply) => {
398
- const rel = (req.params["*"] ?? "").replace(/^\/+/, "");
837
+ const wsPath = (req.params["*"] ?? "").replace(/^\/+/, "");
838
+ const resolved = parseWorkspacePath(roots, wsPath);
839
+ if (!resolved) {
840
+ return reply.code(404).send({ error: "Asset not found" });
841
+ }
399
842
  let abs;
400
843
  try {
401
- abs = resolveSafePath(rootAbsPath, rel);
844
+ abs = resolveSafePath(resolved.state.absPath, resolved.rel);
402
845
  } catch (err) {
403
846
  return reply.code(400).send({ error: err.message });
404
847
  }
@@ -411,7 +854,7 @@ function registerApiAsset(app, rootAbsPath) {
411
854
  if (!st.isFile()) {
412
855
  return reply.code(404).send({ error: "Not a file" });
413
856
  }
414
- const ext = path5.extname(abs).toLowerCase();
857
+ const ext = path8.extname(abs).toLowerCase();
415
858
  const mime = MIME[ext];
416
859
  if (!mime) {
417
860
  return reply.code(404).send({ error: "Asset type not allowed" });
@@ -464,18 +907,18 @@ function computeBodyStartLine(raw) {
464
907
  }
465
908
 
466
909
  // src/render/links.ts
467
- import path6 from "path/posix";
910
+ import path9 from "path/posix";
468
911
  var MD_EXT_RE = /\.(md|markdown|mdx)$/i;
469
912
  var HREF_RE = /<a\s+([^>]*?)href="([^"#?]*)(\?[^"#]*)?(#[^"]*)?"([^>]*)>/gi;
470
913
  var IMG_SRC_RE = /<img\s+([^>]*?)src="([^"]+)"([^>]*)>/gi;
471
914
  function tagInternalLinks(html, currentRelPath) {
472
- const currentDir = path6.dirname(currentRelPath);
915
+ const currentDir = path9.dirname(currentRelPath);
473
916
  return html.replace(HREF_RE, (full, pre, target, query, hash, post) => {
474
917
  if (!target || /^https?:\/\//i.test(target) || target.startsWith("mailto:")) {
475
918
  return full;
476
919
  }
477
920
  if (!MD_EXT_RE.test(target)) return full;
478
- const resolved = path6.normalize(path6.join(currentDir, target));
921
+ const resolved = path9.normalize(path9.join(currentDir, target));
479
922
  let decoded;
480
923
  try {
481
924
  decoded = decodeURIComponent(resolved);
@@ -488,12 +931,12 @@ function tagInternalLinks(html, currentRelPath) {
488
931
  });
489
932
  }
490
933
  function rewriteImageSrc(html, currentRelPath) {
491
- const currentDir = path6.dirname(currentRelPath);
934
+ const currentDir = path9.dirname(currentRelPath);
492
935
  return html.replace(IMG_SRC_RE, (full, pre, src, post) => {
493
936
  if (!src) return full;
494
937
  if (/^(https?:\/\/|data:|\/__asset\/)/i.test(src)) return full;
495
938
  const trimmed = src.replace(/^\/+/, "");
496
- const resolved = path6.normalize(path6.join(currentDir, trimmed));
939
+ const resolved = path9.normalize(path9.join(currentDir, trimmed));
497
940
  if (resolved.startsWith("..")) return full;
498
941
  return `<img ${pre}src="/__asset/${resolved}"${post}>`;
499
942
  });
@@ -725,27 +1168,34 @@ function nest(flat) {
725
1168
  }
726
1169
 
727
1170
  // src/server/routes/api-file.ts
728
- function registerApiFile(app, rootAbsPath, rootInfo) {
1171
+ function registerApiFile(app, roots) {
729
1172
  app.get("/api/file", async (req, reply) => {
730
1173
  const requested = req.query.path?.trim() ?? "";
731
- let relPath;
732
- if (rootInfo.rootKind === "file") {
733
- if (requested && requested !== rootInfo.rootRelPath) {
734
- return reply.code(404).send({ error: "File not found in single-file mode" });
735
- }
736
- relPath = rootInfo.rootRelPath;
737
- } else {
738
- if (!requested) {
1174
+ const primary = roots[0];
1175
+ if (!primary) {
1176
+ return reply.code(404).send({ error: "No folders are open" });
1177
+ }
1178
+ let wsPath = requested;
1179
+ if (!wsPath) {
1180
+ if (primary.root.kind !== "file") {
739
1181
  return reply.code(400).send({ error: "Missing ?path" });
740
1182
  }
741
- relPath = requested;
1183
+ wsPath = toWorkspacePath(primary.root.id, primary.root.filePath);
742
1184
  }
743
- if (!/\.(md|markdown|mdx)$/i.test(relPath)) {
1185
+ const resolved = parseWorkspacePath(roots, wsPath);
1186
+ if (!resolved) {
1187
+ return reply.code(404).send({ error: "File not found" });
1188
+ }
1189
+ const { state, rel } = resolved;
1190
+ if (state.root.kind === "file" && rel !== state.root.filePath) {
1191
+ return reply.code(404).send({ error: "File not found in single-file mode" });
1192
+ }
1193
+ if (!/\.(md|markdown|mdx)$/i.test(rel)) {
744
1194
  return reply.code(400).send({ error: "Only markdown files are supported" });
745
1195
  }
746
1196
  let absPath;
747
1197
  try {
748
- absPath = resolveSafePath(rootAbsPath, relPath);
1198
+ absPath = resolveSafePath(state.absPath, rel);
749
1199
  } catch (err) {
750
1200
  return reply.code(400).send({ error: err.message });
751
1201
  }
@@ -763,11 +1213,12 @@ function registerApiFile(app, rootAbsPath, rootInfo) {
763
1213
  }
764
1214
  const { data, body, bodyStartLine } = parseFrontmatter(raw);
765
1215
  const { html: rawHtml, tokens } = await renderMarkdown(body, bodyStartLine);
766
- const html = rewriteImageSrc(tagInternalLinks(rawHtml, relPath), relPath);
1216
+ const wsRel = toWorkspacePath(state.root.id, rel);
1217
+ const html = rewriteImageSrc(tagInternalLinks(rawHtml, wsRel), wsRel);
767
1218
  const outline = extractOutline(tokens);
768
1219
  const title = (typeof data?.title === "string" ? data.title : null) ?? outline[0]?.text ?? null;
769
1220
  const result = {
770
- relPath,
1221
+ relPath: wsRel,
771
1222
  html,
772
1223
  outline,
773
1224
  frontmatter: data,
@@ -865,13 +1316,12 @@ function flattenMdRelPaths(nodes, out = []) {
865
1316
 
866
1317
  // src/server/fs/tree.ts
867
1318
  import { readdir } from "fs/promises";
868
- import path7 from "path";
1319
+ import path10 from "path";
869
1320
  async function walkFolder(root, opts = {}) {
870
1321
  const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
871
- return walkInner(root, "", ignore);
1322
+ return walkInner(root, opts.relBase ?? "", ignore);
872
1323
  }
873
- async function walkInner(root, relBase, ignore) {
874
- const absDir = path7.join(root, relBase);
1324
+ async function walkInner(absDir, relBase, ignore) {
875
1325
  const entries = await readdir(absDir, { withFileTypes: true });
876
1326
  const out = [];
877
1327
  for (const entry of entries) {
@@ -882,7 +1332,7 @@ async function walkInner(root, relBase, ignore) {
882
1332
  }
883
1333
  const childRel = relBase ? `${relBase}/${entry.name}` : entry.name;
884
1334
  if (entry.isDirectory()) {
885
- const children = await walkInner(root, childRel, ignore);
1335
+ const children = await walkInner(path10.join(absDir, entry.name), childRel, ignore);
886
1336
  out.push({
887
1337
  name: entry.name,
888
1338
  relPath: childRel,
@@ -890,7 +1340,7 @@ async function walkInner(root, relBase, ignore) {
890
1340
  children
891
1341
  });
892
1342
  } else if (entry.isFile()) {
893
- const ext = path7.extname(entry.name).toLowerCase();
1343
+ const ext = path10.extname(entry.name).toLowerCase();
894
1344
  out.push({
895
1345
  name: entry.name,
896
1346
  relPath: childRel,
@@ -920,7 +1370,7 @@ function buildSnippet(line, start, end, radius) {
920
1370
  highlight: [hlStart, hlEnd]
921
1371
  };
922
1372
  }
923
- async function grepFiles(rootAbsPath, query, opts = {}) {
1373
+ async function grepFiles(roots, query, opts = {}) {
924
1374
  const perFileCap = opts.perFileCap ?? 20;
925
1375
  const globalCap = opts.globalCap ?? 200;
926
1376
  const snippetRadius = opts.snippetRadius ?? 60;
@@ -936,64 +1386,75 @@ async function grepFiles(rootAbsPath, query, opts = {}) {
936
1386
  regex: opts.regex ?? false
937
1387
  });
938
1388
  if (!pattern.valid) return { query, results: [], truncated: false };
939
- const tree = await walkFolder(rootAbsPath, { ignore: opts.ignore });
940
- const files = flattenMdRelPaths(tree);
941
1389
  const results = [];
942
1390
  let totalHits = 0;
943
1391
  let truncated = false;
944
- for (const relPath of files) {
1392
+ for (const root of roots) {
945
1393
  if (totalHits >= globalCap) {
946
1394
  truncated = true;
947
1395
  break;
948
1396
  }
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;
1397
+ const tree = await walkFolder(root.absPath, { ignore: root.ignore ?? opts.ignore });
1398
+ const files = flattenMdRelPaths(tree);
1399
+ for (const relPath of files) {
1400
+ if (totalHits >= globalCap) {
1401
+ truncated = true;
1402
+ break;
1403
+ }
1404
+ let absPath;
1405
+ try {
1406
+ absPath = resolveSafePath(root.absPath, relPath);
1407
+ } catch {
970
1408
  continue;
971
1409
  }
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 {
1410
+ let raw;
1411
+ try {
1412
+ raw = await readFile4(absPath, "utf8");
1413
+ } catch {
1414
+ continue;
1415
+ }
1416
+ const { body } = parseFrontmatter(raw);
1417
+ const lines = body.split(/\r?\n/);
1418
+ const fileHits = [];
1419
+ let fileTotal = 0;
1420
+ let fileTruncated = false;
1421
+ for (let i = 0; i < lines.length; i++) {
1422
+ const line = lines[i];
1423
+ if (regexMode && line.length > maxLineLenForRegex) {
986
1424
  fileTruncated = true;
1425
+ continue;
987
1426
  }
988
- if (totalHits >= globalCap) {
989
- truncated = true;
990
- break;
1427
+ const { matches, truncated: lineTruncated } = regexMode ? pattern.matchAllWithBudget(line, perLineBudgetMs) : { matches: pattern.matchAll(line), truncated: false };
1428
+ if (lineTruncated) fileTruncated = true;
1429
+ for (const m of matches) {
1430
+ fileTotal++;
1431
+ if (fileHits.length < perFileCap) {
1432
+ const { snippet, highlight } = buildSnippet(
1433
+ line,
1434
+ m.index,
1435
+ m.index + m.length,
1436
+ snippetRadius
1437
+ );
1438
+ fileHits.push({ line: i + 1, col: m.index, snippet, highlight });
1439
+ totalHits++;
1440
+ } else {
1441
+ fileTruncated = true;
1442
+ }
1443
+ if (totalHits >= globalCap) {
1444
+ truncated = true;
1445
+ break;
1446
+ }
991
1447
  }
1448
+ if (totalHits >= globalCap) break;
1449
+ }
1450
+ if (fileHits.length > 0) {
1451
+ results.push({
1452
+ relPath: `${root.id}/${relPath}`,
1453
+ hits: fileHits,
1454
+ total: fileTotal,
1455
+ truncated: fileTruncated
1456
+ });
992
1457
  }
993
- if (totalHits >= globalCap) break;
994
- }
995
- if (fileHits.length > 0) {
996
- results.push({ relPath, hits: fileHits, total: fileTotal, truncated: fileTruncated });
997
1458
  }
998
1459
  }
999
1460
  return { query, results, truncated };
@@ -1004,11 +1465,8 @@ var MAX_QUERY_LEN = 200;
1004
1465
  function isTrue(v) {
1005
1466
  return v === "1" || v === "true";
1006
1467
  }
1007
- function registerApiSearch(app, rootAbsPath, rootInfo, configState) {
1468
+ function registerApiSearch(app, roots) {
1008
1469
  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
1470
  const q = (req.query.q ?? "").trim();
1013
1471
  if (q.length === 0) {
1014
1472
  return reply.send({ query: "", results: [], truncated: false });
@@ -1016,43 +1474,76 @@ function registerApiSearch(app, rootAbsPath, rootInfo, configState) {
1016
1474
  if (q.length > MAX_QUERY_LEN) {
1017
1475
  return reply.code(400).send({ error: "Query too long" });
1018
1476
  }
1019
- const out = await grepFiles(rootAbsPath, q, {
1477
+ const searchable = roots.filter((r) => r.root.kind === "dir").map((r) => ({ absPath: r.absPath, id: r.root.id, ignore: r.ignoreSet }));
1478
+ const out = await grepFiles(searchable, q, {
1020
1479
  caseSensitive: isTrue(req.query.case),
1021
1480
  wholeWord: isTrue(req.query.word),
1022
- regex: isTrue(req.query.regex),
1023
- ignore: configState.ignoreSet
1481
+ regex: isTrue(req.query.regex)
1024
1482
  });
1025
1483
  return reply.send(out);
1026
1484
  });
1027
1485
  }
1028
1486
 
1029
1487
  // src/server/routes/api-tree.ts
1030
- import path8 from "path";
1488
+ import path11 from "path";
1031
1489
  function applyOverrides(config, paletteOverride) {
1032
1490
  if (!paletteOverride) return config;
1033
1491
  return { ...config ?? {}, palette: paletteOverride };
1034
1492
  }
1035
- function registerApiTree(app, rootAbsPath, rootInfo, configState) {
1493
+ async function rootChildren(state) {
1494
+ const { id, kind, filePath } = state.root;
1495
+ if (kind === "file") {
1496
+ return [
1497
+ {
1498
+ name: path11.basename(filePath),
1499
+ relPath: toWorkspacePath(id, filePath),
1500
+ type: "file",
1501
+ isMarkdown: true
1502
+ }
1503
+ ];
1504
+ }
1505
+ return walkFolder(state.absPath, { ignore: state.ignoreSet, relBase: id });
1506
+ }
1507
+ function registerApiTree(app, roots, paletteOverride) {
1036
1508
  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 });
1509
+ const config = applyOverrides(roots[0]?.config ?? null, paletteOverride);
1510
+ if (roots.length === 1) {
1511
+ const only = roots[0];
1512
+ return reply.send({
1513
+ roots: [only.root],
1514
+ tree: await rootChildren(only),
1515
+ config
1516
+ });
1048
1517
  }
1049
- const tree = await walkFolder(rootAbsPath, { ignore: configState.ignoreSet });
1050
- return reply.send({ root: rootInfo, tree, config });
1518
+ const tree = [];
1519
+ for (const state of roots) {
1520
+ tree.push({
1521
+ name: state.root.name,
1522
+ relPath: state.root.id,
1523
+ type: "dir",
1524
+ children: await rootChildren(state)
1525
+ });
1526
+ }
1527
+ return reply.send({ roots: roots.map((r) => r.root), tree, config });
1051
1528
  });
1052
1529
  }
1053
1530
 
1531
+ // src/server/routes/health.ts
1532
+ function registerHealth(app, identity, roots) {
1533
+ app.get(
1534
+ "/api/health",
1535
+ async (_req, reply) => reply.send({
1536
+ ok: true,
1537
+ pid: identity.pid,
1538
+ startedAt: identity.startedAt,
1539
+ roots: roots.map((r) => r.root.id)
1540
+ })
1541
+ );
1542
+ }
1543
+
1054
1544
  // src/server/routes/sse.ts
1055
- function registerSse(app, watcher) {
1545
+ function registerSse(app, hub, hooks = {}) {
1546
+ let clients = 0;
1056
1547
  app.get("/api/watch", (req, reply) => {
1057
1548
  reply.raw.writeHead(200, {
1058
1549
  "Content-Type": "text/event-stream",
@@ -1062,11 +1553,15 @@ function registerSse(app, watcher) {
1062
1553
  });
1063
1554
  reply.raw.write(": connected\n\n");
1064
1555
  let closed = false;
1556
+ clients++;
1557
+ hooks.onClientsChanged?.(clients);
1065
1558
  const cleanup = () => {
1066
1559
  if (closed) return;
1067
1560
  closed = true;
1068
1561
  clearInterval(heartbeat);
1069
- watcher.off("event", send);
1562
+ hub.off("event", send);
1563
+ clients--;
1564
+ hooks.onClientsChanged?.(clients);
1070
1565
  try {
1071
1566
  reply.raw.end();
1072
1567
  } catch {
@@ -1092,7 +1587,7 @@ function registerSse(app, watcher) {
1092
1587
  cleanup();
1093
1588
  }
1094
1589
  }, 15e3);
1095
- watcher.on("event", send);
1590
+ hub.on("event", send);
1096
1591
  req.raw.on("close", cleanup);
1097
1592
  req.raw.on("error", cleanup);
1098
1593
  reply.raw.on("error", cleanup);
@@ -1102,12 +1597,25 @@ function registerSse(app, watcher) {
1102
1597
 
1103
1598
  // src/server/watcher.ts
1104
1599
  import { EventEmitter } from "events";
1105
- import path9 from "path";
1600
+ import path12 from "path";
1106
1601
  import chokidar from "chokidar";
1602
+ function createEventHub() {
1603
+ const emitter = new EventEmitter();
1604
+ emitter.setMaxListeners(0);
1605
+ return {
1606
+ on: (event, listener) => emitter.on(event, listener),
1607
+ off: (event, listener) => emitter.off(event, listener),
1608
+ emitSynthetic: (e) => emitter.emit("event", e),
1609
+ close: async () => {
1610
+ emitter.removeAllListeners();
1611
+ }
1612
+ };
1613
+ }
1107
1614
  function createWatcher(rootAbsPath, opts = {}) {
1108
1615
  const emitter = new EventEmitter();
1109
1616
  emitter.setMaxListeners(0);
1110
1617
  const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
1618
+ const prefix = opts.prefix ?? "";
1111
1619
  const watcher = chokidar.watch(rootAbsPath, {
1112
1620
  ignoreInitial: true,
1113
1621
  ignored: (p) => isPathIgnored(p, rootAbsPath, ignore),
@@ -1130,9 +1638,10 @@ function createWatcher(rootAbsPath, opts = {}) {
1130
1638
  }
1131
1639
  });
1132
1640
  function emit(kind, abs) {
1133
- const rel = path9.relative(rootAbsPath, abs).split(path9.sep).join("/");
1641
+ const rel = path12.relative(rootAbsPath, abs).split(path12.sep).join("/");
1134
1642
  if (!rel) return;
1135
- emitter.emit("event", { kind, relPath: rel });
1643
+ const relPath = prefix ? `${prefix}/${rel}` : rel;
1644
+ emitter.emit("event", { kind, relPath });
1136
1645
  }
1137
1646
  watcher.on("change", (p) => emit("change", p));
1138
1647
  watcher.on("add", (p) => emit("add", p));
@@ -1147,8 +1656,7 @@ function createWatcher(rootAbsPath, opts = {}) {
1147
1656
 
1148
1657
  // src/server/index.ts
1149
1658
  function buildCspHtml(embedMode) {
1150
- const frameAncestors = embedMode ? "*" : "'none'";
1151
- return [
1659
+ const directives = [
1152
1660
  "default-src 'self'",
1153
1661
  "script-src 'self'",
1154
1662
  // KaTeX + mermaid inject inline styles into rendered output; the rest of
@@ -1157,14 +1665,19 @@ function buildCspHtml(embedMode) {
1157
1665
  "img-src 'self' data: blob:",
1158
1666
  "font-src 'self' data:",
1159
1667
  "connect-src 'self'",
1160
- `frame-ancestors ${frameAncestors}`,
1161
1668
  "base-uri 'none'",
1162
1669
  "form-action 'none'"
1163
- ].join("; ");
1670
+ ];
1671
+ if (!embedMode) directives.push("frame-ancestors 'none'");
1672
+ return directives.join("; ");
1164
1673
  }
1165
1674
  async function createServer(opts) {
1166
1675
  const app = Fastify({ logger: false });
1167
1676
  const cspHtml = buildCspHtml(opts.embedMode ?? false);
1677
+ app.addHook("onRequest", async (req, reply) => {
1678
+ if (isAllowedHost(req.headers.host)) return;
1679
+ return reply.code(403).type("text/plain; charset=utf-8").send("Forbidden");
1680
+ });
1168
1681
  app.addHook("onSend", async (_req, reply, payload) => {
1169
1682
  const ct = String(reply.getHeader("content-type") ?? "");
1170
1683
  if (ct.startsWith("text/html")) {
@@ -1174,33 +1687,76 @@ async function createServer(opts) {
1174
1687
  }
1175
1688
  return payload;
1176
1689
  });
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);
1690
+ const roots = [];
1691
+ const configWatchers = /* @__PURE__ */ new Map();
1692
+ const hub = createEventHub();
1693
+ async function openRoot(spec, id) {
1694
+ const config = await loadEffectiveConfig(spec.absPath);
1695
+ const ignoreSet = buildIgnoreSet(config?.ignore ?? []);
1696
+ const state = {
1697
+ root: describeRoot(spec, id),
1698
+ absPath: spec.absPath,
1699
+ ignoreSet,
1700
+ config,
1701
+ watcher: createWatcher(spec.absPath, { ignore: ignoreSet, prefix: id })
1702
+ };
1703
+ state.watcher.on("event", (e) => hub.emitSynthetic(e));
1704
+ const configWatcher = chokidar2.watch(path13.join(spec.absPath, CONFIG_FILENAME), {
1705
+ ignoreInitial: true,
1706
+ persistent: true,
1707
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
1708
+ });
1709
+ const reloadConfig = async () => {
1710
+ state.config = await loadEffectiveConfig(spec.absPath);
1711
+ const event = { kind: "config", relPath: `${id}/${CONFIG_FILENAME}` };
1712
+ hub.emitSynthetic(event);
1713
+ };
1714
+ configWatcher.on("add", () => void reloadConfig());
1715
+ configWatcher.on("change", () => void reloadConfig());
1716
+ configWatcher.on("unlink", () => void reloadConfig());
1717
+ configWatchers.set(id, configWatcher);
1718
+ return state;
1719
+ }
1720
+ async function closeRoot(state) {
1721
+ const cw = configWatchers.get(state.root.id);
1722
+ configWatchers.delete(state.root.id);
1723
+ await Promise.all([state.watcher.close(), cw?.close()]);
1724
+ }
1725
+ async function setRoots(specs) {
1726
+ const taken = /* @__PURE__ */ new Set();
1727
+ const wanted = specs.map((spec) => {
1728
+ const id = resolveRootId(spec, taken);
1729
+ taken.add(id);
1730
+ return { id, spec };
1731
+ });
1732
+ const keep = new Map(
1733
+ roots.filter((r) => wanted.some((w) => w.id === r.root.id && w.spec.absPath === r.absPath)).map((r) => [r.root.id, r])
1734
+ );
1735
+ const dropped = roots.filter((r) => !keep.has(r.root.id));
1736
+ const next = [];
1737
+ for (const { id, spec } of wanted) {
1738
+ const existing = keep.get(id);
1739
+ next.push(existing ?? await openRoot(spec, id));
1740
+ }
1741
+ roots.length = 0;
1742
+ roots.push(...next);
1743
+ await Promise.all(dropped.map(closeRoot));
1744
+ hub.emitSynthetic({ kind: "workspace" });
1745
+ }
1746
+ {
1747
+ const taken = /* @__PURE__ */ new Set();
1748
+ for (const spec of opts.roots) {
1749
+ const id = resolveRootId(spec, taken);
1750
+ taken.add(id);
1751
+ roots.push(await openRoot(spec, id));
1752
+ }
1753
+ }
1754
+ registerApiFile(app, roots);
1755
+ registerApiTree(app, roots, opts.paletteOverride);
1756
+ registerApiAsset(app, roots);
1757
+ registerApiSearch(app, roots);
1758
+ registerSse(app, hub, { ...opts.onSseClientsChanged ? { onClientsChanged: opts.onSseClientsChanged } : {} });
1759
+ if (opts.identity) registerHealth(app, opts.identity, roots);
1204
1760
  await app.register(import("@fastify/static"), {
1205
1761
  root: opts.clientDir,
1206
1762
  prefix: "/"
@@ -1225,10 +1781,13 @@ async function createServer(opts) {
1225
1781
  return reply.sendFile("index.html");
1226
1782
  });
1227
1783
  app.addHook("onClose", async () => {
1228
- await watcher.close();
1229
- await configWatcher.close();
1784
+ await Promise.all([
1785
+ ...roots.map((r) => r.watcher.close()),
1786
+ ...[...configWatchers.values()].map((w) => w.close())
1787
+ ]);
1788
+ await hub.close();
1230
1789
  });
1231
- return app;
1790
+ return { app, setRoots };
1232
1791
  }
1233
1792
 
1234
1793
  // src/cli.ts
@@ -1237,33 +1796,48 @@ function printUsage() {
1237
1796
  mdview \u2014 local markdown viewer
1238
1797
 
1239
1798
  Usage:
1240
- mdview [path] View file or folder (default: cwd)
1799
+ mdview [path] Open a file or folder and return the shell (default: cwd)
1800
+ mdview ls List open folders and the background server
1801
+ mdview rm <name|path>\u2026 Stop serving a folder
1802
+ mdview stop Stop the background server
1241
1803
  mdview config <\u2026> Read or edit the global config (run 'mdview config -h')
1242
1804
 
1805
+ One background server serves every folder you open, so 'mdview .' in a second
1806
+ repo adds it alongside the first instead of starting a rival server.
1807
+
1243
1808
  Options:
1244
- --port <n> Port to listen on (default: 7331; auto-fallback)
1809
+ --foreground, -f Run a server in this terminal instead (Ctrl-C to stop)
1245
1810
  --no-open Don't auto-launch the browser
1246
- --palette <name> Override the palette for this run (one of: ${PALETTES.join(", ")})
1811
+ --port <n> Port to listen on; implies --foreground
1812
+ --palette <name> Palette for this run; implies --foreground (one of: ${PALETTES.join(", ")})
1247
1813
  --version, -v Print version and exit
1248
1814
  --help, -h Show this help
1249
1815
 
1816
+ Environment:
1817
+ PORT Port for the background server (default: 7331)
1818
+ MDVIEW_IDLE_TIMEOUT Minutes with no open tab after which the background
1819
+ server exits (default: off \u2014 use 'mdview stop')
1820
+ MDVIEW_DEBUG=1 Print full stack traces on unexpected errors
1821
+
1250
1822
  Examples:
1251
- mdview ./docs Browse a folder
1823
+ mdview ./docs Browse a folder, shell stays free
1252
1824
  mdview README.md View a single file
1253
- mdview --port 9000 Use a specific port
1825
+ mdview ls What's open, and where
1826
+ mdview --foreground ./docs Keep it in this terminal
1827
+ mdview --port 9000 ./docs One-off server on a specific port
1254
1828
  mdview config ignore add deps _site Hide extra build dirs globally
1255
1829
  mdview config path Print global config path
1256
1830
  `.trim());
1257
1831
  }
1258
1832
  function readVersion() {
1259
- const here = path11.dirname(fileURLToPath(import.meta.url));
1833
+ const here = path14.dirname(fileURLToPath(import.meta.url));
1260
1834
  const candidates = [
1261
- path11.resolve(here, "../package.json"),
1262
- path11.resolve(here, "../../package.json")
1835
+ path14.resolve(here, "../package.json"),
1836
+ path14.resolve(here, "../../package.json")
1263
1837
  ];
1264
1838
  for (const p of candidates) {
1265
1839
  try {
1266
- const raw = readFileSync(p, "utf8");
1840
+ const raw = readFileSync2(p, "utf8");
1267
1841
  const v = JSON.parse(raw).version;
1268
1842
  if (typeof v === "string") return v;
1269
1843
  } catch {
@@ -1271,8 +1845,38 @@ function readVersion() {
1271
1845
  }
1272
1846
  return "unknown";
1273
1847
  }
1274
- function parseArgs(argv) {
1275
- const args = { target: ".", port: 7331, portExplicit: false, open: true, embedMode: false };
1848
+ var DEFAULT_PORT = 7331;
1849
+ var FRIENDLY_HOST = "mdview.localhost";
1850
+ function isLoopbackAddress(addr) {
1851
+ return addr === "::1" || addr.startsWith("127.");
1852
+ }
1853
+ async function resolveDisplayHost() {
1854
+ try {
1855
+ const addrs = await dns.lookup(FRIENDLY_HOST, { all: true });
1856
+ if (addrs.length > 0 && addrs.every((a) => isLoopbackAddress(a.address))) {
1857
+ return FRIENDLY_HOST;
1858
+ }
1859
+ } catch {
1860
+ }
1861
+ return "127.0.0.1";
1862
+ }
1863
+ function envPort() {
1864
+ const raw = process.env.PORT;
1865
+ if (!raw) return void 0;
1866
+ const n = Number(raw);
1867
+ if (!Number.isInteger(n) || n < 0 || n > 65535) return void 0;
1868
+ return n;
1869
+ }
1870
+ function parseArgs(argv, defaultPort = DEFAULT_PORT) {
1871
+ const args = {
1872
+ target: ".",
1873
+ port: defaultPort,
1874
+ portExplicit: false,
1875
+ open: true,
1876
+ embedMode: false,
1877
+ foreground: false,
1878
+ daemon: false
1879
+ };
1276
1880
  let targetSet = false;
1277
1881
  for (let i = 0; i < argv.length; i++) {
1278
1882
  const a = argv[i];
@@ -1286,6 +1890,14 @@ function parseArgs(argv) {
1286
1890
  args.embedMode = true;
1287
1891
  continue;
1288
1892
  }
1893
+ if (a === "-f" || a === "--foreground") {
1894
+ args.foreground = true;
1895
+ continue;
1896
+ }
1897
+ if (a === "--daemon") {
1898
+ args.daemon = true;
1899
+ continue;
1900
+ }
1289
1901
  if (a === "--port") {
1290
1902
  const v = argv[++i];
1291
1903
  if (!v) throw new Error("--port requires a number");
@@ -1309,26 +1921,24 @@ function parseArgs(argv) {
1309
1921
  targetSet = true;
1310
1922
  }
1311
1923
  if (args.embedMode) args.open = false;
1924
+ if (args.portExplicit || args.palette !== void 0 || args.embedMode) args.foreground = true;
1312
1925
  return { kind: "run", args };
1313
1926
  }
1314
1927
  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);
1928
+ const abs = path14.resolve(target);
1929
+ if (!existsSync2(abs)) throw new Error(`Path does not exist: ${abs}`);
1930
+ const st = statSync4(abs);
1318
1931
  if (st.isDirectory()) {
1319
- return {
1320
- rootAbsPath: abs,
1321
- rootInfo: { rootKind: "dir", rootRelPath: "", rootName: path11.basename(abs) }
1322
- };
1932
+ return { absPath: abs, id: deriveRootId(abs, /* @__PURE__ */ new Set()), kind: "dir" };
1323
1933
  }
1324
1934
  if (st.isFile()) {
1935
+ const dir = path14.dirname(abs);
1325
1936
  return {
1326
- rootAbsPath: path11.dirname(abs),
1327
- rootInfo: {
1328
- rootKind: "file",
1329
- rootRelPath: path11.basename(abs),
1330
- rootName: path11.basename(abs)
1331
- }
1937
+ absPath: dir,
1938
+ id: deriveRootId(dir, /* @__PURE__ */ new Set()),
1939
+ name: path14.basename(abs),
1940
+ kind: "file",
1941
+ filePath: path14.basename(abs)
1332
1942
  };
1333
1943
  }
1334
1944
  throw new Error(`Unsupported path type: ${abs}`);
@@ -1377,48 +1987,41 @@ Find the process with: ${portFinderHint(port)}`
1377
1987
  }
1378
1988
  throw new Error("Could not bind a port (tried 10 in a row).");
1379
1989
  }
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")
1990
+ function requireClientDir() {
1991
+ const here = path14.dirname(fileURLToPath(import.meta.url));
1992
+ const candidates = [
1993
+ path14.resolve(here, "../dist/client"),
1994
+ path14.resolve(here, "../../dist/client")
1408
1995
  ];
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 });
1996
+ const dir = candidates.find(existsSync2);
1997
+ if (!dir) throw new Error("Client bundle not found. Run `npm run build:client` first.");
1998
+ return dir;
1999
+ }
2000
+ function idleTimeoutMs() {
2001
+ const raw = process.env.MDVIEW_IDLE_TIMEOUT;
2002
+ if (raw === void 0) return 0;
2003
+ const n = Number(raw);
2004
+ if (!Number.isFinite(n) || n < 0) return 0;
2005
+ return Math.round(n * 6e4);
2006
+ }
2007
+ async function runForeground(args) {
2008
+ const root = detectRoot(args.target);
2009
+ const clientDir = requireClientDir();
2010
+ const { app } = await createServer({
2011
+ roots: [root],
2012
+ clientDir,
2013
+ ...args.palette === void 0 ? {} : { paletteOverride: args.palette },
2014
+ embedMode: args.embedMode
2015
+ });
1415
2016
  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}/`;
2017
+ const host = args.embedMode ? "127.0.0.1" : await resolveDisplayHost();
2018
+ const url = root.kind === "file" ? `http://${host}:${boundPort}/?file=${encodeURIComponent(`${root.id}/${root.filePath}`)}` : `http://${host}:${boundPort}/`;
1417
2019
  if (args.embedMode) {
1418
- process.stdout.write(JSON.stringify({ event: "ready", url, port: boundPort }) + "\n");
2020
+ process.stdout.write(`${JSON.stringify({ event: "ready", url, port: boundPort })}
2021
+ `);
1419
2022
  } else {
1420
2023
  console.log(`mdview \u2192 ${url}`);
1421
- console.log(`watching: ${rootAbsPath}`);
2024
+ console.log(`watching: ${root.absPath}`);
1422
2025
  }
1423
2026
  if (args.open) await openBrowser(url);
1424
2027
  let shuttingDown = false;
@@ -1448,6 +2051,104 @@ async function main() {
1448
2051
  process.stdin.resume();
1449
2052
  }
1450
2053
  }
2054
+ async function runDaemon(args) {
2055
+ const clientDir = requireClientDir();
2056
+ const startedAt = Date.now();
2057
+ let idleTimer;
2058
+ const server = await createServer({
2059
+ roots: toRootSpecs(readWorkspace()),
2060
+ clientDir,
2061
+ identity: { pid: process.pid, startedAt },
2062
+ onSseClientsChanged: (count) => {
2063
+ if (count > 0) {
2064
+ if (idleTimer) clearTimeout(idleTimer);
2065
+ idleTimer = void 0;
2066
+ } else {
2067
+ armIdle();
2068
+ }
2069
+ }
2070
+ });
2071
+ const boundPort = await listen(server.app, args.port, args.portExplicit);
2072
+ writeDaemonState({ pid: process.pid, port: boundPort, startedAt });
2073
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon pid ${process.pid} on port ${boundPort}`);
2074
+ const wsWatcher = chokidar3.watch(workspaceFilePath(), {
2075
+ ignoreInitial: true,
2076
+ persistent: true,
2077
+ awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
2078
+ });
2079
+ const reload = () => {
2080
+ void server.setRoots(toRootSpecs(readWorkspace())).catch((err) => {
2081
+ console.error(`[mdview] failed to apply workspace change: ${String(err)}`);
2082
+ });
2083
+ };
2084
+ wsWatcher.on("add", reload);
2085
+ wsWatcher.on("change", reload);
2086
+ wsWatcher.on("unlink", reload);
2087
+ let shuttingDown = false;
2088
+ const shutdown = async (why) => {
2089
+ if (shuttingDown) process.exit(130);
2090
+ shuttingDown = true;
2091
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon stopping (${why})`);
2092
+ clearDaemonState();
2093
+ const timeout = new Promise((resolve) => setTimeout(resolve, 1500));
2094
+ await Promise.race([
2095
+ Promise.all([server.app.close().catch(() => void 0), wsWatcher.close()]).then(
2096
+ () => void 0
2097
+ ),
2098
+ timeout
2099
+ ]);
2100
+ process.exit(0);
2101
+ };
2102
+ function armIdle() {
2103
+ const ms = idleTimeoutMs();
2104
+ if (ms <= 0) return;
2105
+ if (idleTimer) clearTimeout(idleTimer);
2106
+ idleTimer = setTimeout(() => void shutdown("idle"), ms);
2107
+ }
2108
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2109
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2110
+ if (process.platform !== "win32") process.on("SIGHUP", () => void shutdown("SIGHUP"));
2111
+ armIdle();
2112
+ }
2113
+ async function runAttach(args) {
2114
+ const added = addRoot(args.target);
2115
+ const state = await ensureDaemon(fileURLToPath(import.meta.url));
2116
+ await waitForRoot(state.port, added.root.id);
2117
+ const host = await resolveDisplayHost();
2118
+ const base = `http://${host}:${state.port}/`;
2119
+ const url = added.filePath ? `${base}?file=${encodeURIComponent(added.filePath)}` : `${base}?root=${encodeURIComponent(added.focusPath)}`;
2120
+ console.log(`mdview \u2192 ${url}`);
2121
+ console.log(`${added.changed ? "added" : "open"}: ${added.root.path} (${added.root.id})`);
2122
+ if (args.open) await openBrowser(url);
2123
+ }
2124
+ async function main() {
2125
+ const argv = process.argv.slice(2);
2126
+ const sub = argv[0];
2127
+ if (sub === "config") process.exit(await runConfigSubcommand(argv.slice(1)));
2128
+ if (sub === "ls" || sub === "status") process.exit(await runListCommand());
2129
+ if (sub === "rm") process.exit(await runRemoveCommand(argv.slice(1)));
2130
+ if (sub === "stop") process.exit(await runStopCommand());
2131
+ let parsed;
2132
+ try {
2133
+ parsed = parseArgs(argv, envPort() ?? DEFAULT_PORT);
2134
+ } catch (err) {
2135
+ console.error(err.message);
2136
+ printUsage();
2137
+ process.exit(2);
2138
+ }
2139
+ if (parsed.kind === "help") {
2140
+ printUsage();
2141
+ process.exit(0);
2142
+ }
2143
+ if (parsed.kind === "version") {
2144
+ console.log(readVersion());
2145
+ process.exit(0);
2146
+ }
2147
+ const args = parsed.args;
2148
+ if (args.daemon) return runDaemon(args);
2149
+ if (args.foreground) return runForeground(args);
2150
+ return runAttach(args);
2151
+ }
1451
2152
  function formatError(err) {
1452
2153
  if (!(err instanceof Error)) return null;
1453
2154
  const errno = err;
@@ -1460,7 +2161,16 @@ function formatError(err) {
1460
2161
  const msg = err.message;
1461
2162
  if (msg.startsWith("Port ")) return { message: msg, code: 1 };
1462
2163
  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")) {
2164
+ const EXPECTED = [
2165
+ "Path does not exist",
2166
+ "Unsupported path type",
2167
+ "Could not bind a port",
2168
+ "Unexpected argument",
2169
+ "Workspace is full",
2170
+ "Timed out waiting for the mdview background server",
2171
+ "The mdview background server did not start"
2172
+ ];
2173
+ if (EXPECTED.some((prefix) => msg.startsWith(prefix))) {
1464
2174
  return { message: `mdview: ${msg}`, code: 1 };
1465
2175
  }
1466
2176
  return null;
@@ -1492,6 +2202,8 @@ if (isEntryPoint) {
1492
2202
  });
1493
2203
  }
1494
2204
  export {
2205
+ DEFAULT_PORT,
2206
+ idleTimeoutMs,
1495
2207
  parseArgs
1496
2208
  };
1497
2209
  //# sourceMappingURL=mdview.mjs.map