@hardikg/mdview 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +52 -10
  3. package/bin/chunk-RBYD2HWV.mjs +196 -0
  4. package/bin/chunk-WWM6JNAS.mjs +209 -0
  5. package/bin/chunk-XFRQFZZ4.mjs +20 -0
  6. package/bin/mdview.mjs +521 -1323
  7. package/bin/regex-worker.mjs +52 -0
  8. package/bin/server-KALIU7HM.mjs +1854 -0
  9. package/bin/shiki-5SFVA32N.mjs +13 -0
  10. package/dist/client/assets/23I4SOAX-BN9xvzna.js +2 -0
  11. package/dist/client/assets/26H2HVXD-Cu1OTMxT.js +2 -0
  12. package/dist/client/assets/2IZ5MNOH-DMjMO3_9.js +1 -0
  13. package/dist/client/assets/2P3CZEXH-D-4BP7dg.js +2 -0
  14. package/dist/client/assets/3E5HRNYT-DUYblpdl.js +5 -0
  15. package/dist/client/assets/3JIRLDUO-C1CwW1Ha.js +1 -0
  16. package/dist/client/assets/5MJ3UKSG-DtT76qKq.js +2 -0
  17. package/dist/client/assets/5THPTBWS-Ce_hS451.js +1 -0
  18. package/dist/client/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  19. package/dist/client/assets/A255FCTW-woaH7Mj_.js +1 -0
  20. package/dist/client/assets/AM43MQ64-CsWC_OQH.js +2 -0
  21. package/dist/client/assets/AOZTNTMC-BIehUQHD.js +5 -0
  22. package/dist/client/assets/AQNLM6KG-DIUuJgPm.js +4 -0
  23. package/dist/client/assets/BNU2RHQB-DeXpwU5k.js +2 -0
  24. package/dist/client/assets/C5HTQT3I-7U13x70S.js +4 -0
  25. package/dist/client/assets/CPKRWH5G-CRxaqyOs.js +12 -0
  26. package/dist/client/assets/CUVCMPQX-JTGMOfG2.js +1 -0
  27. package/dist/client/assets/D4V2MWCI-CytzCr6g.js +1 -0
  28. package/dist/client/assets/DHKBVIA3-CVnlqiEq.js +1 -0
  29. package/dist/client/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  30. package/dist/client/assets/DUZNPW7S-D3pifVNb.js +2 -0
  31. package/dist/client/assets/E3FWOBN4-D5m5o2g0.js +4 -0
  32. package/dist/client/assets/E65FQMJL-Bg5F0Zo8.js +1 -0
  33. package/dist/client/assets/FTXIJQGB-CggtIYAM.js +1 -0
  34. package/dist/client/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  35. package/dist/client/assets/G3OATVW2-VpVvT5i2.js +8 -0
  36. package/dist/client/assets/IV52FOAS-DgPxxRI4.js +5 -0
  37. package/dist/client/assets/JTJEEBDN-Bl0iUT_r.js +2 -0
  38. package/dist/client/assets/K7FUICZ3-DgoihkiR.js +2 -0
  39. package/dist/client/assets/LHH2HHRB-BXHTnBD0.js +2 -0
  40. package/dist/client/assets/MSPQPZZY-DQg7F6a8.js +4 -0
  41. package/dist/client/assets/OB6ZFVKF-UHmC8m-y.js +2 -0
  42. package/dist/client/assets/PLTZDMI2-C1ZAoc-e.js +1 -0
  43. package/dist/client/assets/R7EPXMMD-ChI1SqjJ.js +5 -0
  44. package/dist/client/assets/RVARI57O-CgA1WoRv.js +1 -0
  45. package/dist/client/assets/SPBWZ4VS-EqfCsJ5V.js +1 -0
  46. package/dist/client/assets/SR7OOV3Q-CFnFs0kL.js +4 -0
  47. package/dist/client/assets/STAXBNH4-D4c13Xlj.js +2 -0
  48. package/dist/client/assets/SXFARSFO-CkFSkgdi.js +1 -0
  49. package/dist/client/assets/TC77ISAL--WP0GBo0.js +2 -0
  50. package/dist/client/assets/TYIQQHVC-B67pCpxM.js +13 -0
  51. package/dist/client/assets/TZUXETIT-BFD9W5hl.js +2 -0
  52. package/dist/client/assets/U7N72BGE-Do3829la.js +6 -0
  53. package/dist/client/assets/UFPIB6EZ-DTs_MyQv.js +3 -0
  54. package/dist/client/assets/UO52YEYL-DN1qbGwF.js +3 -0
  55. package/dist/client/assets/VIK3ONSK-DC8mR2sZ.js +2 -0
  56. package/dist/client/assets/VVVO4ZVP-DAtGLbxR.js +2 -0
  57. package/dist/client/assets/WRGXZO53-D30IuREw.js +3 -0
  58. package/dist/client/assets/X5SG7MGC-CdjdoL_s.js +2 -0
  59. package/dist/client/assets/XLHPVVFU-BFP9ID9p.js +2 -0
  60. package/dist/client/assets/XXYPNCD5-DqCewu_S.js +5 -0
  61. package/dist/client/assets/Z6WRYFJC-BEt1KCoU.js +7 -0
  62. package/dist/client/assets/ZI2QNEGF-VzC4uHcW.js +8 -0
  63. package/dist/client/assets/index-B96jWJTi.css +1 -0
  64. package/dist/client/assets/index-DCU09WmS.js +2 -0
  65. package/dist/client/assets/katex-C0xaXAD4.js +257 -0
  66. package/dist/client/assets/katex.min-3X1qOa03.js +1 -0
  67. package/dist/client/assets/katex.min-DMey5pMQ.css +1 -0
  68. package/dist/client/assets/lazy-DaMZI1F7.js +45 -0
  69. package/dist/client/index.html +2 -2
  70. package/package.json +32 -27
  71. package/dist/client/assets/2DXBBFLN-BxPqHNSN.js +0 -5
  72. package/dist/client/assets/46IJ22JX-u_jvQJNg.js +0 -2
  73. package/dist/client/assets/4TTDE5I4-BgbNAVNj.js +0 -3
  74. package/dist/client/assets/4UF3HTLV-DNbPxlOE.js +0 -2
  75. package/dist/client/assets/5THPTBWS-D57irc_4.js +0 -1
  76. package/dist/client/assets/7WLXJQOR-CHhKreq0.js +0 -1
  77. package/dist/client/assets/AAXQQPKT-CsAJqrOC.js +0 -5
  78. package/dist/client/assets/AKE5O7KZ-MSuBOjCG.js +0 -2
  79. package/dist/client/assets/BNMLP6AG-Ca3zyKQY.js +0 -3
  80. package/dist/client/assets/DKTLEL5E-B7ILCVTH.js +0 -1
  81. package/dist/client/assets/DR6PKOQU-Bw7cHvYp.js +0 -2
  82. package/dist/client/assets/E4RJ7UJB-CYB5C4K7.js +0 -2
  83. package/dist/client/assets/ELSXBIJH-DG0MW5dC.js +0 -1
  84. package/dist/client/assets/ERTIBQVS-BMWumWfk.js +0 -2
  85. package/dist/client/assets/F653G4FO-FI8M9Mc0.js +0 -12
  86. package/dist/client/assets/FKCDD66M-BPFkyrwI.js +0 -1
  87. package/dist/client/assets/FP7KDXVC-BLEBNkCO.js +0 -2
  88. package/dist/client/assets/FT5I3RHX-j5YoMicq.js +0 -6
  89. package/dist/client/assets/FTXIJQGB-nrHdCfp7.js +0 -1
  90. package/dist/client/assets/G3MDBVQH-EkdejvdW.js +0 -1
  91. package/dist/client/assets/GY35JXOZ-3ovTGxgz.js +0 -2
  92. package/dist/client/assets/H5EEJPP2-CNVQBEB3.js +0 -1
  93. package/dist/client/assets/HWDLEDTA-C_JcdBMO.js +0 -1
  94. package/dist/client/assets/JZWSX6AR-OwVPptYv.js +0 -1
  95. package/dist/client/assets/KQOEJWJY-DKTXya68.js +0 -2
  96. package/dist/client/assets/KS3BB3KE-QkpqVdmA.js +0 -1
  97. package/dist/client/assets/KXJRRZHI-BQRjeGWj.js +0 -2
  98. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  99. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  100. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  101. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  102. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  103. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  104. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  105. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  106. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  107. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  108. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  109. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  110. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  111. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  112. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  113. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  114. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  115. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  116. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  117. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  118. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  119. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  120. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  121. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  122. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  123. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  124. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  125. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  126. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  127. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  128. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  129. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  130. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  131. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  132. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  133. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  134. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  135. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  136. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  137. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  138. package/dist/client/assets/MFVI5XKI-DrRaRE9X.js +0 -1
  139. package/dist/client/assets/MWY2J62P-AbEgJnzK.js +0 -12
  140. package/dist/client/assets/MXGNCBL2-DG3CZR8g.js +0 -2
  141. package/dist/client/assets/N6DAJLIZ-BsZQKTyT.js +0 -3
  142. package/dist/client/assets/NU5M23IW-CvtpIovv.js +0 -2
  143. package/dist/client/assets/NV4XNBIS-nS8wAx3h.js +0 -8
  144. package/dist/client/assets/OGLU4SIR-DoTUWhkz.js +0 -2
  145. package/dist/client/assets/OUNS5N76-BwVLonT4.js +0 -8
  146. package/dist/client/assets/PU4ISKXV-BYX7eA_r.js +0 -2
  147. package/dist/client/assets/PUZGS23X-n6IKOr7S.js +0 -1
  148. package/dist/client/assets/Q3IYVLG5-hWt1rVyh.js +0 -4
  149. package/dist/client/assets/QYHQDLKZ-CjGeJM7R.js +0 -4
  150. package/dist/client/assets/RVARI57O-CXmrUIiS.js +0 -1
  151. package/dist/client/assets/RZSVICHU-BiwjruZZ.js +0 -4
  152. package/dist/client/assets/UEXXS2NB-Dkz_tz-N.js +0 -2
  153. package/dist/client/assets/UHTQJXKL-lvl-7QeT.js +0 -5
  154. package/dist/client/assets/URA2L2QI-B8pYKxOk.js +0 -1
  155. package/dist/client/assets/UXCHDUKG-BGy3ZRLM.js +0 -2
  156. package/dist/client/assets/VKNV3BUX-DZnoX4IV.js +0 -5
  157. package/dist/client/assets/VMV7XPHP-6QkisQwm.js +0 -4
  158. package/dist/client/assets/WEVWDWVA-nohBBhTA.js +0 -2
  159. package/dist/client/assets/XRRJ35LF-BE7gSN1k.js +0 -2
  160. package/dist/client/assets/YFKBLVVN-CVTPpei6.js +0 -6
  161. package/dist/client/assets/YHRE5QGZ-Bs7dUKjj.js +0 -4
  162. package/dist/client/assets/ZF3OVT4B-6LaC6iLs.js +0 -3
  163. package/dist/client/assets/index-392ivruk.js +0 -2
  164. package/dist/client/assets/index-CGQuEJ_w.css +0 -1
  165. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  166. package/dist/client/assets/jetbrains-mono-cyrillic-600-normal-8K4wrrwR.woff +0 -0
  167. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  168. package/dist/client/assets/jetbrains-mono-greek-600-normal-mc2nkWzM.woff +0 -0
  169. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  170. package/dist/client/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  171. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  172. package/dist/client/assets/jetbrains-mono-latin-ext-600-normal-DObL3zCW.woff +0 -0
  173. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  174. package/dist/client/assets/jetbrains-mono-vietnamese-600-normal-OWROknRo.woff +0 -0
  175. package/dist/client/assets/katex-C5jXJg4s.js +0 -257
  176. package/dist/client/assets/katex.min-BYZTj3zW.css +0 -1
  177. package/dist/client/assets/katex.min-DXaO1tdm.js +0 -1
  178. package/dist/client/assets/lazy-CKqR1774.js +0 -43
package/bin/mdview.mjs CHANGED
@@ -1,159 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_IGNORED_DIRS,
4
+ deriveRootId,
5
+ globalConfigPath,
6
+ isValidRootId,
7
+ loadGlobalConfig
8
+ } from "./chunk-WWM6JNAS.mjs";
9
+ import {
10
+ PALETTES
11
+ } from "./chunk-XFRQFZZ4.mjs";
2
12
 
3
13
  // src/cli.ts
4
14
  import dns from "dns/promises";
5
- import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, statSync as statSync4 } from "fs";
6
- import path14 from "path";
7
- import { fileURLToPath } from "url";
8
- import chokidar3 from "chokidar";
9
- import openBrowser from "open";
15
+ import { existsSync as existsSync2, realpathSync as realpathSync2, statSync as statSync5 } from "fs";
16
+ import path6 from "path";
17
+ import { fileURLToPath as fileURLToPath2 } from "url";
10
18
 
11
- // src/server/config.ts
12
- import { readFile } from "fs/promises";
13
- import os from "os";
19
+ // src/server/config-write.ts
20
+ import { chmod, mkdir, readFile, writeFile } from "fs/promises";
14
21
  import path from "path";
