mdv-live 0.5.8 → 0.5.10

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,62 @@ 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.10] - 2026-05-09
9
+
10
+ ### Fixed (UX revert)
11
+
12
+ - **Markdown PDF ボタンを OS 印刷ダイアログに戻す** (本来の UX 復元):
13
+ - 0.5.9 (実体は 2026-01-31 `e5526f9` から) で plain Markdown も server-side
14
+ md-to-pdf 経由の PDF DL に切り替わっていたが、本来の UX は `Cmd+P` 相当の
15
+ OS 印刷ダイアログ (`window.print()`) で「PDF として保存」を選ぶフロー
16
+ - `src/static/app.js`: `print()` の markdown 分岐を削除 (`else` 分岐の
17
+ `browserPrint()` に落ちる)
18
+ - Marp / HTML preview の挙動は変更なし (server-side marp-cli を維持)
19
+
20
+ ### Fixed (codex review)
21
+
22
+ - `/api/pdf/export` の `fs.readFile` を `try/catch` 外で実行していた問題を修正
23
+ (directory 指定や読み取り不可ファイルで Express デフォルトエラーに落ちて
24
+ controlled JSON が返らなかった)。stat による file 判定を追加
25
+
26
+ ### Removed
27
+
28
+ - `PdfStyleManager` UI モジュール (markdown が server PDF を使わなくなったため
29
+ orphan)、`pdfStyleToggle` / `pdfStylePanel` HTML、関連 CSS、`normalizeUserPath`
30
+ helper、`pdf-style-preview` クラス
31
+ - `md-to-pdf` runtime dependency。web UI で使わなくなったため削除。
32
+ `bin/mdv.js convert` は元々 `npx md-to-pdf` 経由で 0.5.9 以前と同じ挙動
33
+ - `/api/pdf/export` の markdown 分岐 (Marp 専用エンドポイントに整理)。
34
+ 非 Marp ファイルは 415 で拒否
35
+
36
+ ### Tests
37
+
38
+ - 241 → **242 件 (+1)**、全 PASS
39
+ - 追加: directory path → 404 controlled JSON (codex round 3 regression)
40
+ - 変更: plain markdown PDF テスト → 415 テストに置換
41
+
42
+ ## [0.5.9] - 2026-05-09
43
+
44
+ ### Fixed
45
+
46
+ - **PDF export hang for plain Markdown** (regression since 2026-01-31 `e5526f9`):
47
+ - 原因 1: `md-to-pdf` が `package.json` に未宣言だったため `npx md-to-pdf` 経路に
48
+ 依存。npx キャッシュ / TTY / レジストリ状況により挙動が不安定
49
+ - 原因 2: `child_process.execFile` は `stdio` オプションを受け付けない (Node 仕様)。
50
+ `md-to-pdf` 内部の `get-stdin` が EOF を待ち続け 180s SIGTERM していた
51
+ - 直し方: `md-to-pdf` を `dependencies` に追加 / `npx` 経由をやめて
52
+ `node_modules/.bin/md-to-pdf` を直接 spawn / `stdio: ['ignore', ...]` で stdin 即 EOF
53
+ - Marp 経路 (`marp-cli`) も同じ helper (`runPdfTool`) に統一して防御
54
+
55
+ ### Added
56
+
57
+ - `tests/test-pdf-export.js`: PDF export 経路の自動テスト 5 件 (400 / 403 / 404 / plain
58
+ PDF / Marp PDF)。リグレッション再発防止
59
+
60
+ ### Tests
61
+
62
+ - 236 → **241 件 (+5)**、全 PASS
63
+
8
64
  ## [0.5.8] - 2026-05-08
9
65
 
10
66
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -45,16 +45,16 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "dependencies": {
48
- "express": "^4.21.2",
49
- "ws": "^8.18.0",
48
+ "@marp-team/marp-core": "^4.0.0",
50
49
  "chokidar": "^4.0.3",
50
+ "express": "^4.21.2",
51
+ "highlight.js": "^11.10.0",
51
52
  "markdown-it": "^14.1.0",
52
53
  "markdown-it-task-lists": "^2.1.1",
53
- "@marp-team/marp-core": "^4.0.0",
54
- "highlight.js": "^11.10.0",
55
- "open": "^10.1.0",
56
54
  "mime-types": "^2.1.35",
57
- "multer": "^1.4.5-lts.1"
55
+ "multer": "^1.4.5-lts.1",
56
+ "open": "^10.1.0",
57
+ "ws": "^8.18.0"
58
58
  },
