mdv-live 0.5.1 → 0.5.3

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.1",
3
+ "version": "0.5.3",
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
@@ -292,9 +292,15 @@ 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);
298
304
  const chunkSize = end - start + 1;
299
305
  const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
300
306
 
package/src/api/pdf.js CHANGED
@@ -3,14 +3,14 @@
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
11
  import { validatePath } from '../utils/path.js';
12
12
 
13
- const execAsync = promisify(exec);
13
+ const execFileAsync = promisify(execFile);
14
14
  const marpBin = path.join(
15
15
  path.dirname(fileURLToPath(import.meta.url)),
16
16
  '..',
@@ -49,10 +49,9 @@ export function setupPdfRoutes(app) {
49
49
 
50
50
  const outputPath = fullPath.replace(/\.md$/, '.pdf');
51
51
  const outputFileName = path.basename(outputPath);
52
- const command = `"${marpBin}" "${fullPath}" -o "${outputPath}" --html --allow-local-files --no-stdin`;
53
52
 
54
53
  try {
55
- await execAsync(command, { timeout: 60000 });
54
+ await execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: 60000 });
56
55
  res.download(outputPath, outputFileName, (err) => {
57
56
  if (err) {
58
57
  console.error('Download error:', err);
@@ -5,6 +5,73 @@
5
5
  import MarkdownIt from 'markdown-it';
6
6
  import taskLists from 'markdown-it-task-lists';
7
7
 
8
+ /**
9
+ * markdown-it plugin: CJK + Unicode句読点で emphasis が壊れる問題を修正。
10
+ *
11
+ * 根本原因: CommonMark の flanking delimiter 判定は、delimiter の隣が
12
+ * Unicode句読点のとき、反対側も空白か句読点でないと flanking と認めない。
13
+ * right_flanking = !isLastWS && (!isLastPunct || isNextWS || isNextPunct)
14
+ * left_flanking = !isNextWS && (!isNextPunct || isLastWS || isLastPunct)
15
+ *
16
+ * ラテン文字圏では妥当だが、CJK文字(漢字・ひらがな・カタカナ)は
17
+ * 空白でも句読点でもないため、「)**を」のような配置で flanking 判定が
18
+ * 不当に失敗する。
19
+ *
20
+ * 修正方針: flanking 判定の条件式に「反対側がCJKテキスト文字なら、
21
+ * 句読点の隣接制限を免除する」条件を追加する。
22
+ * CJKは語境界を空白で示さないため、句読点の隣にCJKがあっても
23
+ * delimiter は flanking と見なすのが自然。
24
+ *
25
+ * isWhiteSpace / isPunct の分類は変えない。flanking 条件式だけを拡張する。
26
+ */
27
+ function cjkEmphasisFix(md) {
28
+ const StateInline = md.inline.State;
29
+ const origScanDelims = StateInline.prototype.scanDelims;
30
+
31
+ // CJKテキスト文字(句読点・記号は含めない)
32
+ // Hiragana, Katakana, CJK Unified Ideographs, CJK Ext-A,
33
+ // Hangul Syllables, CJK Compatibility Ideographs
34
+ const CJK_TEXT_RE = /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/;
35
+
36
+ StateInline.prototype.scanDelims = function (start, canSplitWord) {
37
+ const result = origScanDelims.call(this, start, canSplitWord);
38
+
39
+ // 元の判定で OK なら何もしない
40
+ if (result.can_open && result.can_close) return result;
41
+
42
+ // delimiter の前後の文字を取得
43
+ const max = this.posMax;
44
+ const marker = this.src.charCodeAt(start);
45
+ let pos = start;
46
+ while (pos < max && this.src.charCodeAt(pos) === marker) { pos++; }
47
+
48
+ const lastChar = start > 0 ? this.src.charAt(start - 1) : '';
49
+ const nextChar = pos < max ? this.src.charAt(pos) : '';
50
+
51
+ const lastIsCJK = CJK_TEXT_RE.test(lastChar);
52
+ const nextIsCJK = CJK_TEXT_RE.test(nextChar);
53
+
54
+ // CJKテキスト文字が delimiter の反対側にあるなら、
55
+ // 句読点隣接による flanking 拒否を解除する。
56
+ //
57
+ // can_close が false になるケース:
58
+ // lastChar=句読点, nextChar=CJK → right_flanking が false
59
+ // → nextIsCJK なら can_close = true に補正
60
+ //
61
+ // can_open が false になるケース:
62
+ // lastChar=CJK, nextChar=句読点 → left_flanking が false
63
+ // → lastIsCJK なら can_open = true に補正
64
+ if (!result.can_close && nextIsCJK) {
65
+ result.can_close = true;
66
+ }
67
+ if (!result.can_open && lastIsCJK) {
68
+ result.can_open = true;
69
+ }
70
+
71
+ return result;
72
+ };
73
+ }
74
+
8
75
  // Initialize markdown-it with options
9
76
  const md = new MarkdownIt({
10
77
  html: true,
@@ -13,6 +80,9 @@ const md = new MarkdownIt({
13
80
  linkify: true
14
81
  });
15
82
 
83
+ // Fix CJK emphasis issues before other plugins
84
+ md.use(cjkEmphasisFix);
85
+
16
86
  // Enable tables and strikethrough
17
87
  md.enable('table');
18
88
  md.enable('strikethrough');
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
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,