mdv-live 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.3] - 2026-03-29
9
+
10
+ ### Fixed
11
+
12
+ - Security: exec/execSync → execFile/process.kill でコマンドインジェクション防止(PDF生成・サーバーkill)
13
+ - Security: PIDバリデーション厳密化(数字のみ許可、部分一致を拒否)
14
+ - Range Requestのバリデーション追加(不正ヘッダで416、end超過はRFC準拠でclamp)
15
+ - ファイル監視の再描画でrelativeDirを渡すように修正(サブフォルダ内Markdownの画像パス解決)
16
+ - バージョン表示をpackage.jsonから動的取得に統一(CLI・サーバー・テスト全箇所)
17
+
18
+ ## [0.5.2] - 2026-03-27
19
+
20
+ ### Fixed
21
+
22
+ - CJK + Unicode句読点で太字・斜体が壊れる問題を修正
23
+
8
24
  ## [0.5.1] - 2026-03-20
9
25
 
10
26
  ### Fixed
package/bin/mdv.js CHANGED
@@ -5,10 +5,12 @@
5
5
  * Compatible with the original Python mdv-live CLI
6
6
  */
7
7
 
8
- import { execSync } from 'node:child_process';
8
+ import { execFileSync, execSync } from 'node:child_process';
9
+ import { readFileSync } from 'node:fs';
9
10
  import fs from 'node:fs/promises';
10
11
  import { createServer as createNetServer } from 'node:net';
11
12
  import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
12
14
  import { parseArgs } from 'node:util';
13
15
 
14
16
  import open from 'open';
