mdv-live 0.5.9 → 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,40 @@ 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
+
8
42
  ## [0.5.9] - 2026-05-09
9
43
 
10
44
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.9",
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": {
@@ -51,7 +51,6 @@
51
51
  "highlight.js": "^11.10.0",
52
52
  "markdown-it": "^14.1.0",
53
53
  "markdown-it-task-lists": "^2.1.1",
54
- "md-to-pdf": "^5.2.5",
55
54
  "mime-types": "^2.1.35",
56
55
  "multer": "^1.4.5-lts.1",
57
56
  "open": "^10.1.0",
package/src/api/pdf.js CHANGED
@@ -3,34 +3,28 @@ import fs from 'fs/promises';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import os from 'os';
6
- import { createRequire } from 'module';
7
6
  import { isMarp } from '../rendering/markdown.js';
8
- import { validatePath, validatePathReal } from '../utils/path.js';
9
- import { resolvePdfOptions } from '../styles/index.js';
10
-
11
- const require = createRequire(import.meta.url);
12
- const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
13
- const binDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '.bin');
14
- const marpBin = path.join(binDir, 'marp');
15
- const mdToPdfBin = path.join(binDir, 'md-to-pdf');
7
+ import { validatePath } from '../utils/path.js';
8
+
9
+ const marpBin = path.join(
10
+ path.dirname(fileURLToPath(import.meta.url)),
11
+ '..',
12
+ '..',
13
+ 'node_modules',
14
+ '.bin',
15
+ 'marp'
16
+ );
16
17
  const PDF_EXPORT_TIMEOUT_MS = 180000;
17
18
 
18
19
  /**
19
- * Spawn a PDF tool with stdin closed.
20
+ * Spawn marp-cli with stdin closed.
20
21
  *
21
- * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf
22
- * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
23
- * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
22
+ * 注意: execFile は stdio オプションを受け付けない (Node 仕様) ため spawn を使う。
24
23
  */
25
- function runPdfTool(bin, args, { cwd } = {}) {
24
+ function runMarp(args) {
26
25
  return new Promise((resolve, reject) => {
27
- const child = spawn(bin, args, {
28
- cwd,
29
- stdio: ['ignore', 'pipe', 'pipe'],
30
- });
31
- let stdout = '';
26
+ const child = spawn(marpBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
32
27
  let stderr = '';
33
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
34
28
  child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
35
29
 
36
30
  const timer = setTimeout(() => {
@@ -44,12 +38,11 @@ function runPdfTool(bin, args, { cwd } = {}) {
44
38
  child.on('close', (code, signal) => {
45
39
  clearTimeout(timer);
46
40
  if (code === 0) {
47
- resolve({ stdout, stderr });
41
+ resolve();
48
42
  } else {
49
- const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
43
+ const err = new Error(`marp exited with code=${code} signal=${signal}`);
50
44
  err.code = code;
51
45
  err.signal = signal;
52
- err.stdout = stdout;
53
46
  err.stderr = stderr;
54
47
  reject(err);
55
48
  }
@@ -58,50 +51,12 @@ function runPdfTool(bin, args, { cwd } = {}) {
58
51
  }
59
52
 
60
53
  /**
61
- * Resolve an optional user-selected file path under the server root.
62
- * @param {string | undefined} relativePath - Path supplied by the web UI.
63
- * @param {string} rootDir - Server root directory.
64
- * @returns {Promise<string | null>} Absolute path or null.
65
- */
66
- async function resolveOptionalUserFile(relativePath, rootDir) {
67
- if (!relativePath) return null;
68
- if (!await validatePathReal(relativePath, rootDir)) {
69
- throw new Error(`Access denied: ${relativePath}`);
70
- }
71
- const fullPath = path.join(rootDir, relativePath);
72
- const stat = await fs.stat(fullPath);
73
- if (!stat.isFile()) {
74
- throw new Error(`Not a file: ${relativePath}`);
75
- }
76
- return fullPath;
77
- }
78
-
79
- /**
80
- * Export a regular markdown document with md-to-pdf.
81
- * @param {string} inputPath - Source markdown file.
82
- * @param {string} outputPath - Temporary output path.
83
- * @param {string | null} stylesheetPath - Optional custom CSS file.
84
- * @param {string | null} pdfOptionsPath - Optional PDF options JSON file.
85
- * @returns {Promise<void>}
86
- */
87
- async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
88
- const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
89
- const args = [inputPath, '--pdf-options', JSON.stringify(pdfOptions)];
90
-
91
- if (stylesheetPath) {
92
- args.push('--stylesheet', highlightStylesheet);
93
- args.push('--stylesheet', stylesheetPath);
94
- args.push('--highlight-style', 'atom-one-dark');
95
- }
96
-
97
- await runPdfTool(mdToPdfBin, args, { cwd: path.dirname(inputPath) });
98
-
99
- const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
100
- await fs.rename(generatedPdf, outputPath);
101
- }
102
-
103
- /**
104
- * Setup PDF export routes
54
+ * Setup PDF export routes.
55
+ *
56
+ * Web UI からは Marp ファイルのみがこの経路を使う。通常 Markdown
57
+ * クライアント側で window.print() (OS 印刷ダイアログ) に流す設計のため、
58
+ * このエンドポイントは Marp 以外を 415 で拒否する。
59
+ *
105
60
  * @param {Express} app - Express application
106
61
  * @returns {void}
107
62
  */
@@ -109,7 +64,7 @@ export function setupPdfRoutes(app) {
109
64
  const { rootDir } = app.locals;
110
65
 
111
66
  app.post('/api/pdf/export', async (req, res) => {
112
- const { filePath, stylePath, pdfOptionsPath } = req.body;
67
+ const { filePath } = req.body;
113
68
 
114
69
  if (!filePath) {
115
70
  return res.status(400).json({ error: 'filePath is required' });
@@ -120,29 +75,28 @@ export function setupPdfRoutes(app) {
120
75
  }
121
76
 
122
77
  const fullPath = path.join(rootDir, filePath);
123
-
124
- try {
125
- await fs.access(fullPath);
126
- } catch {
127
- return res.status(404).json({ error: 'File not found' });
128
- }
129
-
130
78
  const baseName = path.basename(fullPath, '.md');
131
79
  const outputPath = path.join(os.tmpdir(), `mdv-${Date.now()}-${baseName}.pdf`);
132
80
  const outputFileName = `${baseName}.pdf`;
133
81
 
134
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
+
135
93
  const content = await fs.readFile(fullPath, 'utf-8');
136
- if (isMarp(content)) {
137
- await runPdfTool(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
138
- } else {
139
- const [stylesheetPath, resolvedPdfOptionsPath] = await Promise.all([
140
- resolveOptionalUserFile(stylePath, rootDir),
141
- resolveOptionalUserFile(pdfOptionsPath, rootDir),
142
- ]);
143
- 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.' });
144
96
  }
145
97
 
98
+ await runMarp([fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
99
+
146
100
  res.download(outputPath, outputFileName, async (err) => {
147
101
  if (err) {
148
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;