mdv-live 0.3.8 → 0.4.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.
package/bin/mdv.js CHANGED
@@ -23,6 +23,10 @@ const OPTIONS = {
23
23
  type: 'string',
24
24
  short: 'p',
25
25
  },
26
+ depth: {
27
+ type: 'string',
28
+ short: 'd',
29
+ },
26
30
  'no-browser': {
27
31
  type: 'boolean',
28
32
  default: false
@@ -76,6 +80,7 @@ Arguments:
76
80
 
77
81
  Server Options:
78
82
  -p, --port <n> Server port (default: ${DEFAULT_PORT})
83
+ -d, --depth <n> Directory watch depth (default: 3, prevents EMFILE errors)
79
84
  --no-browser Don't open browser automatically
80
85
 
81
86
  Server Management:
@@ -382,8 +387,9 @@ async function resolveTargetPath(targetPath) {
382
387
  * @param {string} targetPath - Target directory or file path
383
388
  * @param {number} startPort - Starting port number
384
389
  * @param {boolean} openBrowser - Whether to open browser automatically
390
+ * @param {number} depth - Directory watch depth
385
391
  */
386
- async function startViewer(targetPath, startPort, openBrowser) {
392
+ async function startViewer(targetPath, startPort, openBrowser, depth) {
387
393
  const { rootDir, initialFile } = await resolveTargetPath(targetPath);
388
394
 
389
395
  const port = await findAvailablePort(startPort);
@@ -396,11 +402,11 @@ async function startViewer(targetPath, startPort, openBrowser) {
396
402
  console.log(`ポート ${startPort} は使用中のため、${port} で起動します`);
397
403
  }
398
404
 
399
- const mdv = createMdvServer({ rootDir, port });
405
+ const mdv = createMdvServer({ rootDir, port, depth });
400
406
  await mdv.start();
401
407
 
402
408
  const url = initialFile
403
- ? `http://localhost:${port}?file=${encodeURIComponent(initialFile)}`
409
+ ? `http://localhost:${port}?path=${encodeURIComponent(initialFile)}`
404
410
  : `http://localhost:${port}`;
405
411
 
406
412
  console.log(`
@@ -472,9 +478,10 @@ async function main() {
472
478
  // Default: start viewer
473
479
  const targetPath = positionals[0] || '.';
474
480
  const port = parseInt(values.port, 10) || DEFAULT_PORT;
481
+ const depth = parseInt(values.depth, 10) || 3;
475
482
  const openBrowser = !values['no-browser'];
476
483
 
477
- await startViewer(targetPath, port, openBrowser);
484
+ await startViewer(targetPath, port, openBrowser, depth);
478
485
  }
479
486
 
480
487
  main().catch(err => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -77,7 +77,7 @@ for i in {1..25}; do
77
77
  echo "Found port: $PORT"
78
78
  echo "Opening browser..."
79
79
  # Use osascript to open URL (works better from AppleScript context)
80
- osascript -e "open location \"http://localhost:$PORT?file=$FILE_NAME\""
80
+ osascript -e "open location \"http://localhost:$PORT?path=$FILE_NAME\""
81
81
  echo "Done"
82
82
  exit 0
83
83
  fi
@@ -85,7 +85,7 @@ done
85
85
 
86
86
  echo "Timeout - using fallback"
87
87
  # Fallback
88
- osascript -e "open location \"http://localhost:8642?file=$FILE_NAME\""
88
+ osascript -e "open location \"http://localhost:8642?path=$FILE_NAME\""
89
89
  LAUNCHSCRIPT
90
90
 
91
91
  # Replace placeholder with actual path
package/src/api/file.js CHANGED
@@ -89,6 +89,32 @@ function buildBinaryFileResponse(name, fileType, downloadUrl) {
89
89
  export function setupFileRoutes(app) {
90
90
  const { rootDir } = app.locals;
91
91
 
92
+ // Serve raw files (for HTML preview with relative paths)
93
+ app.get('/raw/*', async (req, res) => {
94
+ const relativePath = req.params[0];
95
+ const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
96
+
97
+ if (!relativePath || !valid) {
98
+ return res.status(403).json({ error: 'Access denied' });
99
+ }
100
+
101
+ try {
102
+ const stat = await fs.stat(fullPath);
103
+ if (!stat.isFile()) {
104
+ return res.status(400).json({ error: 'Not a file' });
105
+ }
106
+
107
+ const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
108
+ res.setHeader('Content-Type', mimeType);
109
+ res.sendFile(fullPath);
110
+ } catch (err) {
111
+ if (err.code === 'ENOENT') {
112
+ return res.status(404).json({ error: 'File not found' });
113
+ }
114
+ res.status(500).json({ error: err.message });
115
+ }
116
+ });
117
+
92
118
  // Get file content
93
119
  app.get('/api/file', async (req, res) => {
94
120
  const { path: relativePath } = req.query;
@@ -115,6 +141,25 @@ export function setupFileRoutes(app) {
115
141
  return res.json(buildBinaryFileResponse(name, fileType, downloadUrl));
116
142
  }
117
143
 
144
+ // HTML files: return htmlUrl for iframe preview + raw content for editing
145
+ if (fileType.type === 'html') {
146
+ const content = await fs.readFile(fullPath, 'utf-8');
147
+ const escaped = content
148
+ .replace(/&/g, '&amp;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
151
+ .replace(/"/g, '&quot;')
152
+ .replace(/'/g, '&#x27;');
153
+ return res.json({
154
+ name,
155
+ fileType: 'html',
156
+ icon: 'html',
157
+ htmlUrl: `/raw/${relativePath}`,
158
+ content: `<pre><code class="language-html">${escaped}</code></pre>`,
159
+ raw: content
160
+ });
161
+ }
162
+
118
163
  const rendered = await renderFile(fullPath);
119
164
  res.json({ name, ...rendered });
120
165
  } catch (err) {
package/src/server.js CHANGED
@@ -47,10 +47,11 @@ function setupApiRoutes(app) {
47
47
  * @param {Object} options - Server options
48
48
  * @param {string} options.rootDir - Root directory to serve
49
49
  * @param {number} [options.port=8080] - Port to listen on
50
+ * @param {number} [options.depth=3] - Directory watch depth (prevents EMFILE errors)
50
51
  * @returns {{ app: express.Application, server: http.Server, watcher: FSWatcher, wss: WebSocketServer, port: number, start: () => Promise<{port: number}>, stop: () => Promise<void> }}
51
52
  */
52
53
  export function createMdvServer(options) {
53
- const { rootDir, port = 8080 } = options;
54
+ const { rootDir, port = 8080, depth = 3 } = options;
54
55
 
55
56
  const app = express();
56
57
  const server = createServer(app);
@@ -68,7 +69,7 @@ export function createMdvServer(options) {
68
69
  });
69
70
 
70
71
  const wss = setupWebSocket(server);
71
- const watcher = setupWatcher(app.locals.rootDir, wss);
72
+ const watcher = setupWatcher(app.locals.rootDir, wss, { depth });
72
73
 
73
74
  app.locals.watcher = watcher;
74
75
  app.locals.wss = wss;
package/src/static/app.js CHANGED
@@ -88,6 +88,21 @@
88
88
  rootPath: ''
89
89
  };
90
90
 
91
+ // ============================================================
92
+ // URL State Management
93
+ // ============================================================
94
+
95
+ function updateUrlPath(path) {
96
+ const url = new URL(window.location);
97
+ if (path) {
98
+ // パスの/をエンコードせずに表示
99
+ url.search = '?path=' + encodeURIComponent(path).replace(/%2F/g, '/');
100
+ } else {
101
+ url.search = '';
102
+ }
103
+ history.replaceState(null, '', url);
104
+ }
105
+
91
106
  // ============================================================
92
107
  // DOM Elements
93
108
  // ============================================================
@@ -477,6 +492,35 @@
477
492
  }
478
493
  },
479
494
 
495
+ async expandToPath(filePath) {
496
+ // パスを分割して親フォルダのリストを作成
497
+ const parts = filePath.split('/');
498
+ parts.pop(); // ファイル名を除外
499
+
500
+ let currentPath = '';
501
+ for (const part of parts) {
502
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
503
+
504
+ const item = document.querySelector(`.tree-item[data-path="${currentPath}"]`);
505
+ if (!item) continue;
506
+
507
+ const children = item.querySelector('.tree-children');
508
+ const chevron = item.querySelector('.chevron');
509
+
510
+ if (children && children.classList.contains('collapsed')) {
511
+ // 未読み込みの場合は子要素を取得
512
+ if (item.dataset.loaded !== 'true') {
513
+ await this.expandDirectory(currentPath, children);
514
+ }
515
+ children.classList.remove('collapsed');
516
+ if (chevron) chevron.classList.add('expanded');
517
+ }
518
+ }
519
+
520
+ // ファイルをハイライト
521
+ this.updateHighlight();
522
+ },
523
+
480
524
  renderFile(item) {
481
525
  const iconClass = item.icon ? `icon-${item.icon}` : '';
482
526
  const iconSvg = getFileIcon(item.icon);
@@ -830,6 +874,17 @@
830
874
  `;
831
875
  },
832
876
 
877
+ renderHTML(htmlUrl, name) {
878
+ elements.content.style.padding = '0';
879
+ elements.content.innerHTML = `
880
+ <div class="html-preview">
881
+ <iframe src="${htmlUrl}" title="${name}"
882
+ sandbox="allow-scripts allow-same-origin allow-forms allow-modals">
883
+ </iframe>
884
+ </div>
885
+ `;
886
+ },
887
+
833
888
  renderVideo(mediaUrl, name) {
834
889
  elements.content.innerHTML = `
835
890
  <div class="video-preview">
@@ -908,6 +963,7 @@
908
963
  css: data.css || null, // Marp CSS from marp-core
909
964
  imageUrl: data.imageUrl,
910
965
  pdfUrl: data.pdfUrl,
966
+ htmlUrl: data.htmlUrl,
911
967
  mediaUrl: data.mediaUrl,
912
968
  downloadUrl: data.downloadUrl,
913
969
  scrollTop: 0
@@ -923,6 +979,7 @@
923
979
  this.renderActive();
924
980
  WebSocketManager.watchFile(path);
925
981
  FileTreeManager.updateHighlight();
982
+ updateUrlPath(path);
926
983
  },
927
984
 
928
985
  switch(index) {
@@ -953,6 +1010,7 @@
953
1010
  this.renderActive();
954
1011
  WebSocketManager.watchFile(state.tabs[index].path);
955
1012
  FileTreeManager.updateHighlight();
1013
+ updateUrlPath(state.tabs[index].path);
956
1014
  },
957
1015
 
958
1016
  close(index) {
@@ -963,6 +1021,7 @@
963
1021
  this.render();
964
1022
  ContentRenderer.showWelcome();
965
1023
  FileTreeManager.updateHighlight();
1024
+ updateUrlPath(null);
966
1025
  return;
967
1026
  }
968
1027
 
@@ -974,6 +1033,7 @@
974
1033
  this.render();
975
1034
  this.renderActive();
976
1035
  FileTreeManager.updateHighlight();
1036
+ updateUrlPath(state.tabs[state.activeTabIndex].path);
977
1037
  },
978
1038
 
979
1039
  render() {
@@ -1018,6 +1078,8 @@
1018
1078
  ContentRenderer.renderImage(tab.imageUrl, tab.name);
1019
1079
  } else if (fileType === 'pdf') {
1020
1080
  ContentRenderer.renderPDF(tab.pdfUrl, tab.name);
1081
+ } else if (fileType === 'html' && tab.htmlUrl && !state.isEditMode) {
1082
+ ContentRenderer.renderHTML(tab.htmlUrl, tab.name);
1021
1083
  } else if (fileType === 'video') {
1022
1084
  ContentRenderer.renderVideo(tab.mediaUrl, tab.name);
1023
1085
  } else if (fileType === 'audio') {
@@ -1257,6 +1319,10 @@
1257
1319
  return !!elements.content.querySelector('.marpit');
1258
1320
  },
1259
1321
 
1322
+ isHtmlPreview() {
1323
+ return !!elements.content.querySelector('.html-preview iframe');
1324
+ },
1325
+
1260
1326
  async print() {
1261
1327
  if (state.activeTabIndex < 0) return;
1262
1328
 
@@ -1264,6 +1330,8 @@
1264
1330
 
1265
1331
  if (this.isMarpPresentation()) {
1266
1332
  await this.exportMarpPdf(tab.path);
1333
+ } else if (this.isHtmlPreview()) {
1334
+ this.printHtmlPreview(tab.name);
1267
1335
  } else {
1268
1336
  this.browserPrint(tab.name);
1269
1337
  }
@@ -1278,6 +1346,13 @@
1278
1346
  document.title = originalTitle;
1279
1347
  },
1280
1348
 
1349
+ printHtmlPreview(fileName) {
1350
+ const iframe = elements.content.querySelector('.html-preview iframe');
1351
+ if (iframe && iframe.contentWindow) {
1352
+ iframe.contentWindow.print();
1353
+ }
1354
+ },
1355
+
1281
1356
  async exportMarpPdf(filePath) {
1282
1357
  const statusText = elements.statusText;
1283
1358
  const originalStatus = statusText.textContent;
@@ -1931,9 +2006,10 @@
1931
2006
  });
1932
2007
  window.addEventListener('focus', handleFocusChange);
1933
2008
 
1934
- const initialFile = new URLSearchParams(window.location.search).get('file');
1935
- if (initialFile) {
1936
- TabManager.open(initialFile);
2009
+ const initialPath = new URLSearchParams(window.location.search).get('path');
2010
+ if (initialPath) {
2011
+ await FileTreeManager.expandToPath(initialPath);
2012
+ await TabManager.open(initialPath);
1937
2013
  }
1938
2014
  }
1939
2015
 
@@ -520,6 +520,22 @@ body {
520
520
  text-align: center;
521
521
  }
522
522
 
523
+ /* ============================================================
524
+ HTML Preview
525
+ ============================================================ */
526
+
527
+ .html-preview {
528
+ width: 100%;
529
+ height: 100%;
530
+ }
531
+
532
+ .html-preview iframe {
533
+ width: 100%;
534
+ height: 100%;
535
+ border: none;
536
+ background: white;
537
+ }
538
+
523
539
  /* ============================================================
524
540
  Editor Mode
525
541
  ============================================================ */
@@ -29,8 +29,8 @@ const FILE_TYPES = {
29
29
  jsx: code('react', 'jsx'),
30
30
 
31
31
  // Code - Web
32
- html: code('html', 'html'),
33
- htm: code('html', 'html'),
32
+ html: { type: 'html', icon: 'html', lang: 'html', binary: false },
33
+ htm: { type: 'html', icon: 'html', lang: 'html', binary: false },
34
34
  css: code('css', 'css'),
35
35
  scss: code('css', 'scss'),
36
36
  less: code('css', 'less'),
package/src/watcher.js CHANGED
@@ -34,13 +34,18 @@ const TREE_CHANGE_EVENTS = ['add', 'unlink', 'addDir', 'unlinkDir'];
34
34
  * Setup file watcher
35
35
  * @param {string} rootDir - Root directory to watch
36
36
  * @param {WebSocketServer} wss - WebSocket server for broadcasting
37
+ * @param {Object} [options] - Watcher options
38
+ * @param {number} [options.depth=3] - Directory depth to watch (prevents EMFILE errors)
37
39
  * @returns {FSWatcher} Chokidar watcher instance
38
40
  */
39
- export function setupWatcher(rootDir, wss) {
41
+ export function setupWatcher(rootDir, wss, options = {}) {
42
+ const { depth = 3 } = options;
43
+
40
44
  const watcher = chokidar.watch(rootDir, {
41
45
  ignored: IGNORED_PATTERNS,
42
46
  persistent: true,
43
47
  ignoreInitial: true,
48
+ depth,
44
49
  awaitWriteFinish: {
45
50
  stabilityThreshold: 100,
46
51
  pollInterval: 50