59
59
  "optionalDependencies": {
60
60
  "@marp-team/marp-cli": "^4.0.3"
package/src/api/pdf.js CHANGED
@@ -1,17 +1,11 @@
1
- import { execFile } from 'child_process';
2
- import { promisify } from 'util';
1
+ import { spawn } from 'child_process';
3
2
  import fs from 'fs/promises';
4
3
  import path from 'path';
5
4
  import { fileURLToPath } from 'url';
6
5
  import os from 'os';
7
- import { createRequire } from 'module';
8
6
  import { isMarp } from '../rendering/markdown.js';
9
- import { validatePath, validatePathReal } from '../utils/path.js';
10
- import { resolvePdfOptions } from '../styles/index.js';
7
+ import { validatePath } from '../utils/path.js';
11
8
 
12
- const execFileAsync = promisify(execFile);
13
- const require = createRequire(import.meta.url);
14
- const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
15
9
  const marpBin = path.join(
16
10
  path.dirname(fileURLToPath(import.meta.url)),
17
11
  '..',
@@ -23,53 +17,46 @@ const marpBin = path.join(
23
17
  const PDF_EXPORT_TIMEOUT_MS = 180000;
24
18
 
25
19
  /**
26
- * Resolve an optional user-selected file path under the server root.
27
- * @param {string | undefined} relativePath - Path supplied by the web UI.
28
- * @param {string} rootDir - Server root directory.
29
- * @returns {Promise<string | null>} Absolute path or null.
20
+ * Spawn marp-cli with stdin closed.
21
+ *
22
+ * 注意: execFile stdio オプションを受け付けない (Node 仕様) ため spawn を使う。
30
23
  */
31
- async function resolveOptionalUserFile(relativePath, rootDir) {
32
- if (!relativePath) return null;
33
- if (!await validatePathReal(relativePath, rootDir)) {
34
- throw new Error(`Access denied: ${relativePath}`);
35
- }
36
- const fullPath = path.join(rootDir, relativePath);
37
- const stat = await fs.stat(fullPath);
38
- if (!stat.isFile()) {
39
- throw new Error(`Not a file: ${relativePath}`);
40
- }
41
- return fullPath;
42
- }
43
-
44
- /**
45
- * Export a regular markdown document with md-to-pdf.
46
- * @param {string} inputPath - Source markdown file.
47
- * @param {string} outputPath - Temporary output path.
48
- * @param {string | null} stylesheetPath - Optional custom CSS file.
49
- * @param {string | null} pdfOptionsPath - Optional PDF options JSON file.
50
- * @returns {Promise<void>}
51
- */
52
- async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
53
- const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
54
- const args = ['md-to-pdf', inputPath, '--pdf-options', JSON.stringify(pdfOptions)];
55
-
56
- if (stylesheetPath) {
57
- args.push('--stylesheet', highlightStylesheet);
58
- args.push('--stylesheet', stylesheetPath);
59
- args.push('--highlight-style', 'atom-one-dark');
60
- }
61
-
62
- await execFileAsync('npx', args, {
63
- cwd: path.dirname(inputPath),
64
- timeout: PDF_EXPORT_TIMEOUT_MS,
24
+ function runMarp(args) {
25
+ return new Promise((resolve, reject) => {
26
+ const child = spawn(marpBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
27
+ let stderr = '';
28
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
29
+
30
+ const timer = setTimeout(() => {
31
+ child.kill('SIGTERM');
32
+ }, PDF_EXPORT_TIMEOUT_MS);
33
+
34
+ child.on('error', (err) => {
35
+ clearTimeout(timer);
36
+ reject(err);
37
+ });
38
+ child.on('close', (code, signal) => {
39
+ clearTimeout(timer);
40
+ if (code === 0) {
41
+ resolve();
42
+ } else {
43
+ const err = new Error(`marp exited with code=${code} signal=${signal}`);
44
+ err.code = code;
45
+ err.signal = signal;
46
+ err.stderr = stderr;
47
+ reject(err);
48
+ }
49
+ });
65
50
  });
66
-
67
- const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
68
- await fs.rename(generatedPdf, outputPath);
69
51
  }
70
52
 
71
53
  /**
72
- * Setup PDF export routes
54
+ * Setup PDF export routes.
55
+ *
56
+ * Web UI からは Marp ファイルのみがこの経路を使う。通常 Markdown は
57
+ * クライアント側で window.print() (OS 印刷ダイアログ) に流す設計のため、
58
+ * このエンドポイントは Marp 以外を 415 で拒否する。
59
+ *
73
60
  * @param {Express} app - Express application
74
61
  * @returns {void}
75
62
  */
@@ -77,7 +64,7 @@ export function setupPdfRoutes(app) {
77
64
  const { rootDir } = app.locals;
78
65
 
79
66
  app.post('/api/pdf/export', async (req, res) => {
80
- const { filePath, stylePath, pdfOptionsPath } = req.body;
67
+ const { filePath } = req.body;
81
68
 
82
69
  if (!filePath) {
83
70
  return res.status(400).json({ error: 'filePath is required' });
@@ -88,29 +75,28 @@ export function setupPdfRoutes(app) {
88
75
  }
89
76
 
90
77
  const fullPath = path.join(rootDir, filePath);
91
-
92
- try {
93
- await fs.access(fullPath);
94
- } catch {
95
- return res.status(404).json({ error: 'File not found' });
96
- }
97
-
98
78
  const baseName = path.basename(fullPath, '.md');
99
79
  const outputPath = path.join(os.tmpdir(), `mdv-${Date.now()}-${baseName}.pdf`);
100
80
  const outputFileName = `${baseName}.pdf`;
101
81
 
102
82
  try {
83
+ let stat;
84
+ try {
85
+ stat = await fs.stat(fullPath);
86
+ } catch {
87
+ return res.status(404).json({ error: 'File not found' });
88
+ }
89
+ if (!stat.isFile()) {
90
+ return res.status(404).json({ error: 'File not found' });
91
+ }
92
+
103
93
  const content = await fs.readFile(fullPath, 'utf-8');
104
- if (isMarp(content)) {
105
- await execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: PDF_EXPORT_TIMEOUT_MS });
106
- } else {
107
- const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
108
- resolveOptionalUserFile(stylePath, rootDir),
109
- resolveOptionalUserFile(pdfOptionsPath, rootDir),
110
- ]);
111
- await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
94
+ if (!isMarp(content)) {
95
+ return res.status(415).json({ error: 'Server-side PDF export supports Marp files only. Use the browser print dialog for regular Markdown.' });
112
96
  }
113
97
 
98
+ await runMarp([fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
99
+
114
100
  res.download(outputPath, outputFileName, async (err) => {
115
101
  if (err) {
116
102
  console.error('Download error:', err);
package/src/static/app.js CHANGED
@@ -11,9 +11,7 @@
11
11
 
12
12
  const STORAGE_KEYS = {
13
13
  THEME: 'mdv-theme',
14
- SIDEBAR_WIDTH: 'mdv-sidebar-width',
15
- PDF_STYLE_PATH: 'mdv-pdf-style-path',
16
- PDF_OPTIONS_PATH: 'mdv-pdf-options-path'
14
+ SIDEBAR_WIDTH: 'mdv-sidebar-width'
17
15
  };
18
16
 
19
17
  const HLJS_THEMES = {
@@ -87,9 +85,7 @@
87
85
  isResizing: false,
88
86
  skipScrollRestore: false,
89
87
  uploadTargetPath: '',
90
- rootPath: '',
91
- pdfStylePath: localStorage.getItem(STORAGE_KEYS.PDF_STYLE_PATH) || '',
92
- pdfOptionsPath: localStorage.getItem(STORAGE_KEYS.PDF_OPTIONS_PATH) || ''
88
+ rootPath: ''
93
89
  };
94
90
 
95
91
  // ============================================================
@@ -125,12 +121,6 @@
125
121
  statusText: document.getElementById('statusText'),
126
122
  resizeHandle: document.getElementById('resizeHandle'),
127
123
  editToggle: document.getElementById('editToggle'),
128
- pdfStyleToggle: document.getElementById('pdfStyleToggle'),
129
- pdfStylePanel: document.getElementById('pdfStylePanel'),
130
- pdfStylePath: document.getElementById('pdfStylePath'),
131
- pdfOptionsPath: document.getElementById('pdfOptionsPath'),
132
- pdfStyleApply: document.getElementById('pdfStyleApply'),
133
- pdfStyleClear: document.getElementById('pdfStyleClear'),
134
124
  editLabel: document.getElementById('editLabel'),
135
125
  editorStatus: document.getElementById('editorStatus'),
136
126
  shutdownBtn: document.getElementById('shutdownBtn'),
@@ -173,10 +163,6 @@
173
163
  });
174
164
  }
175
165
 
176
- function normalizeUserPath(path) {
177
- return path.trim().replace(/^\/+/, '');
178
- }
179
-
180
166
  async function apiRequest(url, options = {}) {
181
167
  const response = await fetch(url, options);
182
168
  const data = await response.json();
@@ -249,101 +235,6 @@
249
235
  }
250
236
  };
251
237
 
252
- // ============================================================
253
- // PDF Style Preview
254
- // ============================================================
255
-
256
- const PdfStyleManager = {
257
- scopedCssId: 'pdf-style-preview-css',
258
-
259
- init() {
260
- elements.pdfStylePath.value = state.pdfStylePath;
261
- elements.pdfOptionsPath.value = state.pdfOptionsPath;
262
- elements.pdfStyleToggle.addEventListener('click', () => {
263
- elements.pdfStylePanel.classList.toggle('hidden');
264
- });
265
- elements.pdfStyleApply.addEventListener('click', () => this.applyFromInputs());
266
- elements.pdfStyleClear.addEventListener('click', () => this.clear());
267
- elements.pdfStylePath.addEventListener('keydown', (event) => {
268
- if (event.key === 'Enter') this.applyFromInputs();
269
- });
270
- elements.pdfOptionsPath.addEventListener('keydown', (event) => {
271
- if (event.key === 'Enter') this.applyFromInputs();
272
- });
273
- this.loadPreviewCss();
274
- },
275
-
276
- getExportOptions() {
277
- return {
278
- stylePath: normalizeUserPath(state.pdfStylePath),
279
- pdfOptionsPath: normalizeUserPath(state.pdfOptionsPath)
280
- };
281
- },
282
-
283
- async applyFromInputs() {
284
- state.pdfStylePath = normalizeUserPath(elements.pdfStylePath.value);
285
- state.pdfOptionsPath = normalizeUserPath(elements.pdfOptionsPath.value);
286
- elements.pdfStylePath.value = state.pdfStylePath;
287
- elements.pdfOptionsPath.value = state.pdfOptionsPath;
288
- localStorage.setItem(STORAGE_KEYS.PDF_STYLE_PATH, state.pdfStylePath);
289
- localStorage.setItem(STORAGE_KEYS.PDF_OPTIONS_PATH, state.pdfOptionsPath);
290
- await this.loadPreviewCss();
291
- TabManager.renderActive();
292
- },
293
-
294
- clear() {
295
- state.pdfStylePath = '';
296
- state.pdfOptionsPath = '';
297
- elements.pdfStylePath.value = '';
298
- elements.pdfOptionsPath.value = '';
299
- localStorage.removeItem(STORAGE_KEYS.PDF_STYLE_PATH);
300
- localStorage.removeItem(STORAGE_KEYS.PDF_OPTIONS_PATH);
301
- const oldStyle = document.getElementById(this.scopedCssId);
302
- if (oldStyle) oldStyle.remove();
303
- TabManager.renderActive();
304
- elements.statusText.textContent = 'PDF style cleared';
305
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
306
- },
307
-
308
- async loadPreviewCss() {
309
- const oldStyle = document.getElementById(this.scopedCssId);
310
- if (oldStyle) oldStyle.remove();
311
- if (!state.pdfStylePath) return;
312
-
313
- try {
314
- const response = await fetch(`/raw/${state.pdfStylePath}`);
315
- if (!response.ok) throw new Error('CSS file not found');
316
- const cssText = await response.text();
317
- const style = document.createElement('style');
318
- style.id = this.scopedCssId;
319
- style.textContent = this.scopeCss(cssText);
320
- document.head.appendChild(style);
321
- elements.statusText.textContent = 'PDF style applied';
322
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
323
- } catch (error) {
324
- console.error('PDF style preview error:', error);
325
- elements.statusText.textContent = 'PDF style failed';
326
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 2500);
327
- }
328
- },
329
-
330
- scopeCss(cssText) {
331
- const scope = '.markdown-body.pdf-style-preview';
332
- const withoutComments = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
333
- return withoutComments.replace(/([^{}]+)\{/g, (match, selectorText) => {
334
- const selectors = selectorText.trim();
335
- if (!selectors || selectors.startsWith('@')) return match;
336
- const scopedSelectors = selectors.split(',').map((selector) => {
337
- const trimmed = selector.trim();
338
- if (trimmed === ':root' || trimmed === 'body') return scope;
339
- if (trimmed.startsWith(scope)) return trimmed;
340
- return `${scope} ${trimmed}`;
341
- });
342
- return `${scopedSelectors.join(', ')} {`;
343
- });
344
- }
345
- };
346
-
347
238
  // ============================================================
348
239
  // Sidebar Management
349
240
  // ============================================================
@@ -880,9 +771,7 @@
880
771
  render(htmlContent, fileType) {
881
772
  const containerClass = fileType === 'code'
882
773
  ? 'markdown-body code-view-container'
883
- : fileType === 'markdown'
884
- ? 'markdown-body pdf-style-preview'
885
- : 'markdown-body';
774
+ : 'markdown-body';
886
775
  elements.content.innerHTML = `<div class="${containerClass}">${htmlContent}</div>`;
887
776
 
888
777
  elements.content.querySelectorAll('pre code').forEach(block => {
@@ -1709,8 +1598,6 @@
1709
1598
  await this.exportPdf(tab.path);
1710
1599
  } else if (this.isHtmlPreview()) {
1711
1600
  this.printHtmlPreview(tab.name);
1712
- } else if (tab.fileType === 'markdown') {
1713
- await this.exportPdf(tab.path);
1714
1601
  } else {
1715
1602
  this.browserPrint(tab.name);
1716
1603
  }
@@ -1738,9 +1625,8 @@
1738
1625
 
1739
1626
  try {
1740
1627
  statusText.textContent = 'Generating PDF...';
1741
- const exportOptions = PdfStyleManager.getExportOptions();
1742
1628
 
1743
- const response = await MDVApi.exportPdf({ filePath, ...exportOptions });
1629
+ const response = await MDVApi.exportPdf({ filePath });
1744
1630
 
1745
1631
  if (!response.ok) {
1746
1632
  const error = await response.json();
@@ -2366,7 +2252,6 @@
2366
2252
  async function init() {
2367
2253
  // Initialize all managers
2368
2254
  ThemeManager.init();
2369
- PdfStyleManager.init();
2370
2255
  SidebarManager.init();
2371
2256
  ResizeHandler.init();
2372
2257
  EditorManager.init();
@@ -95,13 +95,6 @@
95
95
  PDF
96
96
  </button>
97
97
 
98
- <button class="toolbar-btn" id="pdfStyleToggle" title="PDF style settings" aria-label="PDF style settings">
99
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
100
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
101
- </svg>
102
- Style
103
- </button>
104
-
105
98
  <div class="toolbar-spacer"></div>
106
99
 
107
100
  <span class="editor-status" id="editorStatus" style="display: none;">Ready</span>
@@ -117,19 +110,6 @@
117
110
  </div>
118
111
  </div>
119
112
 
120
- <div class="pdf-style-panel hidden" id="pdfStylePanel">
121
- <label>
122
- <span>CSS</span>
123
- <input type="text" id="pdfStylePath" placeholder="src/styles/report.example.css">
124
- </label>
125
- <label>
126
- <span>PDF options</span>
127
- <input type="text" id="pdfOptionsPath" placeholder="src/styles/report.pdf-options.example.json">
128
- </label>
129
- <button class="toolbar-btn" id="pdfStyleApply">Apply</button>
130
- <button class="toolbar-btn" id="pdfStyleClear">Clear</button>
131
- </div>
132
-
133
113
  <!-- Tab bar -->
134
114
  <div class="tab-bar" id="tabBar" role="tablist" aria-label="Open files"></div>
135
115
 
@@ -241,40 +241,6 @@ body {
241
241
 
242
242
  .toolbar-spacer { flex: 1; }
243
243
 
244
- .pdf-style-panel {
245
- display: flex;
246
- align-items: center;
247
- gap: 10px;
248
- padding: 8px 16px;
249
- border-bottom: 1px solid var(--border);
250
- background: var(--bg-secondary);
251
- }
252
-
253
- .pdf-style-panel label {
254
- display: flex;
255
- align-items: center;
256
- gap: 6px;
257
- min-width: 0;
258
- color: var(--text-secondary);
259
- font-size: 12px;
260
- }
261
-
262
- .pdf-style-panel input {
263
- width: min(34vw, 360px);
264
- min-width: 160px;
265
- padding: 6px 8px;
266
- background: var(--bg-primary);
267
- border: 1px solid var(--border);
268
- border-radius: 6px;
269
- color: var(--text-primary);
270
- font-size: 12px;
271
- }
272
-
273
- .pdf-style-panel input:focus {
274
- border-color: var(--accent);
275
- outline: none;
276
- }
277
-
278
244
  .status {
279
245
  display: flex;
280
246
  align-items: center;