@hardikg/mdview 0.6.2
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 +140 -0
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/bin/mdview.mjs +1418 -0
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/client/assets/arc-DjKPIdc5.js +1 -0
- package/dist/client/assets/architectureDiagram-3BPJPVTR-_OLcSJC-.js +36 -0
- package/dist/client/assets/blockDiagram-GPEHLZMM-BxHKbhSj.js +132 -0
- package/dist/client/assets/c4Diagram-AAUBKEIU-DiBLe7ZZ.js +10 -0
- package/dist/client/assets/channel-oDRyuYVi.js +1 -0
- package/dist/client/assets/chunk-2J33WTMH-4B03UoyK.js +1 -0
- package/dist/client/assets/chunk-4BX2VUAB-Dw7jUHGw.js +1 -0
- package/dist/client/assets/chunk-55IACEB6-AZdo8c-x.js +1 -0
- package/dist/client/assets/chunk-727SXJPM-g0GurlcS.js +206 -0
- package/dist/client/assets/chunk-AQP2D5EJ-eqTzq7th.js +231 -0
- package/dist/client/assets/chunk-FMBD7UC4-D4omRKrC.js +15 -0
- package/dist/client/assets/chunk-ND2GUHAM-mVdFtWqM.js +1 -0
- package/dist/client/assets/chunk-QZHKN3VN-CyPMaN41.js +1 -0
- package/dist/client/assets/classDiagram-4FO5ZUOK-Dn5QR1VE.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-Dn5QR1VE.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-BjlwoH6Y.js +1 -0
- package/dist/client/assets/cytoscape.esm-D_LviqZs.js +331 -0
- package/dist/client/assets/dagre-BM42HDAG-DBF5W9Tk.js +4 -0
- package/dist/client/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/client/assets/diagram-2AECGRRQ-D9gwS4tw.js +43 -0
- package/dist/client/assets/diagram-5GNKFQAL-DTpIgGjI.js +10 -0
- package/dist/client/assets/diagram-KO2AKTUF-ByLH9Z83.js +3 -0
- package/dist/client/assets/diagram-LMA3HP47-Dkd4KfnW.js +24 -0
- package/dist/client/assets/diagram-OG6HWLK6-C-mJW1Tv.js +24 -0
- package/dist/client/assets/erDiagram-TEJ5UH35-DmyZPB5P.js +85 -0
- package/dist/client/assets/flowDiagram-I6XJVG4X-D6RrsDb6.js +162 -0
- package/dist/client/assets/ganttDiagram-6RSMTGT7-CP_O3OdU.js +292 -0
- package/dist/client/assets/gitGraphDiagram-PVQCEYII-DZgdWY-6.js +106 -0
- package/dist/client/assets/graph-CAnANduQ.js +1 -0
- package/dist/client/assets/index-DFYTyxkU.js +2 -0
- package/dist/client/assets/index-_GInkOX2.css +1 -0
- package/dist/client/assets/infoDiagram-5YYISTIA-BAp5orio.js +2 -0
- package/dist/client/assets/init-Gi6I4Gst.js +1 -0
- package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DgqFJxLz.js +70 -0
- package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/client/assets/jetbrains-mono-cyrillic-600-normal-8K4wrrwR.woff +0 -0
- package/dist/client/assets/jetbrains-mono-cyrillic-600-normal-EVf6-Yzo.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-greek-600-normal-H7WoG9Et.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-greek-600-normal-mc2nkWzM.woff +0 -0
- package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
- package/dist/client/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/client/assets/jetbrains-mono-latin-ext-600-normal-BfB_LPfz.woff2 +0 -0
- package/dist/client/assets/jetbrains-mono-latin-ext-600-normal-DObL3zCW.woff +0 -0
- package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/client/assets/jetbrains-mono-vietnamese-600-normal-OWROknRo.woff +0 -0
- package/dist/client/assets/journeyDiagram-JHISSGLW-BFlCgna7.js +139 -0
- package/dist/client/assets/kanban-definition-UN3LZRKU-Ck0sh2an.js +89 -0
- package/dist/client/assets/katex-DkKDou_j.js +257 -0
- package/dist/client/assets/katex.min-CfVKi3_s.css +1 -0
- package/dist/client/assets/katex.min-DBnXG_UH.js +1 -0
- package/dist/client/assets/layout-DGIYPm2g.js +1 -0
- package/dist/client/assets/linear-CfOau-eX.js +1 -0
- package/dist/client/assets/mermaid.core-BUwfPdjt.js +308 -0
- package/dist/client/assets/mindmap-definition-RKZ34NQL-tpaiyCEI.js +96 -0
- package/dist/client/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/client/assets/pieDiagram-4H26LBE5-DaNIMGBH.js +30 -0
- package/dist/client/assets/quadrantDiagram-W4KKPZXB-BUZRyB7p.js +7 -0
- package/dist/client/assets/requirementDiagram-4Y6WPE33-D70DEVyJ.js +84 -0
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-DckSl4S7.js +40 -0
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-If3g75AG.js +162 -0
- package/dist/client/assets/stateDiagram-AJRCARHV-C-83rolJ.js +1 -0
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-BeI0-mGo.js +1 -0
- package/dist/client/assets/timeline-definition-PNZ67QCA-DIFKx0ow.js +120 -0
- package/dist/client/assets/vennDiagram-CIIHVFJN-BGLU5r80.js +34 -0
- package/dist/client/assets/wardley-L42UT6IY-qocA9kr0.js +173 -0
- package/dist/client/assets/wardleyDiagram-YWT4CUSO-CWhmOVum.js +78 -0
- package/dist/client/assets/xychartDiagram-2RQKCTM6-D2Sc4Taz.js +7 -0
- package/dist/client/bootstrap.js +45 -0
- package/dist/client/favicon.svg +5 -0
- package/dist/client/index.html +21 -0
- package/package.json +83 -0
package/bin/mdview.mjs
ADDED
|
@@ -0,0 +1,1418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
5
|
+
import path11 from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import openBrowser from "open";
|
|
8
|
+
|
|
9
|
+
// src/server/config.ts
|
|
10
|
+
import { readFile } from "fs/promises";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import path from "path";
|
|
13
|
+
|
|
14
|
+
// src/shared/types.ts
|
|
15
|
+
var PALETTES = ["classic", "paper", "nord", "solarized", "high-contrast"];
|
|
16
|
+
|
|
17
|
+
// src/server/config.ts
|
|
18
|
+
var CONFIG_FILENAME = ".mdview.json";
|
|
19
|
+
var GLOBAL_CONFIG_FILENAME = "config.json";
|
|
20
|
+
var FONT_FAMILIES = ["serif", "sans", "mono"];
|
|
21
|
+
var IGNORE_ENTRY_PATTERN = /^[A-Za-z0-9_.\-+]{1,64}$/;
|
|
22
|
+
function isObject(v) {
|
|
23
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
24
|
+
}
|
|
25
|
+
function globalConfigPath() {
|
|
26
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
27
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
|
|
28
|
+
return path.join(base, "mdview", GLOBAL_CONFIG_FILENAME);
|
|
29
|
+
}
|
|
30
|
+
function validateConfig(raw, source = CONFIG_FILENAME) {
|
|
31
|
+
if (!isObject(raw)) return null;
|
|
32
|
+
const out = {};
|
|
33
|
+
if ("palette" in raw) {
|
|
34
|
+
const p = raw.palette;
|
|
35
|
+
if (typeof p === "string" && PALETTES.includes(p)) {
|
|
36
|
+
out.palette = p;
|
|
37
|
+
} else {
|
|
38
|
+
console.warn(`[mdview] ${source}: ignoring invalid palette: ${String(p)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if ("fontFamily" in raw) {
|
|
42
|
+
const f = raw.fontFamily;
|
|
43
|
+
if (typeof f === "string" && FONT_FAMILIES.includes(f)) {
|
|
44
|
+
out.fontFamily = f;
|
|
45
|
+
} else {
|
|
46
|
+
console.warn(`[mdview] ${source}: ignoring invalid fontFamily: ${String(f)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if ("lineWidth" in raw) {
|
|
50
|
+
const w = raw.lineWidth;
|
|
51
|
+
if (typeof w === "string" && w.length > 0 && w.length <= 32 && /^[\w%.-]+$/.test(w)) {
|
|
52
|
+
out.lineWidth = w;
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(`[mdview] ${source}: ignoring invalid lineWidth: ${String(w)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if ("defaultCollapsed" in raw && isObject(raw.defaultCollapsed)) {
|
|
58
|
+
const dc = raw.defaultCollapsed;
|
|
59
|
+
const collapsed = {};
|
|
60
|
+
if (typeof dc.tree === "boolean") collapsed.tree = dc.tree;
|
|
61
|
+
if (typeof dc.outline === "boolean") collapsed.outline = dc.outline;
|
|
62
|
+
if (collapsed.tree !== void 0 || collapsed.outline !== void 0) {
|
|
63
|
+
out.defaultCollapsed = collapsed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if ("ignore" in raw) {
|
|
67
|
+
const ig = raw.ignore;
|
|
68
|
+
if (Array.isArray(ig)) {
|
|
69
|
+
const cleaned = [];
|
|
70
|
+
for (const entry of ig) {
|
|
71
|
+
if (typeof entry === "string" && entry !== "." && entry !== ".." && IGNORE_ENTRY_PATTERN.test(entry)) {
|
|
72
|
+
cleaned.push(entry);
|
|
73
|
+
} else {
|
|
74
|
+
console.warn(`[mdview] ${source}: ignoring invalid ignore entry: ${String(entry)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (cleaned.length > 0) out.ignore = cleaned;
|
|
78
|
+
} else {
|
|
79
|
+
console.warn(`[mdview] ${source}: ignoring invalid ignore (expected array of strings)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
async function loadConfigFromPath(filePath, source) {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = await readFile(filePath, "utf8");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err.code === "ENOENT") return null;
|
|
90
|
+
console.warn(`[mdview] failed to read ${source}: ${err.message}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(`[mdview] ${source} is not valid JSON: ${err.message}`);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return validateConfig(parsed, source);
|
|
101
|
+
}
|
|
102
|
+
async function loadProjectConfig(rootAbsPath) {
|
|
103
|
+
return loadConfigFromPath(path.join(rootAbsPath, CONFIG_FILENAME), CONFIG_FILENAME);
|
|
104
|
+
}
|
|
105
|
+
async function loadGlobalConfig() {
|
|
106
|
+
return loadConfigFromPath(globalConfigPath(), `~/.config/mdview/${GLOBAL_CONFIG_FILENAME}`);
|
|
107
|
+
}
|
|
108
|
+
function mergeConfigs(global, project) {
|
|
109
|
+
if (!global && !project) return null;
|
|
110
|
+
const out = {};
|
|
111
|
+
if (global?.palette !== void 0) out.palette = global.palette;
|
|
112
|
+
if (project?.palette !== void 0) out.palette = project.palette;
|
|
113
|
+
if (global?.fontFamily !== void 0) out.fontFamily = global.fontFamily;
|
|
114
|
+
if (project?.fontFamily !== void 0) out.fontFamily = project.fontFamily;
|
|
115
|
+
if (global?.lineWidth !== void 0) out.lineWidth = global.lineWidth;
|
|
116
|
+
if (project?.lineWidth !== void 0) out.lineWidth = project.lineWidth;
|
|
117
|
+
if (global?.defaultCollapsed || project?.defaultCollapsed) {
|
|
118
|
+
out.defaultCollapsed = { ...global?.defaultCollapsed, ...project?.defaultCollapsed };
|
|
119
|
+
}
|
|
120
|
+
const ig = [...global?.ignore ?? [], ...project?.ignore ?? []];
|
|
121
|
+
if (ig.length > 0) {
|
|
122
|
+
out.ignore = Array.from(new Set(ig));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
async function loadEffectiveConfig(rootAbsPath) {
|
|
127
|
+
const [global, project] = await Promise.all([
|
|
128
|
+
loadGlobalConfig(),
|
|
129
|
+
loadProjectConfig(rootAbsPath)
|
|
130
|
+
]);
|
|
131
|
+
return mergeConfigs(global, project);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/server/config-write.ts
|
|
135
|
+
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
136
|
+
import path2 from "path";
|
|
137
|
+
var IGNORE_ENTRY_PATTERN2 = /^[A-Za-z0-9_.\-+]{1,64}$/;
|
|
138
|
+
function isValidIgnoreEntry(s) {
|
|
139
|
+
return s !== "." && s !== ".." && IGNORE_ENTRY_PATTERN2.test(s);
|
|
140
|
+
}
|
|
141
|
+
async function readGlobalRaw(filePath) {
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = await readFile2(filePath, "utf8");
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (err.code === "ENOENT") return {};
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(raw);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error(`Existing config at ${filePath} is not valid JSON \u2014 fix or remove it before editing via CLI.`);
|
|
154
|
+
}
|
|
155
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
156
|
+
throw new Error(`Existing config at ${filePath} is not a JSON object \u2014 fix or remove it before editing via CLI.`);
|
|
157
|
+
}
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
async function writeGlobalRaw(filePath, data) {
|
|
161
|
+
await mkdir(path2.dirname(filePath), { recursive: true });
|
|
162
|
+
const json = JSON.stringify(data, null, 2) + "\n";
|
|
163
|
+
await writeFile(filePath, json, "utf8");
|
|
164
|
+
}
|
|
165
|
+
function readIgnoreArray(cfg) {
|
|
166
|
+
const v = cfg.ignore;
|
|
167
|
+
if (!Array.isArray(v)) return [];
|
|
168
|
+
return v.filter((x) => typeof x === "string");
|
|
169
|
+
}
|
|
170
|
+
async function addIgnoreEntries(names, filePath = globalConfigPath()) {
|
|
171
|
+
for (const n of names) {
|
|
172
|
+
if (!isValidIgnoreEntry(n)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Invalid ignore entry: ${JSON.stringify(n)}
|
|
175
|
+
Must match /^[A-Za-z0-9_.\\-+]{1,64}$/ and not be '.' or '..'.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const cfg = await readGlobalRaw(filePath);
|
|
180
|
+
const before = readIgnoreArray(cfg);
|
|
181
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...before, ...names])).sort();
|
|
182
|
+
cfg.ignore = merged;
|
|
183
|
+
await writeGlobalRaw(filePath, cfg);
|
|
184
|
+
return { before, after: merged, path: filePath };
|
|
185
|
+
}
|
|
186
|
+
async function removeIgnoreEntries(names, filePath = globalConfigPath()) {
|
|
187
|
+
const cfg = await readGlobalRaw(filePath);
|
|
188
|
+
const before = readIgnoreArray(cfg);
|
|
189
|
+
const set = new Set(before);
|
|
190
|
+
for (const n of names) set.delete(n);
|
|
191
|
+
const after = Array.from(set).sort();
|
|
192
|
+
if (after.length === 0) {
|
|
193
|
+
delete cfg.ignore;
|
|
194
|
+
} else {
|
|
195
|
+
cfg.ignore = after;
|
|
196
|
+
}
|
|
197
|
+
await writeGlobalRaw(filePath, cfg);
|
|
198
|
+
return { before, after, path: filePath };
|
|
199
|
+
}
|
|
200
|
+
async function listUserIgnoreEntries(filePath = globalConfigPath()) {
|
|
201
|
+
const cfg = await readGlobalRaw(filePath);
|
|
202
|
+
return readIgnoreArray(cfg);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/shared/ignore.ts
|
|
206
|
+
var DEFAULT_IGNORED_DIRS = [
|
|
207
|
+
"node_modules",
|
|
208
|
+
"dist",
|
|
209
|
+
"build",
|
|
210
|
+
"out",
|
|
211
|
+
"target",
|
|
212
|
+
"coverage",
|
|
213
|
+
"vendor",
|
|
214
|
+
"__pycache__",
|
|
215
|
+
"venv",
|
|
216
|
+
"Pods",
|
|
217
|
+
"DerivedData",
|
|
218
|
+
"_build"
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
// src/cli-config.ts
|
|
222
|
+
function printConfigUsage() {
|
|
223
|
+
console.error(
|
|
224
|
+
`
|
|
225
|
+
mdview config \u2014 read or edit the global config (${globalConfigPath()})
|
|
226
|
+
|
|
227
|
+
Subcommands:
|
|
228
|
+
path Print the global config file path
|
|
229
|
+
ignore list Show the built-in skips plus user-added ones
|
|
230
|
+
ignore add <name\u2026> Append basename(s) to the ignore list
|
|
231
|
+
ignore rm <name\u2026> Remove basename(s) from the ignore list
|
|
232
|
+
|
|
233
|
+
Examples:
|
|
234
|
+
mdview config path
|
|
235
|
+
mdview config ignore add deps _site
|
|
236
|
+
mdview config ignore list
|
|
237
|
+
|
|
238
|
+
After editing the ignore list, restart any running mdview instance \u2014 the file
|
|
239
|
+
watcher caches its skip set at startup.
|
|
240
|
+
`.trim()
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
function printIgnoreUsage() {
|
|
244
|
+
console.error("Usage: mdview config ignore (list|add|rm) [names\u2026]");
|
|
245
|
+
}
|
|
246
|
+
async function runConfigSubcommand(argv) {
|
|
247
|
+
const [sub, ...rest] = argv;
|
|
248
|
+
if (!sub || sub === "-h" || sub === "--help") {
|
|
249
|
+
printConfigUsage();
|
|
250
|
+
return sub ? 0 : 2;
|
|
251
|
+
}
|
|
252
|
+
switch (sub) {
|
|
253
|
+
case "path":
|
|
254
|
+
console.log(globalConfigPath());
|
|
255
|
+
return 0;
|
|
256
|
+
case "ignore":
|
|
257
|
+
return runIgnoreSubcommand(rest);
|
|
258
|
+
default:
|
|
259
|
+
console.error(`Unknown subcommand: config ${sub}`);
|
|
260
|
+
printConfigUsage();
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function runIgnoreSubcommand(argv) {
|
|
265
|
+
const [action, ...names] = argv;
|
|
266
|
+
if (!action) {
|
|
267
|
+
printIgnoreUsage();
|
|
268
|
+
return 2;
|
|
269
|
+
}
|
|
270
|
+
switch (action) {
|
|
271
|
+
case "list": {
|
|
272
|
+
const userList = await listUserIgnoreEntries();
|
|
273
|
+
console.log("Built-in (always skipped):");
|
|
274
|
+
for (const n of DEFAULT_IGNORED_DIRS) console.log(` ${n}`);
|
|
275
|
+
console.log("Dotfiles / dotdirs are always skipped too (.git, .next, .venv, \u2026).");
|
|
276
|
+
console.log("");
|
|
277
|
+
console.log(`User (${globalConfigPath()}):`);
|
|
278
|
+
if (userList.length === 0) {
|
|
279
|
+
console.log(" (none \u2014 run `mdview config ignore add <name>` to add one)");
|
|
280
|
+
} else {
|
|
281
|
+
for (const n of userList) console.log(` ${n}`);
|
|
282
|
+
}
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
case "add": {
|
|
286
|
+
if (names.length === 0) {
|
|
287
|
+
console.error("mdview config ignore add: expected at least one name");
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const result = await addIgnoreEntries(names);
|
|
292
|
+
const added = result.after.filter((n) => !result.before.includes(n));
|
|
293
|
+
if (added.length === 0) {
|
|
294
|
+
console.log("Nothing changed \u2014 all entries were already in the list.");
|
|
295
|
+
} else {
|
|
296
|
+
console.log(`Added to ${result.path}: ${added.join(", ")}`);
|
|
297
|
+
console.log("Restart mdview for the watcher to pick up the change.");
|
|
298
|
+
}
|
|
299
|
+
return 0;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(err.message);
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
case "rm":
|
|
306
|
+
case "remove": {
|
|
307
|
+
if (names.length === 0) {
|
|
308
|
+
console.error("mdview config ignore rm: expected at least one name");
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const result = await removeIgnoreEntries(names);
|
|
313
|
+
const removed = result.before.filter((n) => !result.after.includes(n));
|
|
314
|
+
if (removed.length === 0) {
|
|
315
|
+
console.log("Nothing changed \u2014 none of those names were in the list.");
|
|
316
|
+
} else {
|
|
317
|
+
console.log(`Removed from ${result.path}: ${removed.join(", ")}`);
|
|
318
|
+
console.log("Restart mdview for the watcher to pick up the change.");
|
|
319
|
+
}
|
|
320
|
+
return 0;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error(err.message);
|
|
323
|
+
return 2;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
default:
|
|
327
|
+
printIgnoreUsage();
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/server/index.ts
|
|
333
|
+
import path10 from "path";
|
|
334
|
+
import chokidar2 from "chokidar";
|
|
335
|
+
import Fastify from "fastify";
|
|
336
|
+
|
|
337
|
+
// src/server/fs/ignore.ts
|
|
338
|
+
import path3 from "path";
|
|
339
|
+
var DEFAULT_IGNORED_DIRS2 = new Set(DEFAULT_IGNORED_DIRS);
|
|
340
|
+
function buildIgnoreSet(extra = []) {
|
|
341
|
+
return /* @__PURE__ */ new Set([...DEFAULT_IGNORED_DIRS2, ...extra]);
|
|
342
|
+
}
|
|
343
|
+
function isDirIgnored(name, set) {
|
|
344
|
+
return name.startsWith(".") || set.has(name);
|
|
345
|
+
}
|
|
346
|
+
function isPathIgnored(abs, root, set) {
|
|
347
|
+
const rel = path3.relative(root, abs);
|
|
348
|
+
if (rel === "" || rel.startsWith("..")) return false;
|
|
349
|
+
for (const seg of rel.split(path3.sep)) {
|
|
350
|
+
if (!seg) continue;
|
|
351
|
+
if (seg.startsWith(".")) return true;
|
|
352
|
+
if (set.has(seg)) return true;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/server/routes/api-asset.ts
|
|
358
|
+
import { createReadStream } from "fs";
|
|
359
|
+
import { stat } from "fs/promises";
|
|
360
|
+
import path5 from "path";
|
|
361
|
+
|
|
362
|
+
// src/server/fs/resolve.ts
|
|
363
|
+
import path4 from "path";
|
|
364
|
+
function resolveSafePath(root, relPath) {
|
|
365
|
+
if (path4.isAbsolute(relPath)) {
|
|
366
|
+
throw new Error(`Refusing absolute path: ${relPath}`);
|
|
367
|
+
}
|
|
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)) {
|
|
372
|
+
throw new Error(`Path resolves outside root: ${relPath}`);
|
|
373
|
+
}
|
|
374
|
+
return candidate;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/server/routes/api-asset.ts
|
|
378
|
+
var MIME = {
|
|
379
|
+
".png": "image/png",
|
|
380
|
+
".jpg": "image/jpeg",
|
|
381
|
+
".jpeg": "image/jpeg",
|
|
382
|
+
".gif": "image/gif",
|
|
383
|
+
".webp": "image/webp",
|
|
384
|
+
".svg": "image/svg+xml",
|
|
385
|
+
".avif": "image/avif",
|
|
386
|
+
".bmp": "image/bmp",
|
|
387
|
+
".ico": "image/x-icon",
|
|
388
|
+
".mp4": "video/mp4",
|
|
389
|
+
".webm": "video/webm",
|
|
390
|
+
".mov": "video/quicktime",
|
|
391
|
+
".mp3": "audio/mpeg",
|
|
392
|
+
".wav": "audio/wav",
|
|
393
|
+
".pdf": "application/pdf",
|
|
394
|
+
".txt": "text/plain; charset=utf-8"
|
|
395
|
+
};
|
|
396
|
+
function registerApiAsset(app, rootAbsPath) {
|
|
397
|
+
app.get("/__asset/*", async (req, reply) => {
|
|
398
|
+
const rel = (req.params["*"] ?? "").replace(/^\/+/, "");
|
|
399
|
+
let abs;
|
|
400
|
+
try {
|
|
401
|
+
abs = resolveSafePath(rootAbsPath, rel);
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return reply.code(400).send({ error: err.message });
|
|
404
|
+
}
|
|
405
|
+
let st;
|
|
406
|
+
try {
|
|
407
|
+
st = await stat(abs);
|
|
408
|
+
} catch {
|
|
409
|
+
return reply.code(404).send({ error: "Asset not found" });
|
|
410
|
+
}
|
|
411
|
+
if (!st.isFile()) {
|
|
412
|
+
return reply.code(404).send({ error: "Not a file" });
|
|
413
|
+
}
|
|
414
|
+
const ext = path5.extname(abs).toLowerCase();
|
|
415
|
+
const mime = MIME[ext];
|
|
416
|
+
if (!mime) {
|
|
417
|
+
return reply.code(404).send({ error: "Asset type not allowed" });
|
|
418
|
+
}
|
|
419
|
+
reply.header("content-type", mime);
|
|
420
|
+
reply.header("content-length", st.size);
|
|
421
|
+
reply.header("cache-control", "no-cache");
|
|
422
|
+
return reply.send(createReadStream(abs));
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/server/routes/api-file.ts
|
|
427
|
+
import { readFile as readFile3, stat as stat2 } from "fs/promises";
|
|
428
|
+
|
|
429
|
+
// src/render/frontmatter.ts
|
|
430
|
+
import matter from "gray-matter";
|
|
431
|
+
function parseFrontmatter(raw) {
|
|
432
|
+
if (!raw.startsWith("---")) {
|
|
433
|
+
return { data: null, body: raw };
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const parsed = matter(raw);
|
|
437
|
+
return {
|
|
438
|
+
data: parsed.data ?? null,
|
|
439
|
+
body: parsed.content.replace(/^\n+/, "")
|
|
440
|
+
};
|
|
441
|
+
} catch {
|
|
442
|
+
return { data: null, body: raw };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/render/links.ts
|
|
447
|
+
import path6 from "path/posix";
|
|
448
|
+
var MD_EXT_RE = /\.(md|markdown|mdx)$/i;
|
|
449
|
+
var HREF_RE = /<a\s+([^>]*?)href="([^"#?]*)(\?[^"#]*)?(#[^"]*)?"([^>]*)>/gi;
|
|
450
|
+
var IMG_SRC_RE = /<img\s+([^>]*?)src="([^"]+)"([^>]*)>/gi;
|
|
451
|
+
function tagInternalLinks(html, currentRelPath) {
|
|
452
|
+
const currentDir = path6.dirname(currentRelPath);
|
|
453
|
+
return html.replace(HREF_RE, (full, pre, target, query, hash, post) => {
|
|
454
|
+
if (!target || /^https?:\/\//i.test(target) || target.startsWith("mailto:")) {
|
|
455
|
+
return full;
|
|
456
|
+
}
|
|
457
|
+
if (!MD_EXT_RE.test(target)) return full;
|
|
458
|
+
const resolved = path6.normalize(path6.join(currentDir, target));
|
|
459
|
+
let decoded;
|
|
460
|
+
try {
|
|
461
|
+
decoded = decodeURIComponent(resolved);
|
|
462
|
+
} catch {
|
|
463
|
+
decoded = resolved;
|
|
464
|
+
}
|
|
465
|
+
const href = `?file=${encodeURIComponent(decoded)}${hash ?? ""}`;
|
|
466
|
+
const internal = `${decoded}${query ?? ""}${hash ?? ""}`;
|
|
467
|
+
return `<a ${pre}href="${href}" data-internal-link="${internal}"${post}>`;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function rewriteImageSrc(html, currentRelPath) {
|
|
471
|
+
const currentDir = path6.dirname(currentRelPath);
|
|
472
|
+
return html.replace(IMG_SRC_RE, (full, pre, src, post) => {
|
|
473
|
+
if (!src) return full;
|
|
474
|
+
if (/^(https?:\/\/|data:|\/__asset\/)/i.test(src)) return full;
|
|
475
|
+
const trimmed = src.replace(/^\/+/, "");
|
|
476
|
+
const resolved = path6.normalize(path6.join(currentDir, trimmed));
|
|
477
|
+
if (resolved.startsWith("..")) return full;
|
|
478
|
+
return `<img ${pre}src="/__asset/${resolved}"${post}>`;
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/render/markdown.ts
|
|
483
|
+
import MarkdownIt from "markdown-it";
|
|
484
|
+
import anchor from "markdown-it-anchor";
|
|
485
|
+
import taskLists from "markdown-it-task-lists";
|
|
486
|
+
|
|
487
|
+
// src/render/math.ts
|
|
488
|
+
var BLOCK_RE = /^\$\$([\s\S]+?)\$\$$/;
|
|
489
|
+
var INLINE_RE = /\$(?!\s)([^$\n]+?)(?<!\s)\$/g;
|
|
490
|
+
function blockMathHtml(latex) {
|
|
491
|
+
const encoded = encodeURIComponent(latex.trim());
|
|
492
|
+
return `<div class="math-block" data-source="${encoded}"></div>
|
|
493
|
+
`;
|
|
494
|
+
}
|
|
495
|
+
function inlineMathHtml(latex) {
|
|
496
|
+
const encoded = encodeURIComponent(latex);
|
|
497
|
+
return `<span class="math-inline" data-source="${encoded}"></span>`;
|
|
498
|
+
}
|
|
499
|
+
function convertBlockMath(tokens) {
|
|
500
|
+
for (let i = 0; i < tokens.length - 2; i++) {
|
|
501
|
+
if (tokens[i].type === "paragraph_open" && tokens[i + 1].type === "inline" && tokens[i + 2].type === "paragraph_close") {
|
|
502
|
+
const inline = tokens[i + 1];
|
|
503
|
+
const trimmed = inline.content.trim();
|
|
504
|
+
const m = trimmed.match(BLOCK_RE);
|
|
505
|
+
if (m) {
|
|
506
|
+
const replacement = new tokens[i].constructor(
|
|
507
|
+
"html_block",
|
|
508
|
+
"",
|
|
509
|
+
0
|
|
510
|
+
);
|
|
511
|
+
replacement.content = blockMathHtml(m[1]);
|
|
512
|
+
tokens.splice(i, 3, replacement);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function convertInlineMath(tokens) {
|
|
518
|
+
const TokenCtor = tokens[0]?.constructor;
|
|
519
|
+
if (!TokenCtor) return;
|
|
520
|
+
for (const top of tokens) {
|
|
521
|
+
if (top.type !== "inline" || !top.children) continue;
|
|
522
|
+
const out = [];
|
|
523
|
+
for (const child of top.children) {
|
|
524
|
+
if (child.type !== "text") {
|
|
525
|
+
out.push(child);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const text = child.content;
|
|
529
|
+
INLINE_RE.lastIndex = 0;
|
|
530
|
+
let last = 0;
|
|
531
|
+
let m;
|
|
532
|
+
let found = false;
|
|
533
|
+
while ((m = INLINE_RE.exec(text)) !== null) {
|
|
534
|
+
found = true;
|
|
535
|
+
if (m.index > last) {
|
|
536
|
+
const t = new TokenCtor("text", "", 0);
|
|
537
|
+
t.content = text.slice(last, m.index);
|
|
538
|
+
out.push(t);
|
|
539
|
+
}
|
|
540
|
+
const mathTok = new TokenCtor("html_inline", "", 0);
|
|
541
|
+
mathTok.content = inlineMathHtml(m[1]);
|
|
542
|
+
out.push(mathTok);
|
|
543
|
+
last = m.index + m[0].length;
|
|
544
|
+
}
|
|
545
|
+
if (!found) {
|
|
546
|
+
out.push(child);
|
|
547
|
+
} else if (last < text.length) {
|
|
548
|
+
const t = new TokenCtor("text", "", 0);
|
|
549
|
+
t.content = text.slice(last);
|
|
550
|
+
out.push(t);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
top.children = out;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function mathPlugin(md2) {
|
|
557
|
+
md2.core.ruler.after("inline", "mdview-math", (state) => {
|
|
558
|
+
convertBlockMath(state.tokens);
|
|
559
|
+
convertInlineMath(state.tokens);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/render/shiki.ts
|
|
564
|
+
import { createHighlighter } from "shiki";
|
|
565
|
+
var COMMON_LANGUAGES = [
|
|
566
|
+
"ts",
|
|
567
|
+
"tsx",
|
|
568
|
+
"js",
|
|
569
|
+
"jsx",
|
|
570
|
+
"python",
|
|
571
|
+
"go",
|
|
572
|
+
"rust",
|
|
573
|
+
"java",
|
|
574
|
+
"kotlin",
|
|
575
|
+
"css",
|
|
576
|
+
"scss",
|
|
577
|
+
"html",
|
|
578
|
+
"json",
|
|
579
|
+
"yaml",
|
|
580
|
+
"toml",
|
|
581
|
+
"bash",
|
|
582
|
+
"shell",
|
|
583
|
+
"sql",
|
|
584
|
+
"md",
|
|
585
|
+
"diff",
|
|
586
|
+
"dockerfile"
|
|
587
|
+
];
|
|
588
|
+
var PALETTE_THEME_MAP = {
|
|
589
|
+
"classic-light": "github-light",
|
|
590
|
+
"classic-dark": "github-dark",
|
|
591
|
+
"paper-light": "min-light",
|
|
592
|
+
"paper-dark": "vitesse-dark",
|
|
593
|
+
"nord-light": "min-light",
|
|
594
|
+
"nord-dark": "nord",
|
|
595
|
+
"solarized-light": "solarized-light",
|
|
596
|
+
"solarized-dark": "solarized-dark",
|
|
597
|
+
"high-contrast-light": "github-light-high-contrast",
|
|
598
|
+
"high-contrast-dark": "github-dark-high-contrast"
|
|
599
|
+
};
|
|
600
|
+
var UNDERLYING_THEMES = Array.from(
|
|
601
|
+
new Set(Object.values(PALETTE_THEME_MAP))
|
|
602
|
+
);
|
|
603
|
+
var highlighterPromise = null;
|
|
604
|
+
function getHighlighter() {
|
|
605
|
+
if (!highlighterPromise) {
|
|
606
|
+
highlighterPromise = createHighlighter({
|
|
607
|
+
themes: UNDERLYING_THEMES,
|
|
608
|
+
langs: COMMON_LANGUAGES
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return highlighterPromise;
|
|
612
|
+
}
|
|
613
|
+
function escapeHtml(s) {
|
|
614
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
615
|
+
}
|
|
616
|
+
async function highlightCode(code, lang) {
|
|
617
|
+
const highlighter = await getHighlighter();
|
|
618
|
+
const loaded = highlighter.getLoadedLanguages();
|
|
619
|
+
let effectiveLang = lang;
|
|
620
|
+
if (lang && !loaded.includes(lang) && !["text", "plain", ""].includes(lang)) {
|
|
621
|
+
try {
|
|
622
|
+
await highlighter.loadLanguage(lang);
|
|
623
|
+
} catch {
|
|
624
|
+
effectiveLang = "text";
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
return highlighter.codeToHtml(code, {
|
|
629
|
+
lang: effectiveLang || "text",
|
|
630
|
+
themes: PALETTE_THEME_MAP,
|
|
631
|
+
defaultColor: false
|
|
632
|
+
});
|
|
633
|
+
} catch {
|
|
634
|
+
return `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/render/markdown.ts
|
|
639
|
+
var md = new MarkdownIt({
|
|
640
|
+
html: true,
|
|
641
|
+
linkify: true,
|
|
642
|
+
typographer: false,
|
|
643
|
+
breaks: false,
|
|
644
|
+
highlight: () => ""
|
|
645
|
+
});
|
|
646
|
+
md.use(anchor, {
|
|
647
|
+
permalink: false,
|
|
648
|
+
slugify: (s) => s.toLowerCase().trim().replace(/[^\wÀ-ɏ\s-]/g, "").replace(/\s+/g, "-")
|
|
649
|
+
});
|
|
650
|
+
md.use(taskLists, { enabled: false, label: false });
|
|
651
|
+
md.use(mathPlugin);
|
|
652
|
+
async function renderMarkdown(source) {
|
|
653
|
+
const tokens = md.parse(source, {});
|
|
654
|
+
for (const token of tokens) {
|
|
655
|
+
if (token.type === "fence") {
|
|
656
|
+
const lang = token.info.trim().split(/\s+/)[0] || "text";
|
|
657
|
+
if (lang === "mermaid") {
|
|
658
|
+
token.type = "html_block";
|
|
659
|
+
token.content = `<div class="mermaid-block" data-source="${encodeURIComponent(token.content)}"></div>
|
|
660
|
+
`;
|
|
661
|
+
} else {
|
|
662
|
+
const highlighted = await highlightCode(token.content, lang);
|
|
663
|
+
token.type = "html_block";
|
|
664
|
+
token.content = highlighted + "\n";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
const html = md.renderer.render(tokens, md.options, {});
|
|
669
|
+
return { html, tokens };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/render/outline.ts
|
|
673
|
+
function extractOutline(tokens) {
|
|
674
|
+
const flat = [];
|
|
675
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
676
|
+
const t = tokens[i];
|
|
677
|
+
if (t.type !== "heading_open") continue;
|
|
678
|
+
const level = Number(t.tag.slice(1));
|
|
679
|
+
const inline = tokens[i + 1];
|
|
680
|
+
const id = (t.attrGet("id") ?? "").toString();
|
|
681
|
+
const text = inline?.content?.trim() ?? "";
|
|
682
|
+
if (!id || !text) continue;
|
|
683
|
+
flat.push({ id, text, level, children: [] });
|
|
684
|
+
}
|
|
685
|
+
return nest(flat);
|
|
686
|
+
}
|
|
687
|
+
function nest(flat) {
|
|
688
|
+
const root = [];
|
|
689
|
+
const stack = [];
|
|
690
|
+
for (const node of flat) {
|
|
691
|
+
while (stack.length && stack[stack.length - 1].level >= node.level) {
|
|
692
|
+
stack.pop();
|
|
693
|
+
}
|
|
694
|
+
if (stack.length === 0) {
|
|
695
|
+
root.push(node);
|
|
696
|
+
} else {
|
|
697
|
+
stack[stack.length - 1].children.push(node);
|
|
698
|
+
}
|
|
699
|
+
stack.push(node);
|
|
700
|
+
}
|
|
701
|
+
return root;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/server/routes/api-file.ts
|
|
705
|
+
function registerApiFile(app, rootAbsPath, rootInfo) {
|
|
706
|
+
app.get("/api/file", async (req, reply) => {
|
|
707
|
+
const requested = req.query.path?.trim() ?? "";
|
|
708
|
+
let relPath;
|
|
709
|
+
if (rootInfo.rootKind === "file") {
|
|
710
|
+
if (requested && requested !== rootInfo.rootRelPath) {
|
|
711
|
+
return reply.code(404).send({ error: "File not found in single-file mode" });
|
|
712
|
+
}
|
|
713
|
+
relPath = rootInfo.rootRelPath;
|
|
714
|
+
} else {
|
|
715
|
+
if (!requested) {
|
|
716
|
+
return reply.code(400).send({ error: "Missing ?path" });
|
|
717
|
+
}
|
|
718
|
+
relPath = requested;
|
|
719
|
+
}
|
|
720
|
+
if (!/\.(md|markdown|mdx)$/i.test(relPath)) {
|
|
721
|
+
return reply.code(400).send({ error: "Only markdown files are supported" });
|
|
722
|
+
}
|
|
723
|
+
let absPath;
|
|
724
|
+
try {
|
|
725
|
+
absPath = resolveSafePath(rootAbsPath, relPath);
|
|
726
|
+
} catch (err) {
|
|
727
|
+
return reply.code(400).send({ error: err.message });
|
|
728
|
+
}
|
|
729
|
+
let raw;
|
|
730
|
+
let mtimeMs;
|
|
731
|
+
try {
|
|
732
|
+
const [content, st] = await Promise.all([
|
|
733
|
+
readFile3(absPath, "utf8"),
|
|
734
|
+
stat2(absPath)
|
|
735
|
+
]);
|
|
736
|
+
raw = content;
|
|
737
|
+
mtimeMs = st.mtimeMs;
|
|
738
|
+
} catch {
|
|
739
|
+
return reply.code(404).send({ error: "File not found" });
|
|
740
|
+
}
|
|
741
|
+
const { data, body } = parseFrontmatter(raw);
|
|
742
|
+
const { html: rawHtml, tokens } = await renderMarkdown(body);
|
|
743
|
+
const html = rewriteImageSrc(tagInternalLinks(rawHtml, relPath), relPath);
|
|
744
|
+
const outline = extractOutline(tokens);
|
|
745
|
+
const title = (typeof data?.title === "string" ? data.title : null) ?? outline[0]?.text ?? null;
|
|
746
|
+
const result = {
|
|
747
|
+
relPath,
|
|
748
|
+
html,
|
|
749
|
+
outline,
|
|
750
|
+
frontmatter: data,
|
|
751
|
+
title,
|
|
752
|
+
lastModified: mtimeMs
|
|
753
|
+
};
|
|
754
|
+
return reply.send(result);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/server/fs/grep.ts
|
|
759
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
760
|
+
|
|
761
|
+
// src/shared/search-pattern.ts
|
|
762
|
+
function escapeRegex(s) {
|
|
763
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
764
|
+
}
|
|
765
|
+
function compilePattern(query, opts) {
|
|
766
|
+
if (!query) {
|
|
767
|
+
return {
|
|
768
|
+
test: () => null,
|
|
769
|
+
matchAll: () => [],
|
|
770
|
+
matchAllWithBudget: () => ({ matches: [], truncated: false }),
|
|
771
|
+
query,
|
|
772
|
+
valid: true
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
let re;
|
|
776
|
+
if (opts.regex) {
|
|
777
|
+
try {
|
|
778
|
+
re = new RegExp(query, opts.caseSensitive ? "g" : "gi");
|
|
779
|
+
} catch {
|
|
780
|
+
return {
|
|
781
|
+
test: () => null,
|
|
782
|
+
matchAll: () => [],
|
|
783
|
+
matchAllWithBudget: () => ({ matches: [], truncated: false }),
|
|
784
|
+
query,
|
|
785
|
+
valid: false
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
let pat = escapeRegex(query);
|
|
790
|
+
if (opts.wholeWord) pat = `\\b${pat}\\b`;
|
|
791
|
+
re = new RegExp(pat, opts.caseSensitive ? "g" : "gi");
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
query,
|
|
795
|
+
valid: true,
|
|
796
|
+
test(text) {
|
|
797
|
+
re.lastIndex = 0;
|
|
798
|
+
return re.exec(text);
|
|
799
|
+
},
|
|
800
|
+
matchAll(text) {
|
|
801
|
+
re.lastIndex = 0;
|
|
802
|
+
const out = [];
|
|
803
|
+
let m;
|
|
804
|
+
while ((m = re.exec(text)) !== null) {
|
|
805
|
+
if (m[0].length === 0) {
|
|
806
|
+
re.lastIndex++;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
out.push({ index: m.index, length: m[0].length });
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
},
|
|
813
|
+
matchAllWithBudget(text, budgetMs) {
|
|
814
|
+
re.lastIndex = 0;
|
|
815
|
+
const matches = [];
|
|
816
|
+
const deadline = performance.now() + budgetMs;
|
|
817
|
+
let m;
|
|
818
|
+
while ((m = re.exec(text)) !== null) {
|
|
819
|
+
if (m[0].length === 0) {
|
|
820
|
+
re.lastIndex++;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
matches.push({ index: m.index, length: m[0].length });
|
|
824
|
+
if (performance.now() > deadline) {
|
|
825
|
+
return { matches, truncated: true };
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return { matches, truncated: false };
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/shared/tree-utils.ts
|
|
834
|
+
var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
|
|
835
|
+
function flattenMdRelPaths(nodes, out = []) {
|
|
836
|
+
for (const n of nodes) {
|
|
837
|
+
if (n.type === "file" && n.isMarkdown) out.push(n.relPath);
|
|
838
|
+
else if (n.type === "dir" && n.children) flattenMdRelPaths(n.children, out);
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// src/server/fs/tree.ts
|
|
844
|
+
import { readdir } from "fs/promises";
|
|
845
|
+
import path7 from "path";
|
|
846
|
+
async function walkFolder(root, opts = {}) {
|
|
847
|
+
const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
|
|
848
|
+
return walkInner(root, "", ignore);
|
|
849
|
+
}
|
|
850
|
+
async function walkInner(root, relBase, ignore) {
|
|
851
|
+
const absDir = path7.join(root, relBase);
|
|
852
|
+
const entries = await readdir(absDir, { withFileTypes: true });
|
|
853
|
+
const out = [];
|
|
854
|
+
for (const entry of entries) {
|
|
855
|
+
if (entry.isDirectory()) {
|
|
856
|
+
if (isDirIgnored(entry.name, ignore)) continue;
|
|
857
|
+
} else if (entry.name.startsWith(".")) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
const childRel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
861
|
+
if (entry.isDirectory()) {
|
|
862
|
+
const children = await walkInner(root, childRel, ignore);
|
|
863
|
+
out.push({
|
|
864
|
+
name: entry.name,
|
|
865
|
+
relPath: childRel,
|
|
866
|
+
type: "dir",
|
|
867
|
+
children
|
|
868
|
+
});
|
|
869
|
+
} else if (entry.isFile()) {
|
|
870
|
+
const ext = path7.extname(entry.name).toLowerCase();
|
|
871
|
+
out.push({
|
|
872
|
+
name: entry.name,
|
|
873
|
+
relPath: childRel,
|
|
874
|
+
type: "file",
|
|
875
|
+
isMarkdown: MD_EXT.has(ext)
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
out.sort((a, b) => {
|
|
880
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
881
|
+
return a.name.localeCompare(b.name);
|
|
882
|
+
});
|
|
883
|
+
return out;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/server/fs/grep.ts
|
|
887
|
+
function buildSnippet(line, start, end, radius) {
|
|
888
|
+
const from = Math.max(0, start - radius);
|
|
889
|
+
const to = Math.min(line.length, end + radius);
|
|
890
|
+
const prefix = from > 0 ? "\u2026" : "";
|
|
891
|
+
const suffix = to < line.length ? "\u2026" : "";
|
|
892
|
+
const sliced = line.slice(from, to);
|
|
893
|
+
const hlStart = prefix.length + (start - from);
|
|
894
|
+
const hlEnd = hlStart + (end - start);
|
|
895
|
+
return {
|
|
896
|
+
snippet: prefix + sliced + suffix,
|
|
897
|
+
highlight: [hlStart, hlEnd]
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
async function grepFiles(rootAbsPath, query, opts = {}) {
|
|
901
|
+
const perFileCap = opts.perFileCap ?? 20;
|
|
902
|
+
const globalCap = opts.globalCap ?? 200;
|
|
903
|
+
const snippetRadius = opts.snippetRadius ?? 60;
|
|
904
|
+
const perLineBudgetMs = opts.perLineBudgetMs ?? 5;
|
|
905
|
+
const maxLineLenForRegex = opts.maxLineLenForRegex ?? 1e4;
|
|
906
|
+
const regexMode = opts.regex ?? false;
|
|
907
|
+
if (!query || query.length === 0) {
|
|
908
|
+
return { query, results: [], truncated: false };
|
|
909
|
+
}
|
|
910
|
+
const pattern = compilePattern(query, {
|
|
911
|
+
caseSensitive: opts.caseSensitive ?? false,
|
|
912
|
+
wholeWord: opts.wholeWord ?? false,
|
|
913
|
+
regex: opts.regex ?? false
|
|
914
|
+
});
|
|
915
|
+
if (!pattern.valid) return { query, results: [], truncated: false };
|
|
916
|
+
const tree = await walkFolder(rootAbsPath, { ignore: opts.ignore });
|
|
917
|
+
const files = flattenMdRelPaths(tree);
|
|
918
|
+
const results = [];
|
|
919
|
+
let totalHits = 0;
|
|
920
|
+
let truncated = false;
|
|
921
|
+
for (const relPath of files) {
|
|
922
|
+
if (totalHits >= globalCap) {
|
|
923
|
+
truncated = true;
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
let absPath;
|
|
927
|
+
try {
|
|
928
|
+
absPath = resolveSafePath(rootAbsPath, relPath);
|
|
929
|
+
} catch {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
let raw;
|
|
933
|
+
try {
|
|
934
|
+
raw = await readFile4(absPath, "utf8");
|
|
935
|
+
} catch {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
const { body } = parseFrontmatter(raw);
|
|
939
|
+
const lines = body.split(/\r?\n/);
|
|
940
|
+
const fileHits = [];
|
|
941
|
+
let fileTotal = 0;
|
|
942
|
+
let fileTruncated = false;
|
|
943
|
+
for (let i = 0; i < lines.length; i++) {
|
|
944
|
+
const line = lines[i];
|
|
945
|
+
if (regexMode && line.length > maxLineLenForRegex) {
|
|
946
|
+
fileTruncated = true;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const { matches, truncated: lineTruncated } = regexMode ? pattern.matchAllWithBudget(line, perLineBudgetMs) : { matches: pattern.matchAll(line), truncated: false };
|
|
950
|
+
if (lineTruncated) fileTruncated = true;
|
|
951
|
+
for (const m of matches) {
|
|
952
|
+
fileTotal++;
|
|
953
|
+
if (fileHits.length < perFileCap) {
|
|
954
|
+
const { snippet, highlight } = buildSnippet(
|
|
955
|
+
line,
|
|
956
|
+
m.index,
|
|
957
|
+
m.index + m.length,
|
|
958
|
+
snippetRadius
|
|
959
|
+
);
|
|
960
|
+
fileHits.push({ line: i + 1, col: m.index, snippet, highlight });
|
|
961
|
+
totalHits++;
|
|
962
|
+
} else {
|
|
963
|
+
fileTruncated = true;
|
|
964
|
+
}
|
|
965
|
+
if (totalHits >= globalCap) {
|
|
966
|
+
truncated = true;
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (totalHits >= globalCap) break;
|
|
971
|
+
}
|
|
972
|
+
if (fileHits.length > 0) {
|
|
973
|
+
results.push({ relPath, hits: fileHits, total: fileTotal, truncated: fileTruncated });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return { query, results, truncated };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/server/routes/api-search.ts
|
|
980
|
+
var MAX_QUERY_LEN = 200;
|
|
981
|
+
function isTrue(v) {
|
|
982
|
+
return v === "1" || v === "true";
|
|
983
|
+
}
|
|
984
|
+
function registerApiSearch(app, rootAbsPath, rootInfo, configState) {
|
|
985
|
+
app.get("/api/search", async (req, reply) => {
|
|
986
|
+
if (rootInfo.rootKind === "file") {
|
|
987
|
+
return reply.send({ query: req.query.q ?? "", results: [], truncated: false });
|
|
988
|
+
}
|
|
989
|
+
const q = (req.query.q ?? "").trim();
|
|
990
|
+
if (q.length === 0) {
|
|
991
|
+
return reply.send({ query: "", results: [], truncated: false });
|
|
992
|
+
}
|
|
993
|
+
if (q.length > MAX_QUERY_LEN) {
|
|
994
|
+
return reply.code(400).send({ error: "Query too long" });
|
|
995
|
+
}
|
|
996
|
+
const out = await grepFiles(rootAbsPath, q, {
|
|
997
|
+
caseSensitive: isTrue(req.query.case),
|
|
998
|
+
wholeWord: isTrue(req.query.word),
|
|
999
|
+
regex: isTrue(req.query.regex),
|
|
1000
|
+
ignore: configState.ignoreSet
|
|
1001
|
+
});
|
|
1002
|
+
return reply.send(out);
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// src/server/routes/api-tree.ts
|
|
1007
|
+
import path8 from "path";
|
|
1008
|
+
function registerApiTree(app, rootAbsPath, rootInfo, configState) {
|
|
1009
|
+
app.get("/api/tree", async (_req, reply) => {
|
|
1010
|
+
const config = configState.current;
|
|
1011
|
+
if (rootInfo.rootKind === "file") {
|
|
1012
|
+
const single = [
|
|
1013
|
+
{
|
|
1014
|
+
name: path8.basename(rootInfo.rootRelPath),
|
|
1015
|
+
relPath: rootInfo.rootRelPath,
|
|
1016
|
+
type: "file",
|
|
1017
|
+
isMarkdown: true
|
|
1018
|
+
}
|
|
1019
|
+
];
|
|
1020
|
+
return reply.send({ root: rootInfo, tree: single, config });
|
|
1021
|
+
}
|
|
1022
|
+
const tree = await walkFolder(rootAbsPath, { ignore: configState.ignoreSet });
|
|
1023
|
+
return reply.send({ root: rootInfo, tree, config });
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// src/server/routes/sse.ts
|
|
1028
|
+
function registerSse(app, watcher) {
|
|
1029
|
+
app.get("/api/watch", (req, reply) => {
|
|
1030
|
+
reply.raw.writeHead(200, {
|
|
1031
|
+
"Content-Type": "text/event-stream",
|
|
1032
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1033
|
+
Connection: "keep-alive",
|
|
1034
|
+
"X-Accel-Buffering": "no"
|
|
1035
|
+
});
|
|
1036
|
+
reply.raw.write(": connected\n\n");
|
|
1037
|
+
let closed = false;
|
|
1038
|
+
const cleanup = () => {
|
|
1039
|
+
if (closed) return;
|
|
1040
|
+
closed = true;
|
|
1041
|
+
clearInterval(heartbeat);
|
|
1042
|
+
watcher.off("event", send);
|
|
1043
|
+
try {
|
|
1044
|
+
reply.raw.end();
|
|
1045
|
+
} catch {
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const send = (e) => {
|
|
1049
|
+
if (closed) return;
|
|
1050
|
+
try {
|
|
1051
|
+
reply.raw.write(`event: ${e.kind}
|
|
1052
|
+
`);
|
|
1053
|
+
reply.raw.write(`data: ${JSON.stringify(e)}
|
|
1054
|
+
|
|
1055
|
+
`);
|
|
1056
|
+
} catch {
|
|
1057
|
+
cleanup();
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const heartbeat = setInterval(() => {
|
|
1061
|
+
if (closed) return;
|
|
1062
|
+
try {
|
|
1063
|
+
reply.raw.write(": hb\n\n");
|
|
1064
|
+
} catch {
|
|
1065
|
+
cleanup();
|
|
1066
|
+
}
|
|
1067
|
+
}, 15e3);
|
|
1068
|
+
watcher.on("event", send);
|
|
1069
|
+
req.raw.on("close", cleanup);
|
|
1070
|
+
req.raw.on("error", cleanup);
|
|
1071
|
+
reply.raw.on("error", cleanup);
|
|
1072
|
+
reply.raw.on("close", cleanup);
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// src/server/watcher.ts
|
|
1077
|
+
import { EventEmitter } from "events";
|
|
1078
|
+
import path9 from "path";
|
|
1079
|
+
import chokidar from "chokidar";
|
|
1080
|
+
function createWatcher(rootAbsPath, opts = {}) {
|
|
1081
|
+
const emitter = new EventEmitter();
|
|
1082
|
+
emitter.setMaxListeners(0);
|
|
1083
|
+
const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
|
|
1084
|
+
const watcher = chokidar.watch(rootAbsPath, {
|
|
1085
|
+
ignoreInitial: true,
|
|
1086
|
+
ignored: (p) => isPathIgnored(p, rootAbsPath, ignore),
|
|
1087
|
+
persistent: true,
|
|
1088
|
+
awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
|
|
1089
|
+
});
|
|
1090
|
+
watcher.on("error", (err) => {
|
|
1091
|
+
const e = err;
|
|
1092
|
+
if (e?.code === "EMFILE" || e?.code === "ENOSPC") {
|
|
1093
|
+
console.error(
|
|
1094
|
+
`[mdview] file watcher hit ${e.code} while watching ${rootAbsPath}
|
|
1095
|
+
Path: ${e.path ?? "(unknown)"}
|
|
1096
|
+
Likely cause: a heavy directory (build output, deps) wasn't ignored.
|
|
1097
|
+
Fix: add the directory name to "ignore" in ~/.config/mdview/config.json, e.g.
|
|
1098
|
+
{ "ignore": ["deps", "_site"] }
|
|
1099
|
+
Built-in skips already cover ${[...DEFAULT_IGNORED_DIRS2].join(", ")}.`
|
|
1100
|
+
);
|
|
1101
|
+
} else {
|
|
1102
|
+
console.error(`[mdview] watcher error: ${e?.message ?? String(err)}`);
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
function emit(kind, abs) {
|
|
1106
|
+
const rel = path9.relative(rootAbsPath, abs).split(path9.sep).join("/");
|
|
1107
|
+
if (!rel) return;
|
|
1108
|
+
emitter.emit("event", { kind, relPath: rel });
|
|
1109
|
+
}
|
|
1110
|
+
watcher.on("change", (p) => emit("change", p));
|
|
1111
|
+
watcher.on("add", (p) => emit("add", p));
|
|
1112
|
+
watcher.on("unlink", (p) => emit("unlink", p));
|
|
1113
|
+
return {
|
|
1114
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
1115
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
1116
|
+
emitSynthetic: (e) => emitter.emit("event", e),
|
|
1117
|
+
close: () => watcher.close()
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/server/index.ts
|
|
1122
|
+
var CSP_HTML = [
|
|
1123
|
+
"default-src 'self'",
|
|
1124
|
+
"script-src 'self'",
|
|
1125
|
+
// KaTeX + mermaid inject inline styles into rendered output; the rest of
|
|
1126
|
+
// the policy is strict.
|
|
1127
|
+
"style-src 'self' 'unsafe-inline'",
|
|
1128
|
+
"img-src 'self' data: blob:",
|
|
1129
|
+
"font-src 'self' data:",
|
|
1130
|
+
"connect-src 'self'",
|
|
1131
|
+
"frame-ancestors 'none'",
|
|
1132
|
+
"base-uri 'none'",
|
|
1133
|
+
"form-action 'none'"
|
|
1134
|
+
].join("; ");
|
|
1135
|
+
async function createServer(opts) {
|
|
1136
|
+
const app = Fastify({ logger: false });
|
|
1137
|
+
app.addHook("onSend", async (_req, reply, payload) => {
|
|
1138
|
+
const ct = String(reply.getHeader("content-type") ?? "");
|
|
1139
|
+
if (ct.startsWith("text/html")) {
|
|
1140
|
+
reply.header("content-security-policy", CSP_HTML);
|
|
1141
|
+
reply.header("x-content-type-options", "nosniff");
|
|
1142
|
+
reply.header("referrer-policy", "no-referrer");
|
|
1143
|
+
}
|
|
1144
|
+
return payload;
|
|
1145
|
+
});
|
|
1146
|
+
const initialConfig = await loadEffectiveConfig(opts.rootAbsPath);
|
|
1147
|
+
const ignoreSet = buildIgnoreSet(initialConfig?.ignore ?? []);
|
|
1148
|
+
const configState = { current: initialConfig, ignoreSet };
|
|
1149
|
+
const watcher = createWatcher(opts.rootAbsPath, { ignore: ignoreSet });
|
|
1150
|
+
const configPath = path10.join(opts.rootAbsPath, CONFIG_FILENAME);
|
|
1151
|
+
const configWatcher = chokidar2.watch(configPath, {
|
|
1152
|
+
ignoreInitial: true,
|
|
1153
|
+
persistent: true,
|
|
1154
|
+
awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
|
|
1155
|
+
});
|
|
1156
|
+
const reloadConfig = async () => {
|
|
1157
|
+
configState.current = await loadEffectiveConfig(opts.rootAbsPath);
|
|
1158
|
+
const event = { kind: "config", relPath: CONFIG_FILENAME };
|
|
1159
|
+
watcher.emitSynthetic?.(event);
|
|
1160
|
+
};
|
|
1161
|
+
configWatcher.on("add", () => void reloadConfig());
|
|
1162
|
+
configWatcher.on("change", () => void reloadConfig());
|
|
1163
|
+
configWatcher.on("unlink", () => void reloadConfig());
|
|
1164
|
+
registerApiFile(app, opts.rootAbsPath, opts.rootInfo);
|
|
1165
|
+
registerApiTree(app, opts.rootAbsPath, opts.rootInfo, configState);
|
|
1166
|
+
registerApiAsset(app, opts.rootAbsPath);
|
|
1167
|
+
registerApiSearch(app, opts.rootAbsPath, opts.rootInfo, configState);
|
|
1168
|
+
registerSse(app, watcher);
|
|
1169
|
+
await app.register(import("@fastify/static"), {
|
|
1170
|
+
root: opts.clientDir,
|
|
1171
|
+
prefix: "/"
|
|
1172
|
+
});
|
|
1173
|
+
app.setNotFoundHandler(async (req, reply) => {
|
|
1174
|
+
if (req.url.startsWith("/api/") || req.url.startsWith("/__asset/")) {
|
|
1175
|
+
reply.code(404).send({ error: "Not found" });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const qIndex = req.url.indexOf("?");
|
|
1179
|
+
const rawPath = qIndex >= 0 ? req.url.slice(0, qIndex) : req.url;
|
|
1180
|
+
if (/[\r\n]/.test(rawPath)) {
|
|
1181
|
+
reply.code(404).send({ error: "Not found" });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (/\.(md|markdown|mdx)$/i.test(rawPath)) {
|
|
1185
|
+
const relPath = rawPath.replace(/^\/+/, "");
|
|
1186
|
+
const location = `/?file=${encodeURIComponent(relPath)}`;
|
|
1187
|
+
reply.code(302).header("Location", location).send();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
return reply.sendFile("index.html");
|
|
1191
|
+
});
|
|
1192
|
+
app.addHook("onClose", async () => {
|
|
1193
|
+
await watcher.close();
|
|
1194
|
+
await configWatcher.close();
|
|
1195
|
+
});
|
|
1196
|
+
return app;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/cli.ts
|
|
1200
|
+
function printUsage() {
|
|
1201
|
+
console.error(`
|
|
1202
|
+
mdview \u2014 local markdown viewer
|
|
1203
|
+
|
|
1204
|
+
Usage:
|
|
1205
|
+
mdview [path] View file or folder (default: cwd)
|
|
1206
|
+
mdview config <\u2026> Read or edit the global config (run 'mdview config -h')
|
|
1207
|
+
|
|
1208
|
+
Options:
|
|
1209
|
+
--port <n> Port to listen on (default: 7331; auto-fallback)
|
|
1210
|
+
--no-open Don't auto-launch the browser
|
|
1211
|
+
--version, -v Print version and exit
|
|
1212
|
+
--help, -h Show this help
|
|
1213
|
+
|
|
1214
|
+
Examples:
|
|
1215
|
+
mdview ./docs Browse a folder
|
|
1216
|
+
mdview README.md View a single file
|
|
1217
|
+
mdview --port 9000 Use a specific port
|
|
1218
|
+
mdview config ignore add deps _site Hide extra build dirs globally
|
|
1219
|
+
mdview config path Print global config path
|
|
1220
|
+
`.trim());
|
|
1221
|
+
}
|
|
1222
|
+
function readVersion() {
|
|
1223
|
+
const here = path11.dirname(fileURLToPath(import.meta.url));
|
|
1224
|
+
const candidates = [
|
|
1225
|
+
path11.resolve(here, "../package.json"),
|
|
1226
|
+
path11.resolve(here, "../../package.json")
|
|
1227
|
+
];
|
|
1228
|
+
for (const p of candidates) {
|
|
1229
|
+
try {
|
|
1230
|
+
const raw = readFileSync(p, "utf8");
|
|
1231
|
+
const v = JSON.parse(raw).version;
|
|
1232
|
+
if (typeof v === "string") return v;
|
|
1233
|
+
} catch {
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return "unknown";
|
|
1237
|
+
}
|
|
1238
|
+
function parseArgs(argv) {
|
|
1239
|
+
const args = { target: ".", port: 7331, portExplicit: false, open: true };
|
|
1240
|
+
let targetSet = false;
|
|
1241
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1242
|
+
const a = argv[i];
|
|
1243
|
+
if (a === "-h" || a === "--help") return { kind: "help" };
|
|
1244
|
+
if (a === "-v" || a === "--version") return { kind: "version" };
|
|
1245
|
+
if (a === "--no-open") {
|
|
1246
|
+
args.open = false;
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
if (a === "--port") {
|
|
1250
|
+
const v = argv[++i];
|
|
1251
|
+
if (!v) throw new Error("--port requires a number");
|
|
1252
|
+
args.port = Number(v);
|
|
1253
|
+
if (!Number.isInteger(args.port) || args.port <= 0) throw new Error("Invalid --port");
|
|
1254
|
+
args.portExplicit = true;
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
if (a.startsWith("-")) throw new Error(`Unknown flag: ${a}`);
|
|
1258
|
+
if (targetSet) throw new Error(`Unexpected argument: ${a}`);
|
|
1259
|
+
args.target = a;
|
|
1260
|
+
targetSet = true;
|
|
1261
|
+
}
|
|
1262
|
+
return { kind: "run", args };
|
|
1263
|
+
}
|
|
1264
|
+
function detectRoot(target) {
|
|
1265
|
+
const abs = path11.resolve(target);
|
|
1266
|
+
if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
|
|
1267
|
+
const st = statSync(abs);
|
|
1268
|
+
if (st.isDirectory()) {
|
|
1269
|
+
return {
|
|
1270
|
+
rootAbsPath: abs,
|
|
1271
|
+
rootInfo: { rootKind: "dir", rootRelPath: "", rootName: path11.basename(abs) }
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
if (st.isFile()) {
|
|
1275
|
+
return {
|
|
1276
|
+
rootAbsPath: path11.dirname(abs),
|
|
1277
|
+
rootInfo: {
|
|
1278
|
+
rootKind: "file",
|
|
1279
|
+
rootRelPath: path11.basename(abs),
|
|
1280
|
+
rootName: path11.basename(abs)
|
|
1281
|
+
}
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
throw new Error(`Unsupported path type: ${abs}`);
|
|
1285
|
+
}
|
|
1286
|
+
function portFinderHint(port) {
|
|
1287
|
+
switch (process.platform) {
|
|
1288
|
+
case "darwin":
|
|
1289
|
+
return `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
1290
|
+
case "linux":
|
|
1291
|
+
return `ss -ltnp 'sport = :${port}' (or: lsof -nP -iTCP:${port} -sTCP:LISTEN)`;
|
|
1292
|
+
case "win32":
|
|
1293
|
+
return `netstat -ano | findstr :${port} (then: tasklist /FI "PID eq <pid>")`;
|
|
1294
|
+
default:
|
|
1295
|
+
return `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
async function listen(app, port, explicit) {
|
|
1299
|
+
if (explicit) {
|
|
1300
|
+
try {
|
|
1301
|
+
await app.listen({ host: "127.0.0.1", port });
|
|
1302
|
+
return port;
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
if (err.code === "EADDRINUSE") {
|
|
1305
|
+
throw new Error(
|
|
1306
|
+
`Port ${port} is already in use.
|
|
1307
|
+
Try a different port (e.g. --port ${port + 1}) or stop whatever is bound to ${port}.
|
|
1308
|
+
Find the process with: ${portFinderHint(port)}`
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
throw err;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1315
|
+
const tryPort = port + attempt;
|
|
1316
|
+
try {
|
|
1317
|
+
await app.listen({ host: "127.0.0.1", port: tryPort });
|
|
1318
|
+
if (attempt > 0) {
|
|
1319
|
+
console.log(`port ${port} in use, using ${tryPort} instead`);
|
|
1320
|
+
}
|
|
1321
|
+
return tryPort;
|
|
1322
|
+
} catch (err) {
|
|
1323
|
+
if (err.code !== "EADDRINUSE") throw err;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
throw new Error("Could not bind a port (tried 10 in a row).");
|
|
1327
|
+
}
|
|
1328
|
+
async function main() {
|
|
1329
|
+
const argv = process.argv.slice(2);
|
|
1330
|
+
if (argv[0] === "config") {
|
|
1331
|
+
const code = await runConfigSubcommand(argv.slice(1));
|
|
1332
|
+
process.exit(code);
|
|
1333
|
+
}
|
|
1334
|
+
let parsed;
|
|
1335
|
+
try {
|
|
1336
|
+
parsed = parseArgs(argv);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
console.error(err.message);
|
|
1339
|
+
printUsage();
|
|
1340
|
+
process.exit(2);
|
|
1341
|
+
}
|
|
1342
|
+
if (parsed.kind === "help") {
|
|
1343
|
+
printUsage();
|
|
1344
|
+
process.exit(0);
|
|
1345
|
+
}
|
|
1346
|
+
if (parsed.kind === "version") {
|
|
1347
|
+
console.log(readVersion());
|
|
1348
|
+
process.exit(0);
|
|
1349
|
+
}
|
|
1350
|
+
const args = parsed.args;
|
|
1351
|
+
const { rootAbsPath, rootInfo } = detectRoot(args.target);
|
|
1352
|
+
const here = path11.dirname(fileURLToPath(import.meta.url));
|
|
1353
|
+
const clientDirCandidates = [
|
|
1354
|
+
path11.resolve(here, "../dist/client"),
|
|
1355
|
+
path11.resolve(here, "../../dist/client")
|
|
1356
|
+
];
|
|
1357
|
+
const clientDir = clientDirCandidates.find(existsSync);
|
|
1358
|
+
if (!clientDir) {
|
|
1359
|
+
console.error("Client bundle not found. Run `npm run build:client` first.");
|
|
1360
|
+
process.exit(1);
|
|
1361
|
+
}
|
|
1362
|
+
const app = await createServer({ rootAbsPath, rootInfo, clientDir });
|
|
1363
|
+
const boundPort = await listen(app, args.port, args.portExplicit);
|
|
1364
|
+
const url = rootInfo.rootKind === "file" ? `http://127.0.0.1:${boundPort}/?file=${encodeURIComponent(rootInfo.rootRelPath)}` : `http://127.0.0.1:${boundPort}/`;
|
|
1365
|
+
console.log(`mdview \u2192 ${url}`);
|
|
1366
|
+
console.log(`watching: ${rootAbsPath}`);
|
|
1367
|
+
if (args.open) await openBrowser(url);
|
|
1368
|
+
let shuttingDown = false;
|
|
1369
|
+
const shutdown = async () => {
|
|
1370
|
+
if (shuttingDown) {
|
|
1371
|
+
process.exit(130);
|
|
1372
|
+
}
|
|
1373
|
+
shuttingDown = true;
|
|
1374
|
+
console.log("mdview: shutting down...");
|
|
1375
|
+
const timeout = new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1376
|
+
try {
|
|
1377
|
+
await Promise.race([app.close().catch(() => void 0), timeout]);
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
process.exit(0);
|
|
1381
|
+
};
|
|
1382
|
+
process.on("SIGINT", shutdown);
|
|
1383
|
+
process.on("SIGTERM", shutdown);
|
|
1384
|
+
if (process.platform !== "win32") process.on("SIGHUP", shutdown);
|
|
1385
|
+
}
|
|
1386
|
+
function formatError(err) {
|
|
1387
|
+
if (!(err instanceof Error)) return null;
|
|
1388
|
+
const errno = err;
|
|
1389
|
+
if (errno.code === "ENOENT") {
|
|
1390
|
+
return { message: `mdview: path not found${errno.path ? `: ${errno.path}` : ""}`, code: 1 };
|
|
1391
|
+
}
|
|
1392
|
+
if (errno.code === "EACCES") {
|
|
1393
|
+
return { message: `mdview: permission denied${errno.path ? `: ${errno.path}` : ""}`, code: 1 };
|
|
1394
|
+
}
|
|
1395
|
+
const msg = err.message;
|
|
1396
|
+
if (msg.startsWith("Port ")) return { message: msg, code: 1 };
|
|
1397
|
+
if (msg.startsWith("Client bundle not found")) return { message: `mdview: ${msg}`, code: 1 };
|
|
1398
|
+
if (msg.startsWith("Path does not exist") || msg.startsWith("Unsupported path type") || msg.startsWith("Could not bind a port") || msg.startsWith("Unexpected argument")) {
|
|
1399
|
+
return { message: `mdview: ${msg}`, code: 1 };
|
|
1400
|
+
}
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
main().catch((err) => {
|
|
1404
|
+
const known = formatError(err);
|
|
1405
|
+
if (known) {
|
|
1406
|
+
console.error(known.message);
|
|
1407
|
+
process.exit(known.code);
|
|
1408
|
+
}
|
|
1409
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1410
|
+
console.error(`mdview: unexpected error: ${message}`);
|
|
1411
|
+
if (process.env.MDVIEW_DEBUG === "1" && err instanceof Error && err.stack) {
|
|
1412
|
+
console.error(err.stack);
|
|
1413
|
+
} else {
|
|
1414
|
+
console.error("(set MDVIEW_DEBUG=1 for full stack)");
|
|
1415
|
+
}
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
});
|
|
1418
|
+
//# sourceMappingURL=mdview.mjs.map
|