15
-
16
- // src/shared/types.ts
17
- var DEFAULT_PALETTE = "flexoki";
18
- var PALETTES = [
19
- "flexoki",
20
- "paper",
21
- "solarized",
22
- "everforest",
23
- "rose-pine",
24
- "kanagawa",
25
- "catppuccin",
26
- "high-contrast"
27
- ];
28
-
29
- // src/server/config.ts
30
- var CONFIG_FILENAME = ".mdview.json";
31
- var GLOBAL_CONFIG_FILENAME = "config.json";
32
- var FONT_FAMILIES = ["serif", "sans", "mono"];
33
22
  var IGNORE_ENTRY_PATTERN = /^[A-Za-z0-9_.\-+]{1,64}$/;
34
- function isObject(v) {
35
- return typeof v === "object" && v !== null && !Array.isArray(v);
36
- }
37
- function globalConfigPath() {
38
- const xdg = process.env.XDG_CONFIG_HOME;
39
- const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
40
- return path.join(base, "mdview", GLOBAL_CONFIG_FILENAME);
41
- }
42
- function validateConfig(raw, source = CONFIG_FILENAME) {
43
- if (!isObject(raw)) return null;
44
- const out = {};
45
- if ("palette" in raw) {
46
- const p = raw.palette;
47
- if (typeof p === "string" && PALETTES.includes(p)) {
48
- out.palette = p;
49
- } else {
50
- console.warn(`[mdview] ${source}: ignoring invalid palette: ${String(p)}`);
51
- }
52
- }
53
- if ("fontFamily" in raw) {
54
- const f = raw.fontFamily;
55
- if (typeof f === "string" && FONT_FAMILIES.includes(f)) {
56
- out.fontFamily = f;
57
- } else {
58
- console.warn(`[mdview] ${source}: ignoring invalid fontFamily: ${String(f)}`);
59
- }
60
- }
61
- if ("lineWidth" in raw) {
62
- const w = raw.lineWidth;
63
- if (typeof w === "string" && w.length > 0 && w.length <= 32 && /^[\w%.-]+$/.test(w)) {
64
- out.lineWidth = w;
65
- } else {
66
- console.warn(`[mdview] ${source}: ignoring invalid lineWidth: ${String(w)}`);
67
- }
68
- }
69
- if ("defaultCollapsed" in raw && isObject(raw.defaultCollapsed)) {
70
- const dc = raw.defaultCollapsed;
71
- const collapsed = {};
72
- if (typeof dc.tree === "boolean") collapsed.tree = dc.tree;
73
- if (typeof dc.outline === "boolean") collapsed.outline = dc.outline;
74
- if (collapsed.tree !== void 0 || collapsed.outline !== void 0) {
75
- out.defaultCollapsed = collapsed;
76
- }
77
- }
78
- if ("ignore" in raw) {
79
- const ig = raw.ignore;
80
- if (Array.isArray(ig)) {
81
- const cleaned = [];
82
- for (const entry of ig) {
83
- if (typeof entry === "string" && entry !== "." && entry !== ".." && IGNORE_ENTRY_PATTERN.test(entry)) {
84
- cleaned.push(entry);
85
- } else {
86
- console.warn(`[mdview] ${source}: ignoring invalid ignore entry: ${String(entry)}`);
87
- }
88
- }
89
- if (cleaned.length > 0) out.ignore = cleaned;
90
- } else {
91
- console.warn(`[mdview] ${source}: ignoring invalid ignore (expected array of strings)`);
92
- }
93
- }
94
- return out;
95
- }
96
- async function loadConfigFromPath(filePath, source) {
97
- let raw;
98
- try {
99
- raw = await readFile(filePath, "utf8");
100
- } catch (err) {
101
- if (err.code === "ENOENT") return null;
102
- console.warn(`[mdview] failed to read ${source}: ${err.message}`);
103
- return null;
104
- }
105
- let parsed;
106
- try {
107
- parsed = JSON.parse(raw);
108
- } catch (err) {
109
- console.warn(`[mdview] ${source} is not valid JSON: ${err.message}`);
110
- return null;
111
- }
112
- return validateConfig(parsed, source);
113
- }
114
- async function loadProjectConfig(rootAbsPath) {
115
- return loadConfigFromPath(path.join(rootAbsPath, CONFIG_FILENAME), CONFIG_FILENAME);
116
- }
117
- async function loadGlobalConfig() {
118
- return loadConfigFromPath(globalConfigPath(), `~/.config/mdview/${GLOBAL_CONFIG_FILENAME}`);
119
- }
120
- function mergeConfigs(global, project) {
121
- if (!global && !project) return null;
122
- const out = {};
123
- if (global?.palette !== void 0) out.palette = global.palette;
124
- if (project?.palette !== void 0) out.palette = project.palette;
125
- if (global?.fontFamily !== void 0) out.fontFamily = global.fontFamily;
126
- if (project?.fontFamily !== void 0) out.fontFamily = project.fontFamily;
127
- if (global?.lineWidth !== void 0) out.lineWidth = global.lineWidth;
128
- if (project?.lineWidth !== void 0) out.lineWidth = project.lineWidth;
129
- if (global?.defaultCollapsed || project?.defaultCollapsed) {
130
- out.defaultCollapsed = { ...global?.defaultCollapsed, ...project?.defaultCollapsed };
131
- }
132
- const ig = [...global?.ignore ?? [], ...project?.ignore ?? []];
133
- if (ig.length > 0) {
134
- out.ignore = Array.from(new Set(ig));
135
- }
136
- return out;
137
- }
138
- async function loadEffectiveConfig(rootAbsPath) {
139
- const [global, project] = await Promise.all([
140
- loadGlobalConfig(),
141
- loadProjectConfig(rootAbsPath)
142
- ]);
143
- return mergeConfigs(global, project);
144
- }
145
-
146
- // src/server/config-write.ts
147
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
148
- import path2 from "path";
149
- var IGNORE_ENTRY_PATTERN2 = /^[A-Za-z0-9_.\-+]{1,64}$/;
150
23
  function isValidIgnoreEntry(s) {
151
- return s !== "." && s !== ".." && IGNORE_ENTRY_PATTERN2.test(s);
24
+ return s !== "." && s !== ".." && IGNORE_ENTRY_PATTERN.test(s);
152
25
  }