@@ -184,12 +186,17 @@ function listServers() {
184
186
  function killServers(target, killAll) {
185
187
  if (target) {
186
188
  // Kill specific PID
189
+ if (!/^\d+$/.test(target)) {
190
+ console.log(`無効なPID: ${target}`);
191
+ return 1;
192
+ }
193
+ const pid = Number(target);
187
194
  try {
188
- execSync(`kill ${target}`, { encoding: 'utf-8' });
189
- console.log(`PID ${target} を停止しました`);
195
+ process.kill(pid);
196
+ console.log(`PID ${pid} を停止しました`);
190
197
  return 0;
191
198
  } catch {
192
- console.log(`PID ${target} の停止に失敗しました`);
199
+ console.log(`PID ${pid} の停止に失敗しました`);
193
200
  return 1;
194
201
  }
195
202
  }
@@ -214,7 +221,7 @@ function killServers(target, killAll) {
214
221
  let killed = 0;
215
222
  for (const proc of processes) {
216
223
  try {
217
- execSync(`kill ${proc.pid}`, { encoding: 'utf-8' });
224
+ process.kill(proc.pid);
218
225
  console.log(` PID ${proc.pid} (port ${proc.port}) を停止`);
219
226
  killed++;
220
227
  } catch {
@@ -279,7 +286,7 @@ async function convertToPdf(inputPath, outputPath) {
279
286
  */
280
287
  async function convertMarpToPdf(inputPath, outputPath) {
281
288
  try {
282
- execSync(`npx @marp-team/marp-cli --no-stdin "${inputPath}" --pdf --html --allow-local-files -o "${outputPath}"`, {
289
+ execFileSync('npx', ['@marp-team/marp-cli', '--no-stdin', inputPath, '--pdf', '--html', '--allow-local-files', '-o', outputPath], {
283
290
  encoding: 'utf-8',
284
291
  stdio: 'inherit'
285
292
  });
@@ -302,7 +309,7 @@ async function convertMarkdownToPdf(inputPath, outputPath) {
302
309
 
303
310
  try {
304
311
  const pdfOptions = '{"format":"A4","margin":{"top":"20mm","right":"20mm","bottom":"20mm","left":"20mm"}}';
305
- execSync(`npx md-to-pdf "${inputPath}" --pdf-options '${pdfOptions}'`, {
312
+ execFileSync('npx', ['md-to-pdf', inputPath, '--pdf-options', pdfOptions], {
306
313
  encoding: 'utf-8',
307
314
  stdio: 'inherit',
308
315
  cwd: path.dirname(inputPath)
@@ -453,7 +460,11 @@ async function main() {
453
460
  }
454
461
 
455
462
  if (values.version) {
456
- console.log('mdv v0.5.0');
463
+ const __cliDir = path.dirname(fileURLToPath(import.meta.url));
464
+ const { version } = JSON.parse(
465
+ readFileSync(path.join(__cliDir, '..', 'package.json'), 'utf-8')
466
+ );
467
+ console.log(`mdv v${version}`);
457
468
  process.exit(0);
458
469
  }
459
470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/src/api/file.js CHANGED
@@ -9,16 +9,16 @@ import mime from 'mime-types';
9
9
  import WebSocket from 'ws';
10
10
  import { getFileType } from '../utils/fileTypes.js';
11
11
  import { renderFile } from '../rendering/index.js';
12
- import { validatePath } from '../utils/path.js';
12
+ import { validatePathReal } from '../utils/path.js';
13
13
 
14
14
  /**
15
- * Validate path and resolve to full path
15
+ * Validate path and resolve to full path (with symlink protection)
16
16
  * @param {string} relativePath - Relative path to validate
17
17
  * @param {string} rootDir - Root directory
18
- * @returns {{ valid: boolean, fullPath: string }} Validation result with full path
18
+ * @returns {Promise<{ valid: boolean, fullPath: string }>} Validation result with full path
19
19
  */
20
- function resolveAndValidate(relativePath, rootDir) {
21
- if (!relativePath || !validatePath(relativePath, rootDir)) {
20
+ async function resolveAndValidate(relativePath, rootDir) {
21
+ if (!relativePath || !await validatePathReal(relativePath, rootDir)) {
22
22
  return { valid: false, fullPath: '' };
23
23
  }
24
24
  return { valid: true, fullPath: path.join(rootDir, relativePath) };
@@ -92,7 +92,7 @@ export function setupFileRoutes(app) {
92
92
  // Serve raw files (for HTML preview with relative paths)
93
93
  app.get('/raw/*', async (req, res) => {
94
94
  const relativePath = req.params[0];
95
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
95
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
96
96
 
97
97
  if (!relativePath || !valid) {
98
98
  return res.status(403).json({ error: 'Access denied' });
@@ -118,7 +118,7 @@ export function setupFileRoutes(app) {
118
118
  // Get file content
119
119
  app.get('/api/file', async (req, res) => {
120
120
  const { path: relativePath } = req.query;
121
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
121
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
122
122
 
123
123
  if (!relativePath) {
124
124
  return res.status(400).json({ error: 'Path is required' });
@@ -174,7 +174,7 @@ export function setupFileRoutes(app) {
174
174
  // Save file content
175
175
  app.post('/api/file', async (req, res) => {
176
176
  const { path: relativePath, content } = req.body;
177
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
177
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
178
178
 
179
179
  if (!relativePath) {
180
180
  return res.status(400).json({ error: 'Path is required' });
@@ -195,7 +195,7 @@ export function setupFileRoutes(app) {
195
195
  // Delete file or directory
196
196
  app.delete('/api/file', async (req, res) => {
197
197
  const { path: relativePath } = req.query;
198
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
198
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
199
199
 
200
200
  if (!relativePath) {
201
201
  return res.status(400).json({ error: 'Path is required' });
@@ -225,7 +225,7 @@ export function setupFileRoutes(app) {
225
225
  // Create directory
226
226
  app.post('/api/mkdir', async (req, res) => {
227
227
  const { path: relativePath } = req.body;
228
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
228
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
229
229
 
230
230
  if (!relativePath) {
231
231
  return res.status(400).json({ error: 'Path is required' });
@@ -251,8 +251,8 @@ export function setupFileRoutes(app) {
251
251
  return res.status(400).json({ error: 'Source and destination are required' });
252
252
  }
253
253
 
254
- const sourceResult = resolveAndValidate(source, rootDir);
255
- const destResult = resolveAndValidate(destination, rootDir);
254
+ const sourceResult = await resolveAndValidate(source, rootDir);
255
+ const destResult = await resolveAndValidate(destination, rootDir);
256
256
 
257
257
  if (!sourceResult.valid || !destResult.valid) {
258
258
  return res.status(403).json({ error: 'Access denied' });
@@ -270,7 +270,7 @@ export function setupFileRoutes(app) {
270
270
  // Download file (with Range Request support for video/audio streaming)
271
271
  app.get('/api/download', async (req, res) => {
272
272
  const { path: relativePath } = req.query;
273
- const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
273
+ const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
274
274
 
275
275
  if (!relativePath) {
276
276
  return res.status(400).json({ error: 'Path is required' });
@@ -292,9 +292,18 @@ export function setupFileRoutes(app) {
292
292
 
293
293
  // Range Request for video/audio streaming
294
294
  const fileSize = stat.size;
295
- const parts = rangeHeader.replace(/bytes=/, '').split('-');
296
- const start = parseInt(parts[0], 10);
297
- const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
295
+ const match = /^bytes=(\d+)-(\d+)?$/.exec(rangeHeader);
296
+ if (!match) {
297
+ return res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
298
+ }
299
+ const start = Number(match[1]);
300
+ if (start >= fileSize) {
301
+ return res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
302
+ }
303
+ const end = Math.min(match[2] ? Number(match[2]) : fileSize - 1, fileSize - 1);
304
+ if (end < start) {
305
+ return res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
306
+ }
298
307
  const chunkSize = end - start + 1;
299
308
  const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
300
309
 
package/src/api/pdf.js CHANGED
@@ -3,14 +3,15 @@
3
3
  * Uses marp-cli for Marp presentations
4
4
  */
5
5
 
6
- import { exec } from 'child_process';
6
+ import { execFile } from 'child_process';
7
7
  import { promisify } from 'util';
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import os from 'os';
11
12
  import { validatePath } from '../utils/path.js';
12
13
 
13
- const execAsync = promisify(exec);
14
+ const execFileAsync = promisify(execFile);
14
15
  const marpBin = path.join(
15
16
  path.dirname(fileURLToPath(import.meta.url)),
16
17
  '..',
@@ -47,23 +48,22 @@ export function setupPdfRoutes(app) {
47
48
  return res.status(404).json({ error: 'File not found' });
48
49
  }
49
50
 
50
- const outputPath = fullPath.replace(/\.md$/, '.pdf');
51
- const outputFileName = path.basename(outputPath);
52
- const command = `"${marpBin}" "${fullPath}" -o "${outputPath}" --html --allow-local-files --no-stdin`;
51
+ const baseName = path.basename(fullPath, '.md');
52
+ const outputPath = path.join(os.tmpdir(), `mdv-${Date.now()}-${baseName}.pdf`);
53
+ const outputFileName = `${baseName}.pdf`;
53
54
 
54
55
  try {
55
- await execAsync(command, { timeout: 60000 });
56
- res.download(outputPath, outputFileName, (err) => {
56
+ await execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: 60000 });
57
+ res.download(outputPath, outputFileName, async (err) => {
57
58
  if (err) {
58
59
  console.error('Download error:', err);
59
60
  }
61
+ try { await fs.unlink(outputPath); } catch { /* ignore cleanup errors */ }
60
62
  });
61
63
  } catch (err) {
62
64
  console.error('PDF export error:', err);
63
- res.status(500).json({
64
- error: 'PDF export failed',
65
- details: err.message
66
- });
65
+ try { await fs.unlink(outputPath); } catch { /* ignore */ }
66
+ res.status(500).json({ error: 'PDF export failed' });
67
67
  }
68
68
  });
69
69
  }
package/src/api/tree.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'fs/promises';
6
6
  import path from 'path';
7
7
  import { getFileType } from '../utils/fileTypes.js';
8
- import { getRelativePath, validatePath } from '../utils/path.js';
8
+ import { getRelativePath, validatePathReal } from '../utils/path.js';
9
9
 
10
10
  const IGNORED_PATTERNS = new Set(['node_modules', '__pycache__', '.git']);
11
11
  const MAX_INITIAL_DEPTH = 1;
@@ -100,13 +100,12 @@ export function setupTreeRoutes(app) {
100
100
  return res.status(400).json({ error: 'Path is required' });
101
101
  }
102
102
 
103
- const fullPath = path.join(app.locals.rootDir, relativePath);
104
-
105
- // Security: ensure path is within root
106
- if (!validatePath(relativePath, app.locals.rootDir)) {
103
+ // Security: validate before resolving path (with symlink check)
104
+ if (!await validatePathReal(relativePath, app.locals.rootDir)) {
107
105
  return res.status(403).json({ error: 'Access denied' });
108
106
  }
109
107
 
108
+ const fullPath = path.join(app.locals.rootDir, relativePath);
110
109
  const children = await buildFileTree(fullPath, app.locals.rootDir, 0);
111
110
  res.json(children);
112
111
  } catch (err) {
package/src/server.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import express from 'express';
7
+ import { readFileSync } from 'fs';
7
8
  import { createServer } from 'http';
8
9
  import path from 'path';
9
10
  import { fileURLToPath } from 'url';
@@ -17,7 +18,9 @@ import { setupWebSocket } from './websocket.js';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const STATIC_DIR = path.join(__dirname, 'static');
20
- const VERSION = '0.5.0';
21
+ const { version: VERSION } = JSON.parse(
22
+ readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8')
23
+ );
21
24
 
22
25
  /**
23
26
  * Setup API routes for the Express app
@@ -64,7 +67,9 @@ export function createMdvServer(options) {
64
67
 
65
68
  setupApiRoutes(app);
66
69
 
67
- app.get('/', (req, res) => {
70
+ // Catch-all: serve index.html for SPA (path-based routing)
71
+ // Express matches routes in order, so API/static routes above take priority
72
+ app.get('*', (req, res) => {
68
73
  res.sendFile(path.join(STATIC_DIR, 'index.html'));
69
74
  });
70
75
 
package/src/static/app.js CHANGED
@@ -93,14 +93,13 @@
93
93
  // ============================================================
94
94
 
95
95
  function updateUrlPath(path) {
96
- const url = new URL(window.location);
97
96
  if (path) {
98
- // パスの/をエンコードせずに表示
99
- url.search = '?path=' + encodeURIComponent(path).replace(/%2F/g, '/');
97
+ // パスベースURL: /README.md, /04_提案/10億円戦略.md
98
+ const encoded = path.split('/').map(s => encodeURIComponent(s)).join('/');
99
+ history.replaceState(null, '', '/' + encoded);
100
100
  } else {
101
- url.search = '';
101
+ history.replaceState(null, '', '/');
102
102
  }
103
- history.replaceState(null, '', url);
104
103
  }
105
104
 
106
105
  // ============================================================
@@ -430,7 +429,7 @@
430
429
 
431
430
  // 展開済みディレクトリを復元(子要素も再取得)
432
431
  for (const path of expandedPaths) {
433
- const item = document.querySelector(`.tree-item[data-path="${path}"]`);
432
+ const item = document.querySelector(`.tree-item[data-path="${CSS.escape(path)}"]`);
434
433
  if (item) {
435
434
  const children = item.querySelector('.tree-children');
436
435
  const chevron = item.querySelector('.chevron');
@@ -461,8 +460,10 @@
461
460
 
462
461
  renderDirectory(item) {
463
462
  const loaded = item.loaded !== false;
463
+ const safePath = escapeHtml(item.path);
464
+ const safeName = escapeHtml(item.name);
464
465
  return `
465
- <div class="tree-item" data-path="${item.path}" data-loaded="${loaded}" draggable="true">
466
+ <div class="tree-item" data-path="${safePath}" data-loaded="${loaded}" draggable="true">
466
467
  <div class="tree-item-content" onclick="MDV.toggleDirectory(this)">
467
468
  <svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
468
469
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
@@ -470,7 +471,7 @@
470
471
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
471
472
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
472
473
  </svg>
473
- <span class="name">${item.name}</span>
474
+ <span class="name">${safeName}</span>
474
475
  </div>
475
476
  <div class="tree-children collapsed">${this.renderItems(item.children)}</div>
476
477
  </div>
@@ -501,7 +502,7 @@
501
502
  for (const part of parts) {
502
503
  currentPath = currentPath ? `${currentPath}/${part}` : part;
503
504
 
504
- const item = document.querySelector(`.tree-item[data-path="${currentPath}"]`);
505
+ const item = document.querySelector(`.tree-item[data-path="${CSS.escape(currentPath)}"]`);
505
506
  if (!item) continue;
506
507
 
507
508
  const children = item.querySelector('.tree-children');
@@ -525,13 +526,15 @@
525
526
  renderFile(item) {
526
527
  const iconClass = item.icon ? `icon-${item.icon}` : '';
527
528
  const iconSvg = getFileIcon(item.icon);
529
+ const safePath = escapeHtml(item.path);
530
+ const safeName = escapeHtml(item.name);
528
531
  return `
529
- <div class="tree-item" data-path="${item.path}" draggable="true">
530
- <div class="tree-item-content" onclick="MDV.openFile('${item.path}')">
532
+ <div class="tree-item" data-path="${safePath}" draggable="true">
533
+ <div class="tree-item-content" data-action="open">
531
534
  <span class="${iconClass}" style="margin-left: 22px; display: flex; align-items: center;">
532
535
  ${iconSvg}
533
536
  </span>
534
- <span class="name">${item.name}</span>
537
+ <span class="name">${safeName}</span>
535
538
  </div>
536
539
  </div>
537
540
  `;
@@ -543,7 +546,7 @@
543
546
  });
544
547
  if (state.activeTabIndex >= 0) {
545
548
  const path = state.tabs[state.activeTabIndex].path;
546
- const el = document.querySelector(`.tree-item[data-path="${path}"] > .tree-item-content`);
549
+ const el = document.querySelector(`.tree-item[data-path="${CSS.escape(path)}"] > .tree-item-content`);
547
550
  if (el) el.classList.add('active');
548
551
  }
549
552
  }
@@ -857,29 +860,32 @@
857
860
 
858
861
  renderImage(imageUrl, name) {
859
862
  const url = imageUrl + '&t=' + Date.now();
863
+ const safeName = escapeHtml(name);
860
864
  elements.content.innerHTML = `
861
865
  <div class="image-preview">
862
- <img src="${url}" alt="${name}" />
863
- <div class="image-info">${name}</div>
866
+ <img src="${url}" alt="${safeName}" />
867
+ <div class="image-info">${safeName}</div>
864
868
  </div>
865
869
  `;
866
870
  },
867
871
 
868
872
  renderPDF(pdfUrl, name) {
869
873
  const url = pdfUrl + '&t=' + Date.now();
874
+ const safeName = escapeHtml(name);
870
875
  elements.content.style.padding = '0';
871
876
  elements.content.innerHTML = `
872
877
  <div class="pdf-viewer">
873
- <iframe src="${url}" title="${name}"></iframe>
878
+ <iframe src="${url}" title="${safeName}"></iframe>
874
879
  </div>
875
880
  `;
876
881
  },
877
882
 
878
883
  renderHTML(htmlUrl, name) {
884
+ const safeName = escapeHtml(name);
879
885
  elements.content.style.padding = '0';
880
886
  elements.content.innerHTML = `
881
887
  <div class="html-preview">
882
- <iframe src="${htmlUrl}" title="${name}"
888
+ <iframe src="${htmlUrl}" title="${safeName}"
883
889
  sandbox="allow-scripts allow-same-origin allow-forms allow-modals">
884
890
  </iframe>
885
891
  </div>
@@ -887,35 +893,38 @@
887
893
  },
888
894
 
889
895
  renderVideo(mediaUrl, name) {
896
+ const safeName = escapeHtml(name);
890
897
  elements.content.innerHTML = `
891
898
  <div class="video-preview">
892
899
  <video controls>
893
900
  <source src="${mediaUrl}" type="video/mp4">
894
901
  お使いのブラウザは動画再生に対応していません。
895
902
  </video>
896
- <div class="media-info">${name}</div>
903
+ <div class="media-info">${safeName}</div>
897
904
  </div>
898
905
  `;
899
906
  },
900
907
 
901
908
  renderAudio(mediaUrl, name) {
909
+ const safeName = escapeHtml(name);
902
910
  elements.content.innerHTML = `
903
911
  <div class="audio-preview">
904
912
  <audio controls>
905
913
  <source src="${mediaUrl}">
906
914
  お使いのブラウザは音声再生に対応していません。
907
915
  </audio>
908
- <div class="media-info">${name}</div>
916
+ <div class="media-info">${safeName}</div>
909
917
  </div>
910
918
  `;
911
919
  },
912
920
 
913
921
  renderBinary(name, icon) {
922
+ const safeName = escapeHtml(name);
914
923
  const iconSvg = getFileIcon(icon);
915
924
  elements.content.innerHTML = `
916
925
  <div class="binary-preview">
917
926
  <div class="binary-icon">${iconSvg}</div>
918
- <div class="binary-info">${name}</div>
927
+ <div class="binary-info">${safeName}</div>
919
928
  </div>
920
929
  `;
921
930
  },
@@ -1015,6 +1024,22 @@
1015
1024
  },
1016
1025
 
1017
1026
  close(index) {
1027
+ // Warn about unsaved changes
1028
+ if (state.isEditMode && state.hasUnsavedChanges && index === state.activeTabIndex) {
1029
+ DialogManager.show('未保存の変更', {
1030
+ message: '変更を保存せずにタブを閉じますか?',
1031
+ isConfirm: true,
1032
+ danger: true,
1033
+ confirmText: '閉じる',
1034
+ onConfirm: () => {
1035
+ state.hasUnsavedChanges = false;
1036
+ state.isEditMode = false;
1037
+ EditorManager.updateButton();
1038
+ TabManager.close(index);
1039
+ }
1040
+ });
1041
+ return;
1042
+ }
1018
1043
  state.tabs.splice(index, 1);
1019
1044
 
1020
1045
  if (state.tabs.length === 0) {
@@ -1040,7 +1065,7 @@
1040
1065
  render() {
1041
1066
  elements.tabBar.innerHTML = state.tabs.map((tab, i) => `
1042
1067
  <button class="tab ${i === state.activeTabIndex ? 'active' : ''}" onclick="MDV.switchTab(${i})">
1043
- ${tab.name}
1068
+ ${escapeHtml(tab.name)}
1044
1069
  <span class="tab-close" onclick="event.stopPropagation(); MDV.closeTab(${i})">
1045
1070
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1046
1071
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -1885,7 +1910,7 @@
1885
1910
  const isTextInput = document.activeElement.tagName === 'INPUT' ||
1886
1911
  document.activeElement.tagName === 'TEXTAREA';
1887
1912
 
1888
- const activeItem = document.querySelector(`.tree-item[data-path="${this.selectedTreePath}"]`);
1913
+ const activeItem = document.querySelector(`.tree-item[data-path="${CSS.escape(this.selectedTreePath)}"]`);
1889
1914
  const isDir = activeItem && !!activeItem.querySelector('.tree-children');
1890
1915
 
1891
1916
  if ((key === 'Delete' || key === 'Backspace') && !isTextInput) {
@@ -1918,6 +1943,14 @@
1918
1943
  if (treeItem) {
1919
1944
  this.selectedTreePath = treeItem.dataset.path;
1920
1945
  }
1946
+ // Event delegation for file open (replaces inline onclick)
1947
+ const openTarget = e.target.closest('[data-action="open"]');
1948
+ if (openTarget) {
1949
+ const item = openTarget.closest('.tree-item');
1950
+ if (item && item.dataset.path) {
1951
+ TabManager.open(item.dataset.path);
1952
+ }
1953
+ }
1921
1954
  });
1922
1955
  }
1923
1956
  };
@@ -1997,6 +2030,14 @@
1997
2030
  ContextMenuManager.init();
1998
2031
  DragDropManager.init();
1999
2032
  KeyboardManager.init();
2033
+
2034
+ // Warn before leaving with unsaved changes
2035
+ window.addEventListener('beforeunload', (e) => {
2036
+ if (state.isEditMode && state.hasUnsavedChanges) {
2037
+ e.preventDefault();
2038
+ e.returnValue = '';
2039
+ }
2040
+ });
2000
2041
  TabManager.render();
2001
2042
 
2002
2043
  try {
@@ -2017,19 +2058,83 @@
2017
2058
  });
2018
2059
  window.addEventListener('focus', handleFocusChange);
2019
2060
 
2020
- const initialPath = new URLSearchParams(window.location.search).get('path');
2061
+ // パスベースURL: /README.md → path = "README.md"
2062
+ // ?path= も後方互換で対応
2063
+ let initialPath = decodeURIComponent(window.location.pathname).replace(/^\//, '');
2064
+ if (!initialPath) {
2065
+ initialPath = new URLSearchParams(window.location.search).get('path') || '';
2066
+ }
2021
2067
  if (initialPath) {
2022
- // 末尾の/でディレクトリ判定
2023
2068
  const isDirectoryPath = initialPath.endsWith('/');
2024
2069
  const cleanPath = isDirectoryPath ? initialPath.slice(0, -1) : initialPath;
2025
2070
 
2026
2071
  await FileTreeManager.expandToPath(cleanPath);
2027
2072
 
2028
- // ファイルの場合のみ開く
2029
2073
  if (!isDirectoryPath) {
2030
2074
  await TabManager.open(cleanPath);
2031
2075
  }
2032
2076
  }
2077
+
2078
+ // Markdown内リンクのクリックをインターセプト
2079
+ elements.content.addEventListener('click', (e) => {
2080
+ const link = e.target.closest('a[href]');
2081
+ if (!link) return;
2082
+
2083
+ const href = link.getAttribute('href');
2084
+ if (!href) return;
2085
+
2086
+ // 外部リンク・非HTTPスキーム・アンカーはブラウザに任せる
2087
+ if (href.startsWith('#') || href.startsWith('http') || href.startsWith('//') ||
2088
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return;
2089
+
2090
+ e.preventDefault();
2091
+
2092
+ // フラグメントを保持しつつパスを取り出す
2093
+ const hashIndex = href.indexOf('#');
2094
+ const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : '';
2095
+ const urlPath = (hashIndex >= 0 ? href.slice(0, hashIndex) : href).split('?')[0];
2096
+ const decoded = decodeURIComponent(urlPath);
2097
+
2098
+ // 相対パスを現在のファイルパスから解決
2099
+ let targetPath = decoded;
2100
+ if (!decoded.startsWith('/')) {
2101
+ const currentTab = state.tabs[state.activeTabIndex];
2102
+ const currentDir = currentTab ? currentTab.path.replace(/[^/]*$/, '') : '';
2103
+ targetPath = currentDir + decoded;
2104
+ } else {
2105
+ targetPath = decoded.replace(/^\//, '');
2106
+ }
2107
+
2108
+ // 末尾スラッシュ(ディレクトリ)を保持
2109
+ const isDirectory = targetPath.endsWith('/');
2110
+
2111
+ // パス正規化(foo/../bar → bar)
2112
+ const parts = targetPath.split('/');
2113
+ const resolved = [];
2114
+ for (const part of parts) {
2115
+ if (part === '..') resolved.pop();
2116
+ else if (part !== '.' && part !== '') resolved.push(part);
2117
+ }
2118
+ targetPath = resolved.join('/');
2119
+
2120
+ if (isDirectory) {
2121
+ // ディレクトリはツリーを展開
2122
+ FileTreeManager.expandToPath(targetPath);
2123
+ updateUrlPath(targetPath + '/');
2124
+ } else {
2125
+ TabManager.open(targetPath).then(() => {
2126
+ // フラグメントがあればアンカーにスクロール
2127
+ if (fragment) {
2128
+ const decodedFragment = decodeURIComponent(fragment);
2129
+ // id一致 → heading textContent一致 の順で検索
2130
+ const target = elements.content.querySelector(`#${CSS.escape(decodedFragment)}`) ||
2131
+ Array.from(elements.content.querySelectorAll('h1, h2, h3, h4, h5, h6'))
2132
+ .find(h => h.textContent.trim().toLowerCase() === decodedFragment.toLowerCase());
2133
+ if (target) target.scrollIntoView({ behavior: 'smooth' });
2134
+ }
2135
+ });
2136
+ }
2137
+ });
2033
2138
  }
2034
2139
 
2035
2140
  // DOMContentLoadedを待ってから初期化
package/src/utils/path.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import path from 'path';
6
+ import fs from 'fs/promises';
6
7
 
7
8
  /**
8
9
  * Validate that a path is within the allowed root directory.
@@ -34,6 +35,44 @@ export function validatePath(targetPath, rootDir) {
34
35
  return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
35
36
  }
36
37
 
38
+ /**
39
+ * Validate path with symlink resolution.
40
+ * Calls validatePath first, then verifies the real filesystem path stays within rootDir.
41
+ * @param {string} targetPath - Relative path to validate
42
+ * @param {string} rootDir - Allowed root directory
43
+ * @returns {Promise<boolean>} True if path is safe after symlink resolution
44
+ */
45
+ export async function validatePathReal(targetPath, rootDir) {
46
+ if (!validatePath(targetPath, rootDir)) {
47
+ return false;
48
+ }
49
+
50
+ const fullPath = path.resolve(rootDir, targetPath);
51
+ try {
52
+ const realPath = await fs.realpath(fullPath);
53
+ const realRoot = await fs.realpath(rootDir);
54
+ return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
55
+ } catch (err) {
56
+ if (err.code === 'ENOENT') {
57
+ // File/dir doesn't exist yet — walk up to find nearest existing ancestor
58
+ const realRoot = await fs.realpath(rootDir);
59
+ let current = fullPath;
60
+ while (current !== path.dirname(current)) {
61
+ current = path.dirname(current);
62
+ try {
63
+ const realAncestor = await fs.realpath(current);
64
+ return realAncestor === realRoot || realAncestor.startsWith(realRoot + path.sep);
65
+ } catch (e) {
66
+ if (e.code !== 'ENOENT') return false;
67
+ // Keep walking up
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+ return false;
73
+ }
74
+ }
75
+
37
76
  /**
38
77
  * Convert absolute path to relative path with forward slashes
39
78
  * @param {string} fullPath - Absolute file path
package/src/watcher.js CHANGED
@@ -65,9 +65,10 @@ export function setupWatcher(rootDir, wss, options = {}) {
65
65
 
66
66
  watcher.on('change', async (filePath) => {
67
67
  const relativePath = toRelativePath(filePath);
68
+ const relativeDir = path.dirname(relativePath);
68
69
 
69
70
  try {
70
- const rendered = await renderFile(filePath);
71
+ const rendered = await renderFile(filePath, relativeDir === '.' ? '' : relativeDir);
71
72
  wss.broadcastFileUpdate(relativePath, {
72
73
  type: 'file_update',
73
74
  path: relativePath,
package/src/websocket.js CHANGED
@@ -19,7 +19,7 @@ function isClientReady(client) {
19
19
  * @returns {WebSocketServer} WebSocket server instance
20
20
  */
21
21
  export function setupWebSocket(server) {
22
- const wss = new WebSocketServer({ server });
22
+ const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 });
23
23
  const clientWatches = new Map();
24
24
 
25
25
  wss.on('connection', (ws) => {
@@ -30,6 +30,7 @@ export function setupWebSocket(server) {
30
30
  const message = JSON.parse(data.toString());
31
31
 
32
32
  if (message.type === 'watch') {
33
+ if (typeof message.path !== 'string' || message.path.length > 1024) return;
33
34
  const watches = clientWatches.get(ws);
34
35
  watches.clear();
35
36
  watches.add(message.path);