@hardikg/mdview 0.7.1 → 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.
- package/CHANGELOG.md +34 -0
- package/README.md +72 -6
- package/bin/mdview.mjs +920 -208
- package/dist/client/assets/abnfDiagram-VCTEODGH-DafRYfx3.js +1 -0
- package/dist/client/assets/{arc-DYMLyL-_.js → arc-BqKZ1e2b.js} +1 -1
- package/dist/client/assets/architectureDiagram-5GKGNRK7-_94hPNdL.js +36 -0
- package/dist/client/assets/blockDiagram-NRAW4CY4-BegJqAzx.js +129 -0
- package/dist/client/assets/c4Diagram-UCG6FXSJ-DYBJe_j-.js +38 -0
- package/dist/client/assets/channel-CzMcbnuI.js +1 -0
- package/dist/client/assets/{chunk-QZHKN3VN-Cd7CcXLC.js → chunk-2Q5K7J3B-CarNPuoG.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-BYGwnUQ4.js → chunk-5VM5RSS4-Bmb8JM_j.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-TheCoD51.js → chunk-F27PBJKO-BhLIUyGL.js} +1 -1
- package/dist/client/assets/chunk-G27WJ6UU-B_7t7iym.js +231 -0
- package/dist/client/assets/{chunk-4BX2VUAB-By-ok8D7.js → chunk-JWPE2WC7-6WDfZVE5.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-Bj1JFNtT.js → chunk-LCL6LL3I-BnM13fhY.js} +4 -4
- package/dist/client/assets/chunk-POPQ4Y6H-DQMWRZKQ.js +1 -0
- package/dist/client/assets/chunk-SVP7TREG-DPpw4ke1.js +88 -0
- package/dist/client/assets/{chunk-55IACEB6-BDUDnCl9.js → chunk-XXDRQBXY-ByC-zeqG.js} +1 -1
- package/dist/client/assets/classDiagram-DTDB5LWJ-RvAHOW66.js +1 -0
- package/dist/client/assets/classDiagram-v2-JRS7N3AN-RvAHOW66.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-Br-CySzr.js → cose-bilkent-JH36ORCC-CiRnFlQp.js} +1 -1
- package/dist/client/assets/cynefin-OW5HDTMX-DqTFYole.js +178 -0
- package/dist/client/assets/cynefinDiagram-5FMLGOSQ-B1WAhR06.js +62 -0
- package/dist/client/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/dist/client/assets/dagre-3AP2YEHR-DQK5a43-.js +4 -0
- package/dist/client/assets/diagram-S7CK7UJ4-BMQ8dao_.js +30 -0
- package/dist/client/assets/diagram-UQ7AKVKN-CT7KIFG4.js +41 -0
- package/dist/client/assets/{diagram-KO2AKTUF-mwoLS2TK.js → diagram-VSXAHHWV-By0SbfXs.js} +3 -3
- package/dist/client/assets/diagram-VX7I27RA-DwshpqNi.js +24 -0
- package/dist/client/assets/{diagram-LMA3HP47-4o7ohGjJ.js → diagram-Z3DM3KII-CG7wA7Kr.js} +2 -2
- package/dist/client/assets/ebnfDiagram-PWID7BFC-XlNRbCZw.js +1 -0
- package/dist/client/assets/erDiagram-SSCWMZ5O-BtDUPrpL.js +99 -0
- package/dist/client/assets/flowDiagram-A5DVABFB-DKJ5hkO-.js +168 -0
- package/dist/client/assets/ganttDiagram-EL5Y4UJY-B8rnbHA1.js +292 -0
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-eN40bRnz.js → gitGraphDiagram-WWUBYQGX-Conc20s7.js} +7 -7
- package/dist/client/assets/index-CCiGr1hz.js +2 -0
- package/dist/client/assets/{index-Cmcberpw.css → index-CJ5_G3gC.css} +1 -1
- package/dist/client/assets/infoDiagram-RXCK75RN-DNuHlrdu.js +2 -0
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-BDIzsJJZ.js → ishikawaDiagram-5VMMS53U-DkYaQbSy.js} +5 -5
- package/dist/client/assets/{journeyDiagram-JHISSGLW-BfWwpj1w.js → journeyDiagram-EYS64GPL-BdoQruin.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Cuij1uoR.js → kanban-definition-3QL26DDD-BD3QQjwp.js} +8 -8
- package/dist/client/assets/katex-C5jXJg4s.js +257 -0
- package/dist/client/assets/katex.min-BYZTj3zW.css +1 -0
- package/dist/client/assets/katex.min-DXaO1tdm.js +1 -0
- package/dist/client/assets/layout-DKu9QfZ6.js +1 -0
- package/dist/client/assets/{linear-CZOgGL8m.js → linear-DMmTRDu2.js} +1 -1
- package/dist/client/assets/mermaid.core-2ESgyZ95.js +312 -0
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-BbNc5LRE.js → mindmap-definition-FBJOCRG2-CokK9_yg.js} +1 -1
- package/dist/client/assets/pegDiagram-XKGWAZYB-CUAn26Uv.js +1 -0
- package/dist/client/assets/pieDiagram-E7YTZNPT-BT-XLIyl.js +39 -0
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-CmJGRE1L.js → quadrantDiagram-AXDQQJYC-dDr-ZgjR.js} +3 -3
- package/dist/client/assets/railroadDiagram-O6MQD6OU-B5rCSICc.js +1 -0
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-Dp-nBLhC.js → requirementDiagram-EFPCY7ZU-DyI3FGB5.js} +5 -5
- package/dist/client/assets/sankeyDiagram-P5KCCOFB-CGXj8eCe.js +40 -0
- package/dist/client/assets/sequenceDiagram-WJ2MYXX4-C3yr2FVq.js +162 -0
- package/dist/client/assets/sizeCapture-X5ZJPWSS-CCnkSxko.js +1 -0
- package/dist/client/assets/stateDiagram-HBIQ2CUA-BG5Dshx6.js +1 -0
- package/dist/client/assets/stateDiagram-v2-4QOOHH4V-BdraFugH.js +1 -0
- package/dist/client/assets/swimlanes-XN3QIQJK-MGfvzMlQ.js +1 -0
- package/dist/client/assets/swimlanesDiagram-VK2B7HYN-LQu07Za2.js +8 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-uVQ-oiv8.js → timeline-definition-24CTP7MA-Bj3gMrOq.js} +3 -3
- package/dist/client/assets/vennDiagram-4TSXK5OY-CiIW_j8g.js +34 -0
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-BQMj40Ao.js → wardleyDiagram-VM6X3IG4-DQTIyknP.js} +4 -4
- package/dist/client/assets/xychartDiagram-S5SC5T6Z-Bg0tzmhJ.js +7 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -2
- package/dist/client/assets/architectureDiagram-3BPJPVTR-iXijJkGs.js +0 -36
- package/dist/client/assets/blockDiagram-GPEHLZMM-Dmjj2Ajf.js +0 -132
- package/dist/client/assets/c4Diagram-AAUBKEIU-kJb6eQJJ.js +0 -10
- package/dist/client/assets/channel-DirF0LQF.js +0 -1
- package/dist/client/assets/chunk-2J33WTMH-T3vdO-20.js +0 -1
- package/dist/client/assets/chunk-AQP2D5EJ-BPZWXvxS.js +0 -231
- package/dist/client/assets/classDiagram-4FO5ZUOK-CdoMz3xi.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CdoMz3xi.js +0 -1
- package/dist/client/assets/cytoscape.esm-D_LviqZs.js +0 -331
- package/dist/client/assets/dagre-BM42HDAG-DcIxclFu.js +0 -4
- package/dist/client/assets/diagram-2AECGRRQ-C0xPdtMR.js +0 -43
- package/dist/client/assets/diagram-5GNKFQAL-B9i7DtoQ.js +0 -10
- package/dist/client/assets/diagram-OG6HWLK6-C6rhUBe2.js +0 -24
- package/dist/client/assets/erDiagram-TEJ5UH35-smNcu7Lp.js +0 -85
- package/dist/client/assets/flowDiagram-I6XJVG4X-CPuYTxJr.js +0 -162
- package/dist/client/assets/ganttDiagram-6RSMTGT7-xaV0Djok.js +0 -292
- package/dist/client/assets/graph-CAnANduQ.js +0 -1
- package/dist/client/assets/index-OFdfWE8x.js +0 -2
- package/dist/client/assets/infoDiagram-5YYISTIA-xm1BEpfJ.js +0 -2
- package/dist/client/assets/katex-DkKDou_j.js +0 -257
- package/dist/client/assets/katex.min-CfVKi3_s.css +0 -1
- package/dist/client/assets/katex.min-DBnXG_UH.js +0 -1
- package/dist/client/assets/layout-DGIYPm2g.js +0 -1
- package/dist/client/assets/mermaid.core-B1i-ZGkv.js +0 -309
- package/dist/client/assets/pieDiagram-4H26LBE5-oB2YhWwR.js +0 -30
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-DB5dqk7n.js +0 -40
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-jmqrpzCx.js +0 -162
- package/dist/client/assets/stateDiagram-AJRCARHV-U6m2A9Wk.js +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-00c0MW6J.js +0 -1
- package/dist/client/assets/vennDiagram-CIIHVFJN-By-tXl41.js +0 -34
- package/dist/client/assets/wardley-L42UT6IY-ByB1C5d8.js +0 -173
- 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
|
|
5
|
-
import
|
|
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
|
|
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
|
|
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 =
|
|
766
|
+
const rel = path6.relative(root, abs);
|
|
348
767
|
if (rel === "" || rel.startsWith("..")) return false;
|
|
349
|
-
for (const seg of rel.split(
|
|
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
|
|
799
|
+
import path8 from "path";
|
|
361
800
|
|
|
362
801
|
// src/server/fs/resolve.ts
|
|
363
|
-
import
|
|
802
|
+
import path7 from "path";
|
|
364
803
|
function resolveSafePath(root, relPath) {
|
|
365
|
-
if (
|
|
804
|
+
if (path7.isAbsolute(relPath)) {
|
|
366
805
|
throw new Error(`Refusing absolute path: ${relPath}`);
|
|
367
806
|
}
|
|
368
|
-
const absRoot =
|
|
369
|
-
const candidate =
|
|
370
|
-
const rel =
|
|
371
|
-
if (rel.startsWith("..") ||
|
|
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,
|
|
835
|
+
function registerApiAsset(app, roots) {
|
|
397
836
|
app.get("/__asset/*", async (req, reply) => {
|
|
398
|
-
const
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
1171
|
+
function registerApiFile(app, roots) {
|
|
729
1172
|
app.get("/api/file", async (req, reply) => {
|
|
730
1173
|
const requested = req.query.path?.trim() ?? "";
|
|
731
|
-
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
1183
|
+
wsPath = toWorkspacePath(primary.root.id, primary.root.filePath);
|
|
742
1184
|
}
|
|
743
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
1392
|
+
for (const root of roots) {
|
|
945
1393
|
if (totalHits >= globalCap) {
|
|
946
1394
|
truncated = true;
|
|
947
1395
|
break;
|
|
948
1396
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
1038
|
-
if (
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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 =
|
|
1050
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1641
|
+
const rel = path12.relative(rootAbsPath, abs).split(path12.sep).join("/");
|
|
1134
1642
|
if (!rel) return;
|
|
1135
|
-
|
|
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));
|
|
@@ -1165,6 +1674,10 @@ function buildCspHtml(embedMode) {
|
|
|
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
|
|
1178
|
-
const
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
|
1229
|
-
|
|
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]
|
|
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
|
-
--
|
|
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
|
-
--
|
|
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
|
|
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 =
|
|
1833
|
+
const here = path14.dirname(fileURLToPath(import.meta.url));
|
|
1260
1834
|
const candidates = [
|
|
1261
|
-
|
|
1262
|
-
|
|
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 =
|
|
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
|
-
|
|
1275
|
-
|
|
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 =
|
|
1316
|
-
if (!
|
|
1317
|
-
const st =
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
|
1410
|
-
if (!
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
const
|
|
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
|
|
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 })
|
|
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: ${
|
|
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
|
-
|
|
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
|