153
26
  async function readGlobalRaw(filePath) {
154
27
  let raw;
155
28
  try {
156
- raw = await readFile2(filePath, "utf8");
29
+ raw = await readFile(filePath, "utf8");
157
30
  } catch (err) {
158
31
  if (err.code === "ENOENT") return {};
159
32
  throw err;
@@ -170,9 +43,12 @@ async function readGlobalRaw(filePath) {
170
43
  return parsed;
171
44
  }
172
45
  async function writeGlobalRaw(filePath, data) {
173
- await mkdir(path2.dirname(filePath), { recursive: true });
46
+ const dir = path.dirname(filePath);
47
+ await mkdir(dir, { recursive: true, mode: 448 });
48
+ await chmod(dir, 448);
174
49
  const json = JSON.stringify(data, null, 2) + "\n";
175
- await writeFile(filePath, json, "utf8");
50
+ await writeFile(filePath, json, { encoding: "utf8", mode: 384 });
51
+ await chmod(filePath, 384);
176
52
  }
177
53
  function readIgnoreArray(cfg) {
178
54
  const v = cfg.ignore;
@@ -213,22 +89,18 @@ async function listUserIgnoreEntries(filePath = globalConfigPath()) {
213
89
  const cfg = await readGlobalRaw(filePath);
214
90
  return readIgnoreArray(cfg);
215
91
  }
216
-
217
- // src/shared/ignore.ts
218
- var DEFAULT_IGNORED_DIRS = [
219
- "node_modules",
220
- "dist",
221
- "build",
222
- "out",
223
- "target",
224
- "coverage",
225
- "vendor",
226
- "__pycache__",
227
- "venv",
228
- "Pods",
229
- "DerivedData",
230
- "_build"
231
- ];
92
+ async function readUpdateCheck(filePath = globalConfigPath()) {
93
+ const cfg = await readGlobalRaw(filePath);
94
+ return cfg.updateCheck === true;
95
+ }
96
+ async function setUpdateCheck(enabled, filePath = globalConfigPath()) {
97
+ const cfg = await readGlobalRaw(filePath);
98
+ const before = cfg.updateCheck === true;
99
+ if (enabled) cfg.updateCheck = true;
100
+ else delete cfg.updateCheck;
101
+ await writeGlobalRaw(filePath, cfg);
102
+ return { before, after: enabled, path: filePath };
103
+ }
232
104
 
233
105
  // src/cli-config.ts
234
106
  function printConfigUsage() {
@@ -241,12 +113,18 @@ Subcommands:
241
113
  ignore list Show the built-in skips plus user-added ones
242
114
  ignore add <name\u2026> Append basename(s) to the ignore list
243
115
  ignore rm <name\u2026> Remove basename(s) from the ignore list
116
+ update-check [on|off] Show, or turn on/off, the daily check for a newer release
244
117
 
245
118
  Examples:
246
119
  mdview config path
247
120
  mdview config ignore add deps _site
248
121
  mdview config ignore list
249
122
 
123
+ The update check is off by default. When on, the background server asks
124
+ registry.npmjs.org once a day for the latest version and sends nothing that
125
+ identifies you. It only ever prints a one-line notice; 'mdview update' shows
126
+ the install command.
127
+
250
128
  After editing the ignore list, restart any running mdview instance \u2014 the file
251
129
  watcher caches its skip set at startup.
252
130
  `.trim()
@@ -267,6 +145,8 @@ async function runConfigSubcommand(argv) {
267
145
  return 0;
268
146
  case "ignore":
269
147
  return runIgnoreSubcommand(rest);
148
+ case "update-check":
149
+ return runUpdateCheckSubcommand(rest);
270
150
  default:
271
151
  console.error(`Unknown subcommand: config ${sub}`);
272
152
  printConfigUsage();
@@ -340,31 +220,293 @@ async function runIgnoreSubcommand(argv) {
340
220
  return 2;
341
221
  }
342
222
  }
223
+ async function runUpdateCheckSubcommand(argv) {
224
+ const [action, ...extra] = argv;
225
+ if (extra.length > 0 || action !== void 0 && action !== "on" && action !== "off") {
226
+ console.error("Usage: mdview config update-check [on|off]");
227
+ return 2;
228
+ }
229
+ try {
230
+ if (action === void 0) {
231
+ console.log(`update-check: ${await readUpdateCheck() ? "on" : "off"}`);
232
+ return 0;
233
+ }
234
+ const result = await setUpdateCheck(action === "on");
235
+ if (result.before === result.after) {
236
+ console.log(`update-check is already ${action}.`);
237
+ } else if (result.after) {
238
+ console.log(`update-check on (${result.path}).`);
239
+ console.log(
240
+ "The background server checks registry.npmjs.org about once a day; a running one notices this within an hour."
241
+ );
242
+ } else {
243
+ console.log(`update-check off (${result.path}).`);
244
+ }
245
+ return 0;
246
+ } catch (err) {
247
+ console.error(err.message);
248
+ return 2;
249
+ }
250
+ }
251
+
252
+ // src/cli-update.ts
253
+ import { statSync } from "fs";
254
+ import path3 from "path";
255
+
256
+ // src/update/semver.ts
257
+ var PRERELEASE_ID = "(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)";
258
+ var SEMVER_PATTERN = new RegExp(
259
+ `^(0|[1-9]\\d{0,8})\\.(0|[1-9]\\d{0,8})\\.(0|[1-9]\\d{0,8})(?:-(${PRERELEASE_ID}(?:\\.${PRERELEASE_ID})*))?$`
260
+ );
261
+ var MAX_VERSION_LENGTH = 48;
262
+ function parseVersion(v) {
263
+ if (typeof v !== "string" || v.length > MAX_VERSION_LENGTH) return null;
264
+ const m = SEMVER_PATTERN.exec(v);
265
+ if (!m) return null;
266
+ return {
267
+ core: [Number(m[1]), Number(m[2]), Number(m[3])],
268
+ pre: m[4] === void 0 ? [] : m[4].split(".")
269
+ };
270
+ }
271
+ function isValidVersion(v) {
272
+ return parseVersion(v) !== null;
273
+ }
274
+ var NUMERIC = /^\d+$/;
275
+ function comparePreId(a, b) {
276
+ const an = NUMERIC.test(a);
277
+ const bn = NUMERIC.test(b);
278
+ if (an && bn) return a.length - b.length || (a < b ? -1 : a > b ? 1 : 0);
279
+ if (an) return -1;
280
+ if (bn) return 1;
281
+ return a < b ? -1 : a > b ? 1 : 0;
282
+ }
283
+ function compareVersions(a, b) {
284
+ for (let i = 0; i < 3; i++) {
285
+ const d = a.core[i] - b.core[i];
286
+ if (d !== 0) return d;
287
+ }
288
+ if (a.pre.length === 0 && b.pre.length === 0) return 0;
289
+ if (a.pre.length === 0) return 1;
290
+ if (b.pre.length === 0) return -1;
291
+ const n = Math.min(a.pre.length, b.pre.length);
292
+ for (let i = 0; i < n; i++) {
293
+ const d = comparePreId(a.pre[i], b.pre[i]);
294
+ if (d !== 0) return d;
295
+ }
296
+ return a.pre.length - b.pre.length;
297
+ }
298
+ function isNewerVersion(candidate, current) {
299
+ const c = parseVersion(candidate);
300
+ const k = parseVersion(current);
301
+ return c !== null && k !== null && compareVersions(c, k) > 0;
302
+ }
303
+
304
+ // src/update/latest.ts
305
+ var DIST_TAGS_URL = "https://registry.npmjs.org/-/package/@hardikg%2fmdview/dist-tags";
306
+ var TIMEOUT_MS = 5e3;
307
+ var MAX_BODY_BYTES = 64 * 1024;
308
+ var UpdateCheckError = class extends Error {
309
+ };
310
+ async function readCapped(res, maxBytes, abort) {
311
+ const declared = Number(res.headers.get("content-length"));
312
+ if (Number.isFinite(declared) && declared > maxBytes) {
313
+ abort.abort();
314
+ throw new UpdateCheckError("the registry answer was too large");
315
+ }
316
+ if (!res.body) throw new UpdateCheckError("the registry answer was empty");
317
+ const reader = res.body.getReader();
318
+ const chunks = [];
319
+ let total = 0;
320
+ for (; ; ) {
321
+ const { done, value } = await reader.read();
322
+ if (done) break;
323
+ total += value.byteLength;
324
+ if (total > maxBytes) {
325
+ abort.abort();
326
+ await reader.cancel().catch(() => void 0);
327
+ throw new UpdateCheckError("the registry answer was too large");
328
+ }
329
+ chunks.push(value);
330
+ }
331
+ return new TextDecoder().decode(Buffer.concat(chunks));
332
+ }
333
+ async function fetchLatestVersion(options = {}) {
334
+ const fetchFn = options.fetchFn ?? fetch;
335
+ const abort = new AbortController();
336
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
337
+ const signal = AbortSignal.any([abort.signal, timeout]);
338
+ let body;
339
+ try {
340
+ const res = await fetchFn(DIST_TAGS_URL, { redirect: "error", signal });
341
+ if (!res.ok) throw new UpdateCheckError(`the registry answered HTTP ${res.status}`);
342
+ body = await readCapped(res, options.maxBytes ?? MAX_BODY_BYTES, abort);
343
+ } catch (err) {
344
+ if (err instanceof UpdateCheckError) throw err;
345
+ if (timeout.aborted) throw new UpdateCheckError("the registry did not answer in time", { cause: err });
346
+ throw new UpdateCheckError("could not reach the registry", { cause: err });
347
+ }
348
+ let parsed;
349
+ try {
350
+ parsed = JSON.parse(body);
351
+ } catch {
352
+ throw new UpdateCheckError("the registry answer was not valid JSON");
353
+ }
354
+ const latest = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed.latest : void 0;
355
+ if (!isValidVersion(latest)) {
356
+ throw new UpdateCheckError("the registry answer did not hold a valid version");
357
+ }
358
+ return latest;
359
+ }
360
+
361
+ // src/version.ts
362
+ import { readFileSync } from "fs";
363
+ import path2 from "path";
364
+ import { fileURLToPath } from "url";
365
+ function readVersion() {
366
+ const here = path2.dirname(fileURLToPath(import.meta.url));
367
+ const candidates = [
368
+ path2.resolve(here, "../package.json"),
369
+ path2.resolve(here, "../../package.json")
370
+ ];
371
+ for (const p of candidates) {
372
+ try {
373
+ const raw = readFileSync(p, "utf8");
374
+ const v = JSON.parse(raw).version;
375
+ if (typeof v === "string") return v;
376
+ } catch {
377
+ }
378
+ }
379
+ return "unknown";
380
+ }
381
+
382
+ // src/cli-update.ts
383
+ var POSIX_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
384
+ var WINDOWS_UNSAFE = /["%^&|<>!]/;
385
+ function hasControlCharacter(s) {
386
+ for (let i = 0; i < s.length; i++) {
387
+ const c = s.charCodeAt(i);
388
+ if (c < 32 || c === 127) return true;
389
+ }
390
+ return false;
391
+ }
392
+ function isFileOnDisk(file) {
393
+ try {
394
+ return statSync(file).isFile();
395
+ } catch {
396
+ return false;
397
+ }
398
+ }
399
+ function npmInstallCommand(version, env) {
400
+ const windows = env.platform === "win32";
401
+ const p = windows ? path3.win32 : path3.posix;
402
+ const npm = p.join(p.dirname(env.execPath), windows ? "npm.cmd" : "npm");
403
+ const args = `install -g @hardikg/mdview@${version}`;
404
+ if (!env.isFile(npm) || hasControlCharacter(npm)) return `npm ${args}`;
405
+ if (windows) {
406
+ if (WINDOWS_UNSAFE.test(npm)) return `npm ${args}`;
407
+ return /^[A-Za-z0-9_@+=:,.\\/-]+$/.test(npm) ? `${npm} ${args}` : `"${npm}" ${args}`;
408
+ }
409
+ const quoted = POSIX_SAFE.test(npm) ? npm : `'${npm.replace(/'/g, `'\\''`)}'`;
410
+ return `${quoted} ${args}`;
411
+ }
412
+ async function runUpdateCommand(deps = {}) {
413
+ const log = deps.log ?? ((line) => console.log(line));
414
+ const logError = deps.logError ?? ((line) => console.error(line));
415
+ const current = deps.currentVersion ?? readVersion();
416
+ const currentParsed = parseVersion(current);
417
+ if (!currentParsed) {
418
+ logError("mdview: could not read the version of this install, so it cannot compare.");
419
+ return 1;
420
+ }
421
+ let latest;
422
+ try {
423
+ latest = await fetchLatestVersion({
424
+ ...deps.fetchFn ? { fetchFn: deps.fetchFn } : {},
425
+ ...deps.timeoutMs === void 0 ? {} : { timeoutMs: deps.timeoutMs }
426
+ });
427
+ } catch (err) {
428
+ if (err instanceof UpdateCheckError) {
429
+ logError(`mdview: could not check for updates: ${err.message}.`);
430
+ if (process.env.MDVIEW_DEBUG === "1" && err.cause instanceof Error && err.cause.stack) {
431
+ logError(err.cause.stack);
432
+ }
433
+ return 1;
434
+ }
435
+ throw err;
436
+ }
437
+ const order = compareVersions(parseVersion(latest), currentParsed);
438
+ if (order > 0) {
439
+ const command = npmInstallCommand(latest, {
440
+ execPath: deps.install?.execPath ?? process.execPath,
441
+ platform: deps.install?.platform ?? process.platform,
442
+ isFile: deps.install?.isFile ?? isFileOnDisk
443
+ });
444
+ log(`mdview ${current} \u2192 ${latest} is available.`);
445
+ log("Install it with:");
446
+ log("");
447
+ log(` ${command}`);
448
+ log("");
449
+ log("The background server restarts itself on the next `mdview` run.");
450
+ } else if (order === 0) {
451
+ log(`mdview ${current} is up to date.`);
452
+ } else {
453
+ log(`mdview ${current} is newer than the latest release (${latest}).`);
454
+ }
455
+ return 0;
456
+ }
343
457
 
344
458
  // src/daemon/client.ts
345
459
  import { spawn } from "child_process";
346
- import { closeSync, openSync as openSync2, statSync as statSync2, truncateSync } from "fs";
460
+ import { closeSync as closeSync2, statSync as statSync3, truncateSync } from "fs";
347
461
 
348
462
  // src/daemon/state.ts
349
- import { mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
350
- import path3 from "path";
463
+ import {
464
+ chmodSync,
465
+ closeSync,
466
+ fchmodSync,
467
+ mkdirSync,
468
+ openSync,
469
+ readFileSync as readFileSync2,
470
+ renameSync,
471
+ rmSync,
472
+ statSync as statSync2,
473
+ writeFileSync
474
+ } from "fs";
475
+ import path4 from "path";
351
476
  function stateDir() {
352
- return path3.dirname(globalConfigPath());
477
+ return path4.dirname(globalConfigPath());
353
478
  }
354
479
  function daemonStatePath() {
355
- return path3.join(stateDir(), "daemon.json");
480
+ return path4.join(stateDir(), "daemon.json");
356
481
  }
357
482
  function workspaceFilePath() {
358
- return path3.join(stateDir(), "workspace.json");
483
+ return path4.join(stateDir(), "workspace.json");
359
484
  }
360
485
  function daemonLogPath() {
361
- return path3.join(stateDir(), "daemon.log");
486
+ return path4.join(stateDir(), "daemon.log");
362
487
  }
363
488
  function daemonLockPath() {
364
- return path3.join(stateDir(), "daemon.lock");
489
+ return path4.join(stateDir(), "daemon.lock");
490
+ }
491
+ function updateCheckPath() {
492
+ return path4.join(stateDir(), "update-check.json");
493
+ }
494
+ function ensurePrivateDir(dir) {
495
+ mkdirSync(dir, { recursive: true, mode: 448 });
496
+ chmodSync(dir, 448);
497
+ }
498
+ function openPrivateAppend(file) {
499
+ const fd = openSync(file, "a", 384);
500
+ try {
501
+ fchmodSync(fd, 384);
502
+ } catch (err) {
503
+ closeSync(fd);
504
+ throw err;
505
+ }
506
+ return fd;
365
507
  }
366
508
  function writeJsonAtomic(file, value) {
367
- mkdirSync(path3.dirname(file), { recursive: true, mode: 448 });
509
+ ensurePrivateDir(path4.dirname(file));
368
510
  const tmp = `${file}.${process.pid}.tmp`;
369
511
  writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
370
512
  `, { mode: 384 });
@@ -372,7 +514,7 @@ function writeJsonAtomic(file, value) {
372
514
  }
373
515
  function readJsonSafe(file) {
374
516
  try {
375
- return JSON.parse(readFileSync(file, "utf8"));
517
+ return JSON.parse(readFileSync2(file, "utf8"));
376
518
  } catch {
377
519
  return null;
378
520
  }
@@ -395,18 +537,18 @@ function clearDaemonState() {
395
537
  rmSync(daemonStatePath(), { force: true });
396
538
  }
397
539
  function tryTakeSpawnLock(staleAfterMs = 1e4) {
398
- mkdirSync(stateDir(), { recursive: true, mode: 448 });
540
+ ensurePrivateDir(stateDir());
399
541
  const file = daemonLockPath();
400
542
  try {
401
- openSync(file, "wx");
543
+ closeSync(openSync(file, "wx", 384));
402
544
  return true;
403
545
  } catch (err) {
404
546
  if (err.code !== "EEXIST") throw err;
405
547
  }
406
548
  try {
407
- if (Date.now() - statSync(file).mtimeMs < staleAfterMs) return false;
549
+ if (Date.now() - statSync2(file).mtimeMs < staleAfterMs) return false;
408
550
  rmSync(file, { force: true });
409
- openSync(file, "wx");
551
+ closeSync(openSync(file, "wx", 384));
410
552
  return true;
411
553
  } catch {
412
554
  return false;
@@ -435,6 +577,8 @@ async function probeHealth(port) {
435
577
  return {
436
578
  pid: body.pid,
437
579
  startedAt: body.startedAt,
580
+ // Validated because it is printed to the terminal when it differs.
581
+ ...isValidVersion(body.version) ? { version: body.version } : {},
438
582
  roots: Array.isArray(body.roots) ? body.roots.filter((r) => typeof r === "string") : []
439
583
  };
440
584
  } catch {
@@ -451,12 +595,12 @@ async function findLiveDaemon() {
451
595
  }
452
596
  function spawnDaemon(cliEntry) {
453
597
  try {
454
- if (statSync2(daemonLogPath()).size > LOG_TRUNCATE_BYTES) {
598
+ if (statSync3(daemonLogPath()).size > LOG_TRUNCATE_BYTES) {
455
599
  truncateSync(daemonLogPath(), 0);
456
600
  }
457
601
  } catch {
458
602
  }
459
- const log = openSync2(daemonLogPath(), "a");
603
+ const log = openPrivateAppend(daemonLogPath());
460
604
  try {
461
605
  const child = spawn(process.execPath, [cliEntry, "--daemon"], {
462
606
  detached: true,
@@ -465,28 +609,47 @@ function spawnDaemon(cliEntry) {
465
609
  stdio: ["ignore", log, log]
466
610
  });
467
611
  child.unref();
468
- return child;
469
612
  } finally {
470
- closeSync(log);
613
+ closeSync2(log);
471
614
  }
472
615
  }
473
- async function ensureDaemon(cliEntry) {
616
+ function runsOtherVersion(live, version) {
617
+ return isValidVersion(version) && live.health.version !== version;
618
+ }
619
+ function sameDaemon(a, b) {
620
+ return a.state.pid === b.state.pid && a.state.startedAt === b.state.startedAt;
621
+ }
622
+ async function ensureDaemon(cliEntry, options = {}) {
623
+ const version = options.version ?? readVersion();
624
+ const timeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
625
+ const spawnFn = options.spawnFn ?? spawnDaemon;
474
626
  const existing = await findLiveDaemon();
475
- if (existing) return existing.state;
627
+ if (existing && !runsOtherVersion(existing, version)) return existing.state;
476
628
  if (!tryTakeSpawnLock()) {
477
- const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
629
+ const found = await waitFor(async () => {
630
+ const live = await findLiveDaemon();
631
+ if (!live) return null;
632
+ if (!existing || !sameDaemon(live, existing)) return live;
633
+ return runsOtherVersion(live, version) ? null : live;
634
+ }, timeoutMs);
478
635
  if (found) return found.state;
636
+ const fallback = await findLiveDaemon();
637
+ if (fallback) return fallback.state;
479
638
  throw new Error("Timed out waiting for the mdview background server to start.");
480
639
  }
481
640
  try {
641
+ const current = await findLiveDaemon();
642
+ if (current && !runsOtherVersion(current, version)) return current.state;
643
+ if (current) await stopDaemon();
482
644
  clearDaemonState();
483
- spawnDaemon(cliEntry);
484
- const found = await waitFor(() => findLiveDaemon(), START_TIMEOUT_MS);
645
+ spawnFn(cliEntry);
646
+ const found = await waitFor(() => findLiveDaemon(), timeoutMs);
485
647
  if (!found) {
486
648
  throw new Error(
487
649
  `The mdview background server did not start. See ${daemonLogPath()} for why.`
488
650
  );
489
651
  }
652
+ if (current) options.onRestart?.(current.health.version ?? "unknown", version);
490
653
  return found.state;
491
654
  } finally {
492
655
  releaseSpawnLock();
@@ -532,51 +695,8 @@ async function stopDaemon() {
532
695
  }
533
696
 
534
697
  // src/daemon/workspace-file.ts
535
- import { existsSync, statSync as statSync3 } from "fs";
698
+ import { existsSync, lstatSync, realpathSync, statSync as statSync4 } from "fs";
536
699
  import path5 from "path";
537
-
538
- // src/server/workspace.ts
539
- import path4 from "path";
540
- var ROOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
541
- function isValidRootId(id) {
542
- return id !== "." && id !== ".." && ROOT_ID_PATTERN.test(id);
543
- }
544
- function deriveRootId(absPath, taken) {
545
- const slug = path4.basename(absPath).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-.]+$/, "").slice(0, 64);
546
- const stem = isValidRootId(slug) ? slug : "root";
547
- if (!taken.has(stem)) return stem;
548
- for (let n = 2; ; n++) {
549
- const candidate = `${stem.slice(0, 60)}-${n}`;
550
- if (!taken.has(candidate)) return candidate;
551
- }
552
- }
553
- function resolveRootId(spec, taken) {
554
- if (spec.id && isValidRootId(spec.id) && !taken.has(spec.id)) return spec.id;
555
- return deriveRootId(spec.absPath, taken);
556
- }
557
- function describeRoot(spec, id) {
558
- return {
559
- id,
560
- name: spec.name ?? path4.basename(spec.absPath),
561
- kind: spec.kind,
562
- filePath: spec.kind === "file" ? spec.filePath ?? "" : ""
563
- };
564
- }
565
- function parseWorkspacePath(roots, wsPath) {
566
- const primary = roots[0];
567
- if (!primary) return null;
568
- const trimmed = wsPath.replace(/^\/+/, "");
569
- const slash = trimmed.indexOf("/");
570
- const head = slash === -1 ? trimmed : trimmed.slice(0, slash);
571
- const match = roots.find((r) => r.root.id === head);
572
- if (!match) return { state: primary, rel: trimmed };
573
- return { state: match, rel: slash === -1 ? "" : trimmed.slice(slash + 1) };
574
- }
575
- function toWorkspacePath(rootId, rel) {
576
- return rel ? `${rootId}/${rel}` : rootId;
577
- }
578
-
579
- // src/daemon/workspace-file.ts
580
700
  var MAX_ROOTS = 8;
581
701
  function validateRecord(raw) {
582
702
  if (!raw || typeof raw !== "object") return null;
@@ -623,13 +743,19 @@ function toRootSpecs(record) {
623
743
  function resolveTarget(target) {
624
744
  const abs = path5.resolve(target);
625
745
  if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
626
- const st = statSync3(abs);
746
+ const st = statSync4(abs);
627
747
  if (st.isDirectory()) {
628
748
  return { absPath: abs, kind: "dir", filePath: "", name: path5.basename(abs) };
629
749
  }
630
750
  if (st.isFile()) {
631
- const dir = path5.dirname(abs);
632
- return { absPath: dir, kind: "file", filePath: path5.basename(abs), name: path5.basename(abs) };
751
+ const onDisk = realpathSync.native(abs);
752
+ const real = lstatSync(abs).isSymbolicLink() ? onDisk : path5.join(path5.dirname(abs), path5.basename(onDisk));
753
+ return {
754
+ absPath: path5.dirname(real),
755
+ kind: "file",
756
+ filePath: path5.basename(real),
757
+ name: path5.basename(abs)
758
+ };
633
759
  }
634
760
  throw new Error(`Unsupported path type: ${abs}`);
635
761
  }
@@ -718,6 +844,12 @@ Workspace is full (${MAX_ROOTS}). Remove one with \`mdview rm <name>\`.`);
718
844
  if (live) {
719
845
  console.log(`server: http://mdview.localhost:${live.state.port}/ (pid ${live.state.pid})`);
720
846
  console.log(`logs: ${daemonLogPath()}`);
847
+ const own = readVersion();
848
+ if (isValidVersion(own) && live.health.version !== own) {
849
+ console.log(
850
+ `note: it runs ${live.health.version ?? "a version that reports none"}, this is ${own}; the next \`mdview <path>\` restarts it`
851
+ );
852
+ }
721
853
  } else {
722
854
  console.log("server: not running \u2014 the next `mdview <path>` will start it");
723
855
  }
@@ -758,1086 +890,100 @@ async function runStopCommand() {
758
890
  }
759
891
  }
760
892
 
761
- // src/server/index.ts
762
- import path13 from "path";
763
- import chokidar2 from "chokidar";
764
- import Fastify from "fastify";
765
-
766
- // src/server/fs/ignore.ts
767
- import path6 from "path";
768
- var DEFAULT_IGNORED_DIRS2 = new Set(DEFAULT_IGNORED_DIRS);
769
- function buildIgnoreSet(extra = []) {
770
- return /* @__PURE__ */ new Set([...DEFAULT_IGNORED_DIRS2, ...extra]);
771
- }
772
- function isDirIgnored(name, set) {
773
- return name.startsWith(".") || set.has(name);
774
- }
775
- function isPathIgnored(abs, root, set) {
776
- const rel = path6.relative(root, abs);
777
- if (rel === "" || rel.startsWith("..")) return false;
778
- for (const seg of rel.split(path6.sep)) {
779
- if (!seg) continue;
780
- if (seg.startsWith(".")) return true;
781
- if (set.has(seg)) return true;
782
- }
783
- return false;
784
- }
785
-
786
- // src/server/hosts.ts
787
- var LOOPBACK_NAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
788
- var MAX_HOST_LEN = 253;
789
- function hostOnly(value) {
790
- const h = value.trim().toLowerCase();
791
- if (h.startsWith("[")) {
792
- const end = h.indexOf("]");
793
- return end === -1 ? h : h.slice(0, end + 1);
794
- }
795
- if (h.indexOf(":") !== h.lastIndexOf(":")) return h;
796
- const colon = h.indexOf(":");
797
- return colon === -1 ? h : h.slice(0, colon);
798
- }
799
- function isAllowedHost(value) {
800
- if (!value || value.length > MAX_HOST_LEN) return false;
801
- if (!/^[\x21-\x7e]+$/.test(value)) return false;
802
- const host = hostOnly(value);
803
- return LOOPBACK_NAMES.has(host) || /^[a-z0-9][a-z0-9.-]*\.localhost$/.test(host);
804
- }
805
-
806
- // src/server/routes/api-asset.ts
807
- import { createReadStream } from "fs";
808
- import { stat } from "fs/promises";
809
- import path8 from "path";
810
-
811
- // src/server/fs/resolve.ts
812
- import path7 from "path";
813
- function resolveSafePath(root, relPath) {
814
- if (path7.isAbsolute(relPath)) {
815
- throw new Error(`Refusing absolute path: ${relPath}`);
816
- }
817
- const absRoot = path7.resolve(root);
818
- const candidate = path7.resolve(absRoot, relPath);
819
- const rel = path7.relative(absRoot, candidate);
820
- if (rel.startsWith("..") || path7.isAbsolute(rel)) {
821
- throw new Error(`Path resolves outside root: ${relPath}`);
822
- }
823
- return candidate;
824
- }
825
-
826
- // src/server/routes/api-asset.ts
827
- var MIME = {
828
- ".png": "image/png",
829
- ".jpg": "image/jpeg",
830
- ".jpeg": "image/jpeg",
831
- ".gif": "image/gif",
832
- ".webp": "image/webp",
833
- ".svg": "image/svg+xml",
834
- ".avif": "image/avif",
835
- ".bmp": "image/bmp",
836
- ".ico": "image/x-icon",
837
- ".mp4": "video/mp4",
838
- ".webm": "video/webm",
839
- ".mov": "video/quicktime",
840
- ".mp3": "audio/mpeg",
841
- ".wav": "audio/wav",
842
- ".pdf": "application/pdf",
843
- ".txt": "text/plain; charset=utf-8"
844
- };
845
- function registerApiAsset(app, roots) {
846
- app.get("/__asset/*", async (req, reply) => {
847
- const wsPath = (req.params["*"] ?? "").replace(/^\/+/, "");
848
- const resolved = parseWorkspacePath(roots, wsPath);
849
- if (!resolved) {
850
- return reply.code(404).send({ error: "Asset not found" });
851
- }
852
- let abs;
853
- try {
854
- abs = resolveSafePath(resolved.state.absPath, resolved.rel);
855
- } catch (err) {
856
- return reply.code(400).send({ error: err.message });
857
- }
858
- let st;
859
- try {
860
- st = await stat(abs);
861
- } catch {
862
- return reply.code(404).send({ error: "Asset not found" });
863
- }
864
- if (!st.isFile()) {
865
- return reply.code(404).send({ error: "Not a file" });
866
- }
867
- const ext = path8.extname(abs).toLowerCase();
868
- const mime = MIME[ext];
869
- if (!mime) {
870
- return reply.code(404).send({ error: "Asset type not allowed" });
871
- }
872
- reply.header("content-type", mime);
873
- reply.header("content-length", st.size);
874
- reply.header("cache-control", "no-cache");
875
- return reply.send(createReadStream(abs));
876
- });
877
- }
878
-
879
- // src/server/routes/api-file.ts
880
- import { readFile as readFile3, stat as stat2 } from "fs/promises";
881
-
882
- // src/render/frontmatter.ts
883
- import { load } from "js-yaml";
884
- var MAX_NODES = 1e4;
885
- function unparsed(raw) {
886
- return { data: null, body: raw, bodyStartLine: 0 };
887
- }
888
- function parseFrontmatter(raw) {
889
- const block = locateBlock(raw);
890
- if (!block) return unparsed(raw);
891
- let loaded;
892
- try {
893
- loaded = load(block.yaml);
894
- } catch {
895
- return unparsed(raw);
896
- }
897
- if (!withinBudget(loaded)) return unparsed(raw);
898
- return {
899
- // An empty or comment-only block loads as `undefined`; it is still a
900
- // front matter block with no keys.
901
- data: loaded === void 0 ? {} : isPlainObject(loaded) ? loaded : null,
902
- // CRLF-safe strip so the body and the bodyStartLine offset stay aligned for
903
- // both LF- and CRLF-authored sources.
904
- body: block.body.replace(/^(?:\r?\n)+/, ""),
905
- bodyStartLine: block.bodyStartLine
906
- };
907
- }
908
- function locateBlock(raw) {
909
- const parts = raw.split(/(\r?\n)/);
910
- const lineAt = (i) => parts[i * 2];
911
- const lineCount = Math.ceil(parts.length / 2);
912
- if (lineAt(0) !== "---") return null;
913
- let closingIdx = -1;
914
- for (let i = 1; i < lineCount; i++) {
915
- if (lineAt(i) === "---") {
916
- closingIdx = i;
917
- break;
918
- }
919
- }
920
- if (closingIdx < 0) return null;
921
- let bodyStartLine = closingIdx + 1;
922
- while (bodyStartLine < lineCount && lineAt(bodyStartLine) === "") {
923
- bodyStartLine++;
924
- }
925
- const yaml = [];
926
- for (let i = 1; i < closingIdx; i++) yaml.push(lineAt(i) ?? "");
927
- return {
928
- yaml: yaml.join("\n"),
929
- body: parts.slice((closingIdx + 1) * 2).join(""),
930
- bodyStartLine
931
- };
932
- }
933
- function withinBudget(root) {
934
- const stack = [root];
935
- let visited = 0;
936
- while (stack.length > 0) {
937
- const node = stack.pop();
938
- if (++visited > MAX_NODES) return false;
939
- if (Array.isArray(node)) {
940
- for (const child of node) stack.push(child);
941
- } else if (isPlainObject(node)) {
942
- for (const child of Object.values(node)) stack.push(child);
943
- }
944
- }
945
- return true;
946
- }
947
- function isPlainObject(value) {
948
- if (typeof value !== "object" || value === null) return false;
949
- const proto = Object.getPrototypeOf(value);
950
- return proto === Object.prototype || proto === null;
951
- }
952
-
953
- // src/render/links.ts
954
- import path9 from "path/posix";
955
- var MD_EXT_RE = /\.(md|markdown|mdx)$/i;
956
- var HREF_RE = /<a\s+([^>]*?)href="([^"#?]*)(\?[^"#]*)?(#[^"]*)?"([^>]*)>/gi;
957
- var IMG_SRC_RE = /<img\s+([^>]*?)src="([^"]+)"([^>]*)>/gi;
958
- function tagInternalLinks(html, currentRelPath) {
959
- const currentDir = path9.dirname(currentRelPath);
960
- return html.replace(HREF_RE, (full, pre, target, query, hash, post) => {
961
- if (!target || /^https?:\/\//i.test(target) || target.startsWith("mailto:")) {
962
- return full;
963
- }
964
- if (!MD_EXT_RE.test(target)) return full;
965
- const resolved = path9.normalize(path9.join(currentDir, target));
966
- let decoded;
967
- try {
968
- decoded = decodeURIComponent(resolved);
969
- } catch {
970
- decoded = resolved;
971
- }
972
- const href = `?file=${encodeURIComponent(decoded)}${hash ?? ""}`;
973
- const internal = `${decoded}${query ?? ""}${hash ?? ""}`;
974
- return `<a ${pre}href="${href}" data-internal-link="${internal}"${post}>`;
975
- });
976
- }
977
- function rewriteImageSrc(html, currentRelPath) {
978
- const currentDir = path9.dirname(currentRelPath);
979
- return html.replace(IMG_SRC_RE, (full, pre, src, post) => {
980
- if (!src) return full;
981
- if (/^(https?:\/\/|data:|\/__asset\/)/i.test(src)) return full;
982
- const trimmed = src.replace(/^\/+/, "");
983
- const resolved = path9.normalize(path9.join(currentDir, trimmed));
984
- if (resolved.startsWith("..")) return full;
985
- return `<img ${pre}src="/__asset/${resolved}"${post}>`;
986
- });
893
+ // src/update/cache.ts
894
+ var DAY_MS = 24 * 60 * 60 * 1e3;
895
+ function isTimestamp(v) {
896
+ return typeof v === "number" && Number.isInteger(v) && v > 0;
987
897
  }
988
-
989
- // src/render/markdown.ts
990
- import MarkdownIt from "markdown-it";
991
- import anchor from "markdown-it-anchor";
992
- import taskLists from "markdown-it-task-lists";
993
-
994
- // src/render/math.ts
995
- var BLOCK_RE = /^\$\$([\s\S]+?)\$\$$/;
996
- var INLINE_RE = /\$(?!\s)([^$\n]+?)(?<!\s)\$/g;
997
- function blockMathHtml(latex) {
998
- const encoded = encodeURIComponent(latex.trim());
999
- return `<div class="math-block" data-source="${encoded}"></div>
1000
- `;
1001
- }
1002
- function inlineMathHtml(latex) {
1003
- const encoded = encodeURIComponent(latex);
1004
- return `<span class="math-inline" data-source="${encoded}"></span>`;
1005
- }
1006
- function convertBlockMath(tokens) {
1007
- for (let i = 0; i < tokens.length - 2; i++) {
1008
- if (tokens[i].type === "paragraph_open" && tokens[i + 1].type === "inline" && tokens[i + 2].type === "paragraph_close") {
1009
- const inline = tokens[i + 1];
1010
- const trimmed = inline.content.trim();
1011
- const m = trimmed.match(BLOCK_RE);
1012
- if (m) {
1013
- const replacement = new tokens[i].constructor(
1014
- "html_block",
1015
- "",
1016
- 0
1017
- );
1018
- replacement.content = blockMathHtml(m[1]);
1019
- tokens.splice(i, 3, replacement);
1020
- }
1021
- }
898
+ function readUpdateCache(file = updateCheckPath()) {
899
+ const raw = readJsonSafe(file);
900
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
901
+ const r = raw;
902
+ if (!isTimestamp(r.checkedAt) || !isValidVersion(r.latest)) return null;
903
+ const cache = { checkedAt: r.checkedAt, latest: r.latest };
904
+ if (isValidVersion(r.notifiedFor) && isTimestamp(r.notifiedAt)) {
905
+ cache.notifiedFor = r.notifiedFor;
906
+ cache.notifiedAt = r.notifiedAt;
1022
907
  }
908
+ return cache;
1023
909
  }
1024
- function convertInlineMath(tokens) {
1025
- const TokenCtor = tokens[0]?.constructor;
1026
- if (!TokenCtor) return;
1027
- for (const top of tokens) {
1028
- if (top.type !== "inline" || !top.children) continue;
1029
- const out = [];
1030
- for (const child of top.children) {
1031
- if (child.type !== "text") {
1032
- out.push(child);
1033
- continue;
1034
- }
1035
- const text = child.content;
1036
- INLINE_RE.lastIndex = 0;
1037
- let last = 0;
1038
- let m;
1039
- let found = false;
1040
- while ((m = INLINE_RE.exec(text)) !== null) {
1041
- found = true;
1042
- if (m.index > last) {
1043
- const t = new TokenCtor("text", "", 0);
1044
- t.content = text.slice(last, m.index);
1045
- out.push(t);
1046
- }
1047
- const mathTok = new TokenCtor("html_inline", "", 0);
1048
- mathTok.content = inlineMathHtml(m[1]);
1049
- out.push(mathTok);
1050
- last = m.index + m[0].length;
1051
- }
1052
- if (!found) {
1053
- out.push(child);
1054
- } else if (last < text.length) {
1055
- const t = new TokenCtor("text", "", 0);
1056
- t.content = text.slice(last);
1057
- out.push(t);
1058
- }
1059
- }
1060
- top.children = out;
910
+ function recordCheck(latest, now, file = updateCheckPath()) {
911
+ const previous = readUpdateCache(file);
912
+ const next = { checkedAt: now, latest };
913
+ if (previous?.notifiedFor !== void 0 && previous.notifiedAt !== void 0) {
914
+ next.notifiedFor = previous.notifiedFor;
915
+ next.notifiedAt = previous.notifiedAt;
1061
916
  }
917
+ writeJsonAtomic(file, next);
1062
918
  }
1063
- function mathPlugin(md2) {
1064
- md2.core.ruler.after("inline", "mdview-math", (state) => {
1065
- convertBlockMath(state.tokens);
1066
- convertInlineMath(state.tokens);
1067
- });
919
+ function recordNotified(version, now, file = updateCheckPath()) {
920
+ const current = readUpdateCache(file);
921
+ if (!current) return;
922
+ writeJsonAtomic(file, { ...current, notifiedFor: version, notifiedAt: now });
1068
923
  }
1069
-
1070
- // src/render/shiki.ts
1071
- import { createHighlighter } from "shiki";
1072
- var COMMON_LANGUAGES = [
1073
- "ts",
1074
- "tsx",
1075
- "js",
1076
- "jsx",
1077
- "python",
1078
- "go",
1079
- "rust",
1080
- "java",
1081
- "kotlin",
1082
- "css",
1083
- "scss",
1084
- "html",
1085
- "json",
1086
- "yaml",
1087
- "toml",
1088
- "bash",
1089
- "shell",
1090
- "sql",
1091
- "md",
1092
- "diff",
1093
- "dockerfile"
1094
- ];
1095
- var PALETTE_THEMES = {
1096
- // Flexoki has no upstream TextMate theme. `min-light` and `vesper` are the
1097
- // closest bundled stand-ins: both are deliberately low-chroma, which is the
1098
- // one property of Flexoki that a louder theme would contradict.
1099
- flexoki: { light: "min-light", dark: "vesper" },
1100
- paper: { light: "min-light", dark: "vitesse-dark" },
1101
- solarized: { light: "solarized-light", dark: "solarized-dark" },
1102
- everforest: { light: "everforest-light", dark: "everforest-dark" },
1103
- "rose-pine": { light: "rose-pine-dawn", dark: "rose-pine" },
1104
- kanagawa: { light: "kanagawa-lotus", dark: "kanagawa-wave" },
1105
- catppuccin: { light: "catppuccin-latte", dark: "catppuccin-mocha" },
1106
- "high-contrast": { light: "github-light-high-contrast", dark: "github-dark-high-contrast" }
1107
- };
1108
- var UNDERLYING_THEMES = Array.from(
1109
- new Set(Object.values(PALETTE_THEMES).flatMap((t) => [t.light, t.dark]))
1110
- );
1111
- var highlighterPromise = null;
1112
- function getHighlighter() {
1113
- if (!highlighterPromise) {
1114
- highlighterPromise = createHighlighter({
1115
- themes: UNDERLYING_THEMES,
1116
- langs: COMMON_LANGUAGES
1117
- });
1118
- }
1119
- return highlighterPromise;
924
+ function isCheckDue(cache, now) {
925
+ if (!cache) return true;
926
+ return cache.checkedAt > now || now - cache.checkedAt >= DAY_MS;
1120
927
  }
1121
- function escapeHtml(s) {
1122
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1123
- }
1124
- async function highlightCode(code, lang, palette = DEFAULT_PALETTE) {
1125
- const highlighter = await getHighlighter();
1126
- const loaded = highlighter.getLoadedLanguages();
1127
- let effectiveLang = lang;
1128
- if (lang && !loaded.includes(lang) && !["text", "plain", ""].includes(lang)) {
1129
- try {
1130
- await highlighter.loadLanguage(lang);
1131
- } catch {
1132
- effectiveLang = "text";
1133
- }
1134
- }
1135
- try {
1136
- return highlighter.codeToHtml(code, {
1137
- lang: effectiveLang || "text",
1138
- themes: PALETTE_THEMES[palette],
1139
- defaultColor: false
1140
- });
1141
- } catch {
1142
- return `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`;
1143
- }
928
+ function wasRecentlyNotified(cache, now) {
929
+ if (cache.notifiedFor !== cache.latest || cache.notifiedAt === void 0) return false;
930
+ return cache.notifiedAt <= now && now - cache.notifiedAt < DAY_MS;
1144
931
  }
1145
932
 
1146
- // src/render/markdown.ts
1147
- var md = new MarkdownIt({
1148
- html: true,
1149
- linkify: true,
1150
- typographer: false,
1151
- breaks: false,
1152
- highlight: () => ""
1153
- });
1154
- md.use(anchor, {
1155
- permalink: false,
1156
- slugify: (s) => s.toLowerCase().trim().replace(/[^\wÀ-ɏ\s-]/g, "").replace(/\s+/g, "-")
1157
- });
1158
- md.use(taskLists, { enabled: false, label: false });
1159
- md.use(mathPlugin);
1160
- async function renderMarkdown(source, bodyStartLine = 0, palette = DEFAULT_PALETTE) {
1161
- const tokens = md.parse(source, {});
1162
- for (const token of tokens) {
1163
- if (token.type === "heading_open" && token.map) {
1164
- token.attrSet("data-source-line", String(token.map[0] + bodyStartLine));
1165
- }
1166
- if (token.type === "fence") {
1167
- const lang = token.info.trim().split(/\s+/)[0] || "text";
1168
- if (lang === "mermaid") {
1169
- token.type = "html_block";
1170
- token.content = `<div class="mermaid-block" data-source="${encodeURIComponent(token.content)}"></div>
1171
- `;
1172
- } else {
1173
- const highlighted = await highlightCode(token.content, lang, palette);
1174
- token.type = "html_block";
1175
- token.content = highlighted + "\n";
1176
- }
1177
- }
1178
- }
1179
- const html = md.renderer.render(tokens, md.options, {});
1180
- return { html, tokens };
1181
- }
1182
-
1183
- // src/render/outline.ts
1184
- function extractOutline(tokens) {
1185
- const flat = [];
1186
- for (let i = 0; i < tokens.length; i++) {
1187
- const t = tokens[i];
1188
- if (t.type !== "heading_open") continue;
1189
- const level = Number(t.tag.slice(1));
1190
- const inline = tokens[i + 1];
1191
- const id = (t.attrGet("id") ?? "").toString();
1192
- const text = inline?.content?.trim() ?? "";
1193
- if (!id || !text) continue;
1194
- flat.push({ id, text, level, children: [] });
1195
- }
1196
- return nest(flat);
1197
- }
1198
- function nest(flat) {
1199
- const root = [];
1200
- const stack = [];
1201
- for (const node of flat) {
1202
- while (stack.length && stack[stack.length - 1].level >= node.level) {
1203
- stack.pop();
1204
- }
1205
- if (stack.length === 0) {
1206
- root.push(node);
1207
- } else {
1208
- stack[stack.length - 1].children.push(node);
1209
- }
1210
- stack.push(node);
1211
- }
1212
- return root;
1213
- }
1214
-
1215
- // src/server/routes/api-file.ts
1216
- function registerApiFile(app, roots) {
1217
- app.get(
1218
- "/api/file",
1219
- async (req, reply) => {
1220
- const requested = req.query.path?.trim() ?? "";
1221
- const rawPalette = req.query.palette?.trim() ?? "";
1222
- const palette = PALETTES.includes(rawPalette) ? rawPalette : DEFAULT_PALETTE;
1223
- const primary = roots[0];
1224
- if (!primary) {
1225
- return reply.code(404).send({ error: "No folders are open" });
1226
- }
1227
- let wsPath = requested;
1228
- if (!wsPath) {
1229
- if (primary.root.kind !== "file") {
1230
- return reply.code(400).send({ error: "Missing ?path" });
1231
- }
1232
- wsPath = toWorkspacePath(primary.root.id, primary.root.filePath);
1233
- }
1234
- const resolved = parseWorkspacePath(roots, wsPath);
1235
- if (!resolved) {
1236
- return reply.code(404).send({ error: "File not found" });
1237
- }
1238
- const { state, rel } = resolved;
1239
- if (state.root.kind === "file" && rel !== state.root.filePath) {
1240
- return reply.code(404).send({ error: "File not found in single-file mode" });
1241
- }
1242
- if (!/\.(md|markdown|mdx)$/i.test(rel)) {
1243
- return reply.code(400).send({ error: "Only markdown files are supported" });
1244
- }
1245
- let absPath;
1246
- try {
1247
- absPath = resolveSafePath(state.absPath, rel);
1248
- } catch (err) {
1249
- return reply.code(400).send({ error: err.message });
1250
- }
1251
- let raw;
1252
- let mtimeMs;
1253
- try {
1254
- const [content, st] = await Promise.all([
1255
- readFile3(absPath, "utf8"),
1256
- stat2(absPath)
1257
- ]);
1258
- raw = content;
1259
- mtimeMs = st.mtimeMs;
1260
- } catch {
1261
- return reply.code(404).send({ error: "File not found" });
1262
- }
1263
- const { data, body, bodyStartLine } = parseFrontmatter(raw);
1264
- const { html: rawHtml, tokens } = await renderMarkdown(body, bodyStartLine, palette);
1265
- const wsRel = toWorkspacePath(state.root.id, rel);
1266
- const html = rewriteImageSrc(tagInternalLinks(rawHtml, wsRel), wsRel);
1267
- const outline = extractOutline(tokens);
1268
- const title = (typeof data?.title === "string" ? data.title : null) ?? outline[0]?.text ?? null;
1269
- const result = {
1270
- relPath: wsRel,
1271
- html,
1272
- outline,
1273
- frontmatter: data,
1274
- title,
1275
- lastModified: mtimeMs
1276
- };
1277
- return reply.send(result);
1278
- }
1279
- );
1280
- }
1281
-
1282
- // src/server/fs/grep.ts
1283
- import { readFile as readFile4 } from "fs/promises";
1284
-
1285
- // src/shared/search-pattern.ts
1286
- function escapeRegex(s) {
1287
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1288
- }
1289
- function compilePattern(query, opts) {
1290
- if (!query) {
1291
- return {
1292
- test: () => null,
1293
- matchAll: () => [],
1294
- matchAllWithBudget: () => ({ matches: [], truncated: false }),
1295
- query,
1296
- valid: true
1297
- };
1298
- }
1299
- let re;
1300
- if (opts.regex) {
933
+ // src/daemon/update-check.ts
934
+ var POLL_MS = 60 * 60 * 1e3;
935
+ function createUpdateChecker(deps) {
936
+ let running = false;
937
+ const tick = async () => {
938
+ if (running) return "busy";
939
+ running = true;
1301
940
  try {
1302
- re = new RegExp(query, opts.caseSensitive ? "g" : "gi");
1303
- } catch {
1304
- return {
1305
- test: () => null,
1306
- matchAll: () => [],
1307
- matchAllWithBudget: () => ({ matches: [], truncated: false }),
1308
- query,
1309
- valid: false
1310
- };
1311
- }
1312
- } else {
1313
- let pat = escapeRegex(query);
1314
- if (opts.wholeWord) pat = `\\b${pat}\\b`;
1315
- re = new RegExp(pat, opts.caseSensitive ? "g" : "gi");
1316
- }
1317
- return {
1318
- query,
1319
- valid: true,
1320
- test(text) {
1321
- re.lastIndex = 0;
1322
- return re.exec(text);
1323
- },
1324
- matchAll(text) {
1325
- re.lastIndex = 0;
1326
- const out = [];
1327
- let m;
1328
- while ((m = re.exec(text)) !== null) {
1329
- if (m[0].length === 0) {
1330
- re.lastIndex++;
1331
- continue;
1332
- }
1333
- out.push({ index: m.index, length: m[0].length });
1334
- }
1335
- return out;
1336
- },
1337
- matchAllWithBudget(text, budgetMs) {
1338
- re.lastIndex = 0;
1339
- const matches = [];
1340
- const deadline = performance.now() + budgetMs;
1341
- let m;
1342
- while ((m = re.exec(text)) !== null) {
1343
- if (m[0].length === 0) {
1344
- re.lastIndex++;
1345
- continue;
1346
- }
1347
- matches.push({ index: m.index, length: m[0].length });
1348
- if (performance.now() > deadline) {
1349
- return { matches, truncated: true };
1350
- }
1351
- }
1352
- return { matches, truncated: false };
941
+ if (!await deps.isEnabled()) return "disabled";
942
+ const now = deps.now();
943
+ if (!isCheckDue(readUpdateCache(deps.cacheFile), now)) return "fresh";
944
+ const latest = await deps.fetchLatest();
945
+ recordCheck(latest, now, deps.cacheFile);
946
+ return "checked";
947
+ } catch (err) {
948
+ deps.log(
949
+ `update check failed: ${err instanceof UpdateCheckError ? err.message : "unexpected error"}`
950
+ );
951
+ return "failed";
952
+ } finally {
953
+ running = false;
1353
954
  }
1354
955
  };
1355
- }
1356
-
1357
- // src/shared/tree-utils.ts
1358
- var MD_EXT = /* @__PURE__ */ new Set([".md", ".markdown", ".mdx"]);
1359
- function flattenMdRelPaths(nodes, out = []) {
1360
- for (const n of nodes) {
1361
- if (n.type === "file" && n.isMarkdown) out.push(n.relPath);
1362
- else if (n.type === "dir" && n.children) flattenMdRelPaths(n.children, out);
1363
- }
1364
- return out;
1365
- }
1366
-
1367
- // src/server/fs/tree.ts
1368
- import { readdir } from "fs/promises";
1369
- import path10 from "path";
1370
- async function walkFolder(root, opts = {}) {
1371
- const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
1372
- return walkInner(root, opts.relBase ?? "", ignore);
1373
- }
1374
- async function walkInner(absDir, relBase, ignore) {
1375
- const entries = await readdir(absDir, { withFileTypes: true });
1376
- const out = [];
1377
- for (const entry of entries) {
1378
- if (entry.isDirectory()) {
1379
- if (isDirIgnored(entry.name, ignore)) continue;
1380
- } else if (entry.name.startsWith(".")) {
1381
- continue;
1382
- }
1383
- const childRel = relBase ? `${relBase}/${entry.name}` : entry.name;
1384
- if (entry.isDirectory()) {
1385
- const children = await walkInner(path10.join(absDir, entry.name), childRel, ignore);
1386
- out.push({
1387
- name: entry.name,
1388
- relPath: childRel,
1389
- type: "dir",
1390
- children
1391
- });
1392
- } else if (entry.isFile()) {
1393
- const ext = path10.extname(entry.name).toLowerCase();
1394
- out.push({
1395
- name: entry.name,
1396
- relPath: childRel,
1397
- type: "file",
1398
- isMarkdown: MD_EXT.has(ext)
1399
- });
1400
- }
1401
- }
1402
- out.sort((a, b) => {
1403
- if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
1404
- return a.name.localeCompare(b.name);
1405
- });
1406
- return out;
1407
- }
1408
-
1409
- // src/server/fs/grep.ts
1410
- function buildSnippet(line, start, end, radius) {
1411
- const from = Math.max(0, start - radius);
1412
- const to = Math.min(line.length, end + radius);
1413
- const prefix = from > 0 ? "\u2026" : "";
1414
- const suffix = to < line.length ? "\u2026" : "";
1415
- const sliced = line.slice(from, to);
1416
- const hlStart = prefix.length + (start - from);
1417
- const hlEnd = hlStart + (end - start);
956
+ let timer;
1418
957
  return {
1419
- snippet: prefix + sliced + suffix,
1420
- highlight: [hlStart, hlEnd]
1421
- };
1422
- }
1423
- async function grepFiles(roots, query, opts = {}) {
1424
- const perFileCap = opts.perFileCap ?? 20;
1425
- const globalCap = opts.globalCap ?? 200;
1426
- const snippetRadius = opts.snippetRadius ?? 60;
1427
- const perLineBudgetMs = opts.perLineBudgetMs ?? 5;
1428
- const maxLineLenForRegex = opts.maxLineLenForRegex ?? 1e4;
1429
- const regexMode = opts.regex ?? false;
1430
- if (!query || query.length === 0) {
1431
- return { query, results: [], truncated: false };
1432
- }
1433
- const pattern = compilePattern(query, {
1434
- caseSensitive: opts.caseSensitive ?? false,
1435
- wholeWord: opts.wholeWord ?? false,
1436
- regex: opts.regex ?? false
1437
- });
1438
- if (!pattern.valid) return { query, results: [], truncated: false };
1439
- const results = [];
1440
- let totalHits = 0;
1441
- let truncated = false;
1442
- for (const root of roots) {
1443
- if (totalHits >= globalCap) {
1444
- truncated = true;
1445
- break;
1446
- }
1447
- const tree = await walkFolder(root.absPath, { ignore: root.ignore ?? opts.ignore });
1448
- const files = flattenMdRelPaths(tree);
1449
- for (const relPath of files) {
1450
- if (totalHits >= globalCap) {
1451
- truncated = true;
1452
- break;
1453
- }
1454
- let absPath;
1455
- try {
1456
- absPath = resolveSafePath(root.absPath, relPath);
1457
- } catch {
1458
- continue;
1459
- }
1460
- let raw;
1461
- try {
1462
- raw = await readFile4(absPath, "utf8");
1463
- } catch {
1464
- continue;
1465
- }
1466
- const { body } = parseFrontmatter(raw);
1467
- const lines = body.split(/\r?\n/);
1468
- const fileHits = [];
1469
- let fileTotal = 0;
1470
- let fileTruncated = false;
1471
- for (let i = 0; i < lines.length; i++) {
1472
- const line = lines[i];
1473
- if (regexMode && line.length > maxLineLenForRegex) {
1474
- fileTruncated = true;
1475
- continue;
1476
- }
1477
- const { matches, truncated: lineTruncated } = regexMode ? pattern.matchAllWithBudget(line, perLineBudgetMs) : { matches: pattern.matchAll(line), truncated: false };
1478
- if (lineTruncated) fileTruncated = true;
1479
- for (const m of matches) {
1480
- fileTotal++;
1481
- if (fileHits.length < perFileCap) {
1482
- const { snippet, highlight } = buildSnippet(
1483
- line,
1484
- m.index,
1485
- m.index + m.length,
1486
- snippetRadius
1487
- );
1488
- fileHits.push({ line: i + 1, col: m.index, snippet, highlight });
1489
- totalHits++;
1490
- } else {
1491
- fileTruncated = true;
1492
- }
1493
- if (totalHits >= globalCap) {
1494
- truncated = true;
1495
- break;
1496
- }
1497
- }
1498
- if (totalHits >= globalCap) break;
1499
- }
1500
- if (fileHits.length > 0) {
1501
- results.push({
1502
- relPath: `${root.id}/${relPath}`,
1503
- hits: fileHits,
1504
- total: fileTotal,
1505
- truncated: fileTruncated
1506
- });
1507
- }
1508
- }
1509
- }
1510
- return { query, results, truncated };
1511
- }
1512
-
1513
- // src/server/routes/api-search.ts
1514
- var MAX_QUERY_LEN = 200;
1515
- function isTrue(v) {
1516
- return v === "1" || v === "true";
1517
- }
1518
- function registerApiSearch(app, roots) {
1519
- app.get("/api/search", async (req, reply) => {
1520
- const q = (req.query.q ?? "").trim();
1521
- if (q.length === 0) {
1522
- return reply.send({ query: "", results: [], truncated: false });
1523
- }
1524
- if (q.length > MAX_QUERY_LEN) {
1525
- return reply.code(400).send({ error: "Query too long" });
1526
- }
1527
- const searchable = roots.filter((r) => r.root.kind === "dir").map((r) => ({ absPath: r.absPath, id: r.root.id, ignore: r.ignoreSet }));
1528
- const out = await grepFiles(searchable, q, {
1529
- caseSensitive: isTrue(req.query.case),
1530
- wholeWord: isTrue(req.query.word),
1531
- regex: isTrue(req.query.regex)
1532
- });
1533
- return reply.send(out);
1534
- });
1535
- }
1536
-
1537
- // src/server/routes/api-tree.ts
1538
- import path11 from "path";
1539
- function applyOverrides(config, paletteOverride) {
1540
- if (!paletteOverride) return config;
1541
- return { ...config ?? {}, palette: paletteOverride };
1542
- }
1543
- async function rootChildren(state) {
1544
- const { id, kind, filePath } = state.root;
1545
- if (kind === "file") {
1546
- return [
1547
- {
1548
- name: path11.basename(filePath),
1549
- relPath: toWorkspacePath(id, filePath),
1550
- type: "file",
1551
- isMarkdown: true
1552
- }
1553
- ];
1554
- }
1555
- return walkFolder(state.absPath, { ignore: state.ignoreSet, relBase: id });
1556
- }
1557
- function registerApiTree(app, roots, paletteOverride) {
1558
- app.get("/api/tree", async (_req, reply) => {
1559
- const config = applyOverrides(roots[0]?.config ?? null, paletteOverride);
1560
- if (roots.length === 1) {
1561
- const only = roots[0];
1562
- return reply.send({
1563
- roots: [only.root],
1564
- tree: await rootChildren(only),
1565
- config
1566
- });
1567
- }
1568
- const tree = [];
1569
- for (const state of roots) {
1570
- tree.push({
1571
- name: state.root.name,
1572
- relPath: state.root.id,
1573
- type: "dir",
1574
- children: await rootChildren(state)
1575
- });
1576
- }
1577
- return reply.send({ roots: roots.map((r) => r.root), tree, config });
1578
- });
1579
- }
1580
-
1581
- // src/server/routes/health.ts
1582
- function registerHealth(app, identity, roots) {
1583
- app.get(
1584
- "/api/health",
1585
- async (_req, reply) => reply.send({
1586
- ok: true,
1587
- pid: identity.pid,
1588
- startedAt: identity.startedAt,
1589
- roots: roots.map((r) => r.root.id)
1590
- })
1591
- );
1592
- }
1593
-
1594
- // src/server/routes/sse.ts
1595
- function registerSse(app, hub, hooks = {}) {
1596
- let clients = 0;
1597
- app.get("/api/watch", (req, reply) => {
1598
- reply.raw.writeHead(200, {
1599
- "Content-Type": "text/event-stream",
1600
- "Cache-Control": "no-cache, no-transform",
1601
- Connection: "keep-alive",
1602
- "X-Accel-Buffering": "no"
1603
- });
1604
- reply.raw.write(": connected\n\n");
1605
- let closed = false;
1606
- clients++;
1607
- hooks.onClientsChanged?.(clients);
1608
- const cleanup = () => {
1609
- if (closed) return;
1610
- closed = true;
1611
- clearInterval(heartbeat);
1612
- hub.off("event", send);
1613
- clients--;
1614
- hooks.onClientsChanged?.(clients);
1615
- try {
1616
- reply.raw.end();
1617
- } catch {
1618
- }
1619
- };
1620
- const send = (e) => {
1621
- if (closed) return;
1622
- try {
1623
- reply.raw.write(`event: ${e.kind}
1624
- `);
1625
- reply.raw.write(`data: ${JSON.stringify(e)}
1626
-
1627
- `);
1628
- } catch {
1629
- cleanup();
1630
- }
1631
- };
1632
- const heartbeat = setInterval(() => {
1633
- if (closed) return;
1634
- try {
1635
- reply.raw.write(": hb\n\n");
1636
- } catch {
1637
- cleanup();
1638
- }
1639
- }, 15e3);
1640
- hub.on("event", send);
1641
- req.raw.on("close", cleanup);
1642
- req.raw.on("error", cleanup);
1643
- reply.raw.on("error", cleanup);
1644
- reply.raw.on("close", cleanup);
1645
- });
1646
- }
1647
-
1648
- // src/server/watcher.ts
1649
- import { EventEmitter } from "events";
1650
- import path12 from "path";
1651
- import chokidar from "chokidar";
1652
- function createEventHub() {
1653
- const emitter = new EventEmitter();
1654
- emitter.setMaxListeners(0);
1655
- return {
1656
- on: (event, listener) => emitter.on(event, listener),
1657
- off: (event, listener) => emitter.off(event, listener),
1658
- emitSynthetic: (e) => emitter.emit("event", e),
1659
- close: async () => {
1660
- emitter.removeAllListeners();
1661
- }
1662
- };
1663
- }
1664
- function createWatcher(rootAbsPath, opts = {}) {
1665
- const emitter = new EventEmitter();
1666
- emitter.setMaxListeners(0);
1667
- const ignore = opts.ignore ?? DEFAULT_IGNORED_DIRS2;
1668
- const prefix = opts.prefix ?? "";
1669
- const watcher = chokidar.watch(rootAbsPath, {
1670
- ignoreInitial: true,
1671
- ignored: (p) => isPathIgnored(p, rootAbsPath, ignore),
1672
- persistent: true,
1673
- awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
1674
- });
1675
- watcher.on("error", (err) => {
1676
- const e = err;
1677
- if (e?.code === "EMFILE" || e?.code === "ENOSPC") {
1678
- console.error(
1679
- `[mdview] file watcher hit ${e.code} while watching ${rootAbsPath}
1680
- Path: ${e.path ?? "(unknown)"}
1681
- Likely cause: a heavy directory (build output, deps) wasn't ignored.
1682
- Fix: add the directory name to "ignore" in ~/.config/mdview/config.json, e.g.
1683
- { "ignore": ["deps", "_site"] }
1684
- Built-in skips already cover ${[...DEFAULT_IGNORED_DIRS2].join(", ")}.`
1685
- );
1686
- } else {
1687
- console.error(`[mdview] watcher error: ${e?.message ?? String(err)}`);
958
+ tick,
959
+ start: () => {
960
+ if (timer) return;
961
+ void tick();
962
+ timer = setInterval(() => void tick(), deps.pollMs ?? POLL_MS);
963
+ timer.unref();
964
+ },
965
+ stop: () => {
966
+ if (timer) clearInterval(timer);
967
+ timer = void 0;
1688
968
  }
1689
- });
1690
- function emit(kind, abs) {
1691
- const rel = path12.relative(rootAbsPath, abs).split(path12.sep).join("/");
1692
- if (!rel) return;
1693
- const relPath = prefix ? `${prefix}/${rel}` : rel;
1694
- emitter.emit("event", { kind, relPath });
1695
- }
1696
- watcher.on("change", (p) => emit("change", p));
1697
- watcher.on("add", (p) => emit("add", p));
1698
- watcher.on("unlink", (p) => emit("unlink", p));
1699
- return {
1700
- on: (event, listener) => emitter.on(event, listener),
1701
- off: (event, listener) => emitter.off(event, listener),
1702
- emitSynthetic: (e) => emitter.emit("event", e),
1703
- close: () => watcher.close()
1704
969
  };
1705
970
  }
1706
971
 
1707
- // src/server/index.ts
1708
- function buildCspHtml(embedMode) {
1709
- const directives = [
1710
- "default-src 'self'",
1711
- "script-src 'self'",
1712
- // KaTeX and the diagram renderer set inline style attributes on their
1713
- // output; the rest of the policy is strict.
1714
- "style-src 'self' 'unsafe-inline'",
1715
- "img-src 'self' data:",
1716
- "font-src 'self' data:",
1717
- "connect-src 'self'",
1718
- "base-uri 'none'",
1719
- "form-action 'none'"
1720
- ];
1721
- if (!embedMode) directives.push("frame-ancestors 'none'");
1722
- return directives.join("; ");
1723
- }
1724
- async function createServer(opts) {
1725
- const app = Fastify({ logger: false });
1726
- const cspHtml = buildCspHtml(opts.embedMode ?? false);
1727
- app.addHook("onRequest", async (req, reply) => {
1728
- if (isAllowedHost(req.headers.host)) return;
1729
- return reply.code(403).type("text/plain; charset=utf-8").send("Forbidden");
1730
- });
1731
- app.addHook("onSend", async (_req, reply, payload) => {
1732
- const ct = String(reply.getHeader("content-type") ?? "");
1733
- if (ct.startsWith("text/html")) {
1734
- reply.header("content-security-policy", cspHtml);
1735
- reply.header("x-content-type-options", "nosniff");
1736
- reply.header("referrer-policy", "no-referrer");
1737
- }
1738
- return payload;
1739
- });
1740
- const roots = [];
1741
- const configWatchers = /* @__PURE__ */ new Map();
1742
- const hub = createEventHub();
1743
- async function openRoot(spec, id) {
1744
- const config = await loadEffectiveConfig(spec.absPath);
1745
- const ignoreSet = buildIgnoreSet(config?.ignore ?? []);
1746
- const state = {
1747
- root: describeRoot(spec, id),
1748
- absPath: spec.absPath,
1749
- ignoreSet,
1750
- config,
1751
- watcher: createWatcher(spec.absPath, { ignore: ignoreSet, prefix: id })
1752
- };
1753
- state.watcher.on("event", (e) => hub.emitSynthetic(e));
1754
- const configWatcher = chokidar2.watch(path13.join(spec.absPath, CONFIG_FILENAME), {
1755
- ignoreInitial: true,
1756
- persistent: true,
1757
- awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
1758
- });
1759
- const reloadConfig = async () => {
1760
- state.config = await loadEffectiveConfig(spec.absPath);
1761
- const event = { kind: "config", relPath: `${id}/${CONFIG_FILENAME}` };
1762
- hub.emitSynthetic(event);
1763
- };
1764
- configWatcher.on("add", () => void reloadConfig());
1765
- configWatcher.on("change", () => void reloadConfig());
1766
- configWatcher.on("unlink", () => void reloadConfig());
1767
- configWatchers.set(id, configWatcher);
1768
- return state;
1769
- }
1770
- async function closeRoot(state) {
1771
- const cw = configWatchers.get(state.root.id);
1772
- configWatchers.delete(state.root.id);
1773
- await Promise.all([state.watcher.close(), cw?.close()]);
1774
- }
1775
- async function setRoots(specs) {
1776
- const taken = /* @__PURE__ */ new Set();
1777
- const wanted = specs.map((spec) => {
1778
- const id = resolveRootId(spec, taken);
1779
- taken.add(id);
1780
- return { id, spec };
1781
- });
1782
- const keep = new Map(
1783
- roots.filter((r) => wanted.some((w) => w.id === r.root.id && w.spec.absPath === r.absPath)).map((r) => [r.root.id, r])
972
+ // src/update/notice.ts
973
+ async function printUpdateNotice(deps) {
974
+ try {
975
+ if (!deps.interactive()) return;
976
+ if (!await deps.isEnabled()) return;
977
+ const cache = readUpdateCache(deps.cacheFile);
978
+ if (!cache || !isNewerVersion(cache.latest, deps.version)) return;
979
+ const now = deps.now();
980
+ if (wasRecentlyNotified(cache, now)) return;
981
+ deps.print(
982
+ `mdview ${cache.latest} is available \u2014 run \`mdview update\` for the install command.`
1784
983
  );
1785
- const dropped = roots.filter((r) => !keep.has(r.root.id));
1786
- const next = [];
1787
- for (const { id, spec } of wanted) {
1788
- const existing = keep.get(id);
1789
- next.push(existing ?? await openRoot(spec, id));
1790
- }
1791
- roots.length = 0;
1792
- roots.push(...next);
1793
- await Promise.all(dropped.map(closeRoot));
1794
- hub.emitSynthetic({ kind: "workspace" });
1795
- }
1796
- {
1797
- const taken = /* @__PURE__ */ new Set();
1798
- for (const spec of opts.roots) {
1799
- const id = resolveRootId(spec, taken);
1800
- taken.add(id);
1801
- roots.push(await openRoot(spec, id));
1802
- }
984
+ recordNotified(cache.latest, now, deps.cacheFile);
985
+ } catch {
1803
986
  }
1804
- registerApiFile(app, roots);
1805
- registerApiTree(app, roots, opts.paletteOverride);
1806
- registerApiAsset(app, roots);
1807
- registerApiSearch(app, roots);
1808
- registerSse(app, hub, { ...opts.onSseClientsChanged ? { onClientsChanged: opts.onSseClientsChanged } : {} });
1809
- if (opts.identity) registerHealth(app, opts.identity, roots);
1810
- await app.register(import("@fastify/static"), {
1811
- root: opts.clientDir,
1812
- prefix: "/"
1813
- });
1814
- app.setNotFoundHandler(async (req, reply) => {
1815
- if (req.url.startsWith("/api/") || req.url.startsWith("/__asset/")) {
1816
- reply.code(404).send({ error: "Not found" });
1817
- return;
1818
- }
1819
- const qIndex = req.url.indexOf("?");
1820
- const rawPath = qIndex >= 0 ? req.url.slice(0, qIndex) : req.url;
1821
- if (/[\r\n]/.test(rawPath)) {
1822
- reply.code(404).send({ error: "Not found" });
1823
- return;
1824
- }
1825
- if (/\.(md|markdown|mdx)$/i.test(rawPath)) {
1826
- const relPath = rawPath.replace(/^\/+/, "");
1827
- const location = `/?file=${encodeURIComponent(relPath)}`;
1828
- reply.code(302).header("Location", location).send();
1829
- return;
1830
- }
1831
- return reply.sendFile("index.html");
1832
- });
1833
- app.addHook("onClose", async () => {
1834
- await Promise.all([
1835
- ...roots.map((r) => r.watcher.close()),
1836
- ...[...configWatchers.values()].map((w) => w.close())
1837
- ]);
1838
- await hub.close();
1839
- });
1840
- return { app, setRoots };
1841
987
  }
1842
988
 
1843
989
  // src/cli.ts
@@ -1850,6 +996,7 @@ Usage:
1850
996
  mdview ls List open folders and the background server
1851
997
  mdview rm <name|path>\u2026 Stop serving a folder
1852
998
  mdview stop Stop the background server
999
+ mdview update Check for a newer release and print the install command
1853
1000
  mdview config <\u2026> Read or edit the global config (run 'mdview config -h')
1854
1001
 
1855
1002
  One background server serves every folder you open, so 'mdview .' in a second
@@ -1877,24 +1024,9 @@ Examples:
1877
1024
  mdview --port 9000 ./docs One-off server on a specific port
1878
1025
  mdview config ignore add deps _site Hide extra build dirs globally
1879
1026
  mdview config path Print global config path
1027
+ mdview update Is there a newer mdview?
1880
1028
  `.trim());
1881
1029
  }
1882
- function readVersion() {
1883
- const here = path14.dirname(fileURLToPath(import.meta.url));
1884
- const candidates = [
1885
- path14.resolve(here, "../package.json"),
1886
- path14.resolve(here, "../../package.json")
1887
- ];
1888
- for (const p of candidates) {
1889
- try {
1890
- const raw = readFileSync2(p, "utf8");
1891
- const v = JSON.parse(raw).version;
1892
- if (typeof v === "string") return v;
1893
- } catch {
1894
- }
1895
- }
1896
- return "unknown";
1897
- }
1898
1030
  var DEFAULT_PORT = 7331;
1899
1031
  var FRIENDLY_HOST = "mdview.localhost";
1900
1032
  function isLoopbackAddress(addr) {
@@ -1975,24 +1107,38 @@ function parseArgs(argv, defaultPort = DEFAULT_PORT) {
1975
1107
  return { kind: "run", args };
1976
1108
  }
1977
1109
  function detectRoot(target) {
1978
- const abs = path14.resolve(target);
1110
+ const abs = path6.resolve(target);
1979
1111
  if (!existsSync2(abs)) throw new Error(`Path does not exist: ${abs}`);
1980
- const st = statSync4(abs);
1112
+ const st = statSync5(abs);
1981
1113
  if (st.isDirectory()) {
1982
1114
  return { absPath: abs, id: deriveRootId(abs, /* @__PURE__ */ new Set()), kind: "dir" };
1983
1115
  }
1984
1116
  if (st.isFile()) {
1985
- const dir = path14.dirname(abs);
1117
+ const target2 = realpathSync2.native(abs);
1118
+ const linked = path6.dirname(target2) !== realpathSync2.native(path6.dirname(abs));
1119
+ const dir = linked ? path6.dirname(target2) : path6.dirname(abs);
1986
1120
  return {
1987
1121
  absPath: dir,
1988
1122
  id: deriveRootId(dir, /* @__PURE__ */ new Set()),
1989
- name: path14.basename(abs),
1123
+ name: path6.basename(abs),
1990
1124
  kind: "file",
1991
- filePath: path14.basename(abs)
1125
+ filePath: path6.basename(target2)
1992
1126
  };
1993
1127
  }
1994
1128
  throw new Error(`Unsupported path type: ${abs}`);
1995
1129
  }
1130
+ var EMBED_TOKEN_ENV = "MDVIEW_EMBED_TOKEN";
1131
+ var EMBED_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,256}$/;
1132
+ function takeEmbedToken() {
1133
+ const token = process.env[EMBED_TOKEN_ENV];
1134
+ delete process.env[EMBED_TOKEN_ENV];
1135
+ if (token === void 0 || !EMBED_TOKEN_PATTERN.test(token)) {
1136
+ throw new Error(
1137
+ `Embed mode (--vscode) needs ${EMBED_TOKEN_ENV} set to a random base64url token of at least 32 characters. --vscode is meant to be started by the mdview VS Code extension; use --foreground to run a server yourself.`
1138
+ );
1139
+ }
1140
+ return token;
1141
+ }
1996
1142
  function portFinderHint(port) {
1997
1143
  switch (process.platform) {
1998
1144
  case "darwin":
@@ -2038,10 +1184,10 @@ Find the process with: ${portFinderHint(port)}`
2038
1184
  throw new Error("Could not bind a port (tried 10 in a row).");
2039
1185
  }
2040
1186
  function requireClientDir() {
2041
- const here = path14.dirname(fileURLToPath(import.meta.url));
1187
+ const here = path6.dirname(fileURLToPath2(import.meta.url));
2042
1188
  const candidates = [
2043
- path14.resolve(here, "../dist/client"),
2044
- path14.resolve(here, "../../dist/client")
1189
+ path6.resolve(here, "../dist/client"),
1190
+ path6.resolve(here, "../../dist/client")
2045
1191
  ];
2046
1192
  const dir = candidates.find(existsSync2);
2047
1193
  if (!dir) throw new Error("Client bundle not found. Run `npm run build:client` first.");
@@ -2054,14 +1200,29 @@ function idleTimeoutMs() {
2054
1200
  if (!Number.isFinite(n) || n < 0) return 0;
2055
1201
  return Math.round(n * 6e4);
2056
1202
  }
1203
+ function loadServer() {
1204
+ return import("./server-KALIU7HM.mjs");
1205
+ }
1206
+ async function openBrowser(url) {
1207
+ const { default: open } = await import("open");
1208
+ await open(url);
1209
+ }
1210
+ var WARM_UP_DELAY_MS = 100;
1211
+ function warmHighlighterInBackground() {
1212
+ setTimeout(() => {
1213
+ void import("./shiki-5SFVA32N.mjs").then((m) => m.warmHighlighter()).catch(() => void 0);
1214
+ }, WARM_UP_DELAY_MS).unref();
1215
+ }
2057
1216
  async function runForeground(args) {
1217
+ const embedToken = args.embedMode ? takeEmbedToken() : void 0;
2058
1218
  const root = detectRoot(args.target);
2059
1219
  const clientDir = requireClientDir();
1220
+ const { createServer } = await loadServer();
2060
1221
  const { app } = await createServer({
2061
1222
  roots: [root],
2062
1223
  clientDir,
2063
1224
  ...args.palette === void 0 ? {} : { paletteOverride: args.palette },
2064
- embedMode: args.embedMode
1225
+ ...embedToken === void 0 ? {} : { embedToken }
2065
1226
  });
2066
1227
  const boundPort = await listen(app, args.port, args.portExplicit);
2067
1228
  const host = args.embedMode ? "127.0.0.1" : await resolveDisplayHost();
@@ -2071,8 +1232,11 @@ async function runForeground(args) {
2071
1232
  `);
2072
1233
  } else {
2073
1234
  console.log(`mdview \u2192 ${url}`);
2074
- console.log(`watching: ${root.absPath}`);
1235
+ console.log(
1236
+ `watching: ${root.kind === "file" ? path6.join(root.absPath, root.filePath ?? "") : root.absPath}`
1237
+ );
2075
1238
  }
1239
+ warmHighlighterInBackground();
2076
1240
  if (args.open) await openBrowser(url);
2077
1241
  let shuttingDown = false;
2078
1242
  const shutdown = async () => {
@@ -2105,10 +1269,11 @@ async function runDaemon(args) {
2105
1269
  const clientDir = requireClientDir();
2106
1270
  const startedAt = Date.now();
2107
1271
  let idleTimer;
1272
+ const { createServer } = await loadServer();
2108
1273
  const server = await createServer({
2109
1274
  roots: toRootSpecs(readWorkspace()),
2110
1275
  clientDir,
2111
- identity: { pid: process.pid, startedAt },
1276
+ identity: { pid: process.pid, startedAt, version: readVersion() },
2112
1277
  onSseClientsChanged: (count) => {
2113
1278
  if (count > 0) {
2114
1279
  if (idleTimer) clearTimeout(idleTimer);
@@ -2121,9 +1286,19 @@ async function runDaemon(args) {
2121
1286
  const boundPort = await listen(server.app, args.port, args.portExplicit);
2122
1287
  writeDaemonState({ pid: process.pid, port: boundPort, startedAt });
2123
1288
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon pid ${process.pid} on port ${boundPort}`);
2124
- const wsWatcher = chokidar3.watch(workspaceFilePath(), {
1289
+ const updateChecker = createUpdateChecker({
1290
+ isEnabled: async () => (await loadGlobalConfig())?.updateCheck === true,
1291
+ fetchLatest: () => fetchLatestVersion(),
1292
+ now: Date.now,
1293
+ log: (message) => console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}`)
1294
+ });
1295
+ const { default: chokidar } = await import("chokidar");
1296
+ const wsWatcher = chokidar.watch(workspaceFilePath(), {
2125
1297
  ignoreInitial: true,
2126
1298
  persistent: true,
1299
+ // A workspace.json replaced by a link to a directory would otherwise be
1300
+ // walked and watched recursively.
1301
+ followSymlinks: false,
2127
1302
  awaitWriteFinish: { stabilityThreshold: 60, pollInterval: 30 }
2128
1303
  });
2129
1304
  const reload = () => {
@@ -2140,6 +1315,7 @@ async function runDaemon(args) {
2140
1315
  shuttingDown = true;
2141
1316
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] mdview daemon stopping (${why})`);
2142
1317
  clearDaemonState();
1318
+ updateChecker.stop();
2143
1319
  const timeout = new Promise((resolve) => setTimeout(resolve, 1500));
2144
1320
  await Promise.race([
2145
1321
  Promise.all([server.app.close().catch(() => void 0), wsWatcher.close()]).then(
@@ -2159,16 +1335,29 @@ async function runDaemon(args) {
2159
1335
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2160
1336
  if (process.platform !== "win32") process.on("SIGHUP", () => void shutdown("SIGHUP"));
2161
1337
  armIdle();
1338
+ warmHighlighterInBackground();
1339
+ updateChecker.start();
2162
1340
  }
2163
1341
  async function runAttach(args) {
2164
1342
  const added = addRoot(args.target);
2165
- const state = await ensureDaemon(fileURLToPath(import.meta.url));
1343
+ const state = await ensureDaemon(fileURLToPath2(import.meta.url), {
1344
+ onRestart: (from, to) => console.error(`mdview: restarted the background server (${from} \u2192 ${to})`)
1345
+ });
2166
1346
  await waitForRoot(state.port, added.root.id);
2167
1347
  const host = await resolveDisplayHost();
2168
1348
  const base = `http://${host}:${state.port}/`;
2169
1349
  const url = added.filePath ? `${base}?file=${encodeURIComponent(added.filePath)}` : `${base}?root=${encodeURIComponent(added.focusPath)}`;
2170
1350
  console.log(`mdview \u2192 ${url}`);
2171
1351
  console.log(`${added.changed ? "added" : "open"}: ${added.root.path} (${added.root.id})`);
1352
+ await printUpdateNotice({
1353
+ version: readVersion(),
1354
+ now: Date.now,
1355
+ interactive: () => Boolean(process.stdout.isTTY && process.stderr.isTTY),
1356
+ // The raw key, not loadGlobalConfig: this short command must not start
1357
+ // warning about unrelated fields in a config it never read before.
1358
+ isEnabled: () => readUpdateCheck(),
1359
+ print: (line) => console.error(line)
1360
+ });
2172
1361
  if (args.open) await openBrowser(url);
2173
1362
  }
2174
1363
  async function main() {
@@ -2178,6 +1367,13 @@ async function main() {
2178
1367
  if (sub === "ls" || sub === "status") process.exit(await runListCommand());
2179
1368
  if (sub === "rm") process.exit(await runRemoveCommand(argv.slice(1)));
2180
1369
  if (sub === "stop") process.exit(await runStopCommand());
1370
+ if (sub === "update") {
1371
+ if (argv.length > 1) {
1372
+ console.error("Usage: mdview update");
1373
+ process.exit(2);
1374
+ }
1375
+ process.exit(await runUpdateCommand());
1376
+ }
2181
1377
  let parsed;
2182
1378
  try {
2183
1379
  parsed = parseArgs(argv, envPort() ?? DEFAULT_PORT);
@@ -2216,6 +1412,7 @@ function formatError(err) {
2216
1412
  "Unsupported path type",
2217
1413
  "Could not bind a port",
2218
1414
  "Unexpected argument",
1415
+ "Embed mode",
2219
1416
  "Workspace is full",
2220
1417
  "Timed out waiting for the mdview background server",
2221
1418
  "The mdview background server did not start"
@@ -2229,7 +1426,7 @@ var isEntryPoint = (() => {
2229
1426
  const argv1 = process.argv[1];
2230
1427
  if (!argv1) return false;
2231
1428
  try {
2232
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(argv1);
1429
+ return realpathSync2(fileURLToPath2(import.meta.url)) === realpathSync2(argv1);
2233
1430
  } catch {
2234
1431
  return false;
2235
1432
  }
@@ -2253,6 +1450,7 @@ if (isEntryPoint) {
2253
1450
  }
2254
1451
  export {
2255
1452
  DEFAULT_PORT,
1453
+ detectRoot,
2256
1454
  idleTimeoutMs,
2257
1455
  parseArgs
2258
1456
  };