mdv-live 0.5.13 → 0.5.15

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,80 @@ 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.15] - 2026-05-09
9
+
10
+ ### Refactored
11
+
12
+ - **PDF 生成ロジックを `src/services/pdf.js` に集約**:
13
+ - サーバー HTTP route (`src/api/pdf.js`) と CLI (`bin/mdv.js convert`) の
14
+ 両方が **同じ実装** を共有
15
+ - bug fix・security check (realpath/symlink)・hoist 対応・stdin pipe ハング
16
+ 対応・workspace 汚染回避 (temp copy) が 1 箇所に集約。今後どちらの経路
17
+ でも同じ品質を保つ
18
+ - **CLI から `npx` を完全排除**:
19
+ - 旧: `execFileSync('npx', ['md-to-pdf', ...])` / `npx @marp-team/marp-cli`
20
+ - 新: `services/pdf.js` の `exportMarpPdf` / `exportMarkdownPdf` を直接呼ぶ
21
+ - 効果: registry 不通でも動く、バージョン揺れ防止、サーバー側と整合
22
+
23
+ ### Changed (breaking-ish)
24
+
25
+ - **`md-to-pdf` を `optionalDependencies` に降格** (旧 `dependencies`):
26
+ - デフォルト `npm install mdv-live` で puppeteer/chromium DL がほぼ消滅
27
+ し、install 大幅軽量化 (Marp 使わない人は完全 skip)
28
+ - `@marp-team/marp-cli` も同じく optional (従来通り)
29
+ - PDF 機能 (Plain Markdown / Marp 両方) を使う場合は `--include=optional`
30
+ か通常 `npm install` (default で optional も入る)。CI で `--omit=optional`
31
+ してると `PDF_TOOL_UNAVAILABLE` になり 503 / exit 1 + 案内
32
+ - **既存ユーザー影響**: `npm install --omit=optional` していた人は要再 install
33
+
34
+ ### Tests
35
+
36
+ - 247 → **249 件 (+2)**:
37
+ - `src/services/pdf.js` の import が throw しない
38
+ - `bin/mdv.js` が `npx` を呼んでいない (regression guard)
39
+
40
+ ### Chore
41
+
42
+ - `tests/fixtures/html-preview/` `tests/fixtures/marp-notes/` を `.gitignore`
43
+ に追加 (test-html-preview.js が runtime に書き出す artifact)
44
+ - `CODEX.md` を git 管理に追加 (codex review の Project ルール)
45
+
46
+ ### Docs
47
+
48
+ - README に **依存パッケージは optional 扱い** であることと install 方法を明記
49
+
50
+ ## [0.5.14] - 2026-05-09
51
+
52
+ ### Changed — Style PDF dispatch をリファイン
53
+
54
+ `PdfStyleManager` の dispatch 判定を **「PDF options JSON の有無」** に変更
55
+ (これまでは「CSS or JSON のいずれか」で server PDF 経路に切り替わっていた)。
56
+
57
+ | CSS | PDF options | PDF ボタン押下で |
58
+ |---|---|---|
59
+ | 空 | 空 | 印刷ダイアログ |
60
+ | 入れる | 空 | **印刷ダイアログ** (preview CSS が styled DOM で print engine に渡る) |
61
+ | 入れる/空 | 入れる | サーバー md-to-pdf で styled PDF 自動 DL |
62
+
63
+ JSON は margin/format/printBackground 等の細かい制御用。CSS だけ当てて
64
+ PDF にしたい普通のケースは印刷ダイアログで十分なので、md-to-pdf 経路に
65
+ 無闇に流さない。`shouldUseServerPdf()` メソッドに rename。
66
+
67
+ ### UX improvements
68
+
69
+ - **Style パネルの placeholder/tooltip** を rootDir 相対パス前提のヒント
70
+ に変更 (`report.css (rootDir からの相対パス)` 等)。Claude Code 等で
71
+ ファイル生成して入力する人が迷わないように
72
+ - **失敗時のステータスメッセージ詳細化**: 旧「Style failed」→ 新
73
+ 「Style failed: CSS not found: <path>」のように原因を出す。表示時間
74
+ も 2.5s → 4.5s に延長 (読み切る時間)
75
+ - **PDF export 失敗時** もサーバーから返されたエラーメッセージを
76
+ ステータスバーに表示
77
+
78
+ ### Docs
79
+
80
+ - README の `Web UI` 節を全面改訂: dispatch テーブル + Claude Code 連携手順
81
+
8
82
  ## [0.5.13] - 2026-05-09
9
83
 
10
84
  ### Restored
package/README.md CHANGED
@@ -78,6 +78,14 @@ mdv -v
78
78
 
79
79
  Markdown ファイルは CLI または Web UI から PDF に変換できます。
80
80
 
81
+ > **依存パッケージは optional 扱い** — `@marp-team/marp-cli` (Marp 用) と `md-to-pdf` (Plain Markdown 用) は `optionalDependencies`。デフォルト `npm install` でも入りますが、CI 等で `--omit=optional` 指定すると入りません。**PDF 機能を使うなら**:
82
+ > ```bash
83
+ > npm install -g mdv-live # 通常はこれで OK (optional も入る)
84
+ > # CI などで --omit=optional する場合:
85
+ > npm install --include=optional
86
+ > ```
87
+ > 不在のまま PDF 生成すると、サーバーは 503 + 案内、CLI は exit 1 + `npm install --include=optional` 提案を返します。
88
+
81
89
  ### CLI
82
90
 
83
91
  ```bash
@@ -107,12 +115,34 @@ mdv convert \
107
115
 
108
116
  ### Web UI
109
117
 
110
- ビューア上部の `Style` から以下を指定できます。
118
+ ビューア上部の `Style` ボタンを押すとパネルが開き、以下を指定できます。
119
+
120
+ - **CSS** — サーバー起動時の `rootDir` からの相対パス (例: `report.css`、`subdir/style.css`)
121
+ - **PDF options** — `rootDir` からの相対パス (例: `pdf-options.json`)。省略可
122
+
123
+ `Apply` を押すと CSS は Markdown プレビューにも反映されます。`Clear` で解除。
124
+
125
+ #### PDF ボタン押下時の挙動 (Markdown ファイル)
126
+
127
+ | CSS | PDF options | PDF ボタン押下で |
128
+ |---|---|---|
129
+ | 空 | 空 | OS の **印刷ダイアログ** (`window.print()`) |
130
+ | 入れる | 空 | OS の印刷ダイアログ。preview の CSS が styled DOM として print engine に渡る |
131
+ | 入れる/空 | **入れる** | サーバー側 `md-to-pdf` で **styled PDF を自動 DL** (`@page` で margin/format を JSON 制御したいときの本格モード) |
111
132
 
112
- - CSS ファイルパス
113
- - PDF options JSON ファイルパス
133
+ `PDF options` を入れない限り印刷ダイアログ経由になるので、ふだんは CSS だけ指定すれば OK。
134
+
135
+ #### Claude Code との連携
136
+
137
+ CSS を Claude Code に生成させるとき、**サーバーの `rootDir` 配下** に保存して相対パスを Style パネルに入力してください。例:
138
+
139
+ ```
140
+ $ mdv ~/notes # rootDir = ~/notes
141
+ # Claude Code で ~/notes/report.css を生成
142
+ # Style パネル CSS 欄に "report.css" を入力 → Apply
143
+ ```
114
144
 
115
- CSS Markdown プレビューにも反映されます。指定を解除する場合は `Clear` を押してください。
145
+ ファイルが見つからない場合 `Style failed: CSS not found: <path>` がステータスバーに出ます。
116
146
 
117
147
  ### ポート自動増分
118
148
 
package/bin/mdv.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Compatible with the original Python mdv-live CLI
6
6
  */
7
7
 
8
- import { execFileSync, execSync } from 'node:child_process';
8
+ import { execSync } from 'node:child_process';
9
9
  import { readFileSync } from 'node:fs';
10
10
  import fs from 'node:fs/promises';
11
11
  import { createServer as createNetServer } from 'node:net';
@@ -17,6 +17,7 @@ import open from 'open';
17
17
 
18
18
  import { createMdvServer } from '../src/server.js';
19
19
  import { resolvePdfOptions, resolveStyle } from '../src/styles/index.js';
20
+ import { exportMarpPdf, exportMarkdownPdf } from '../src/services/pdf.js';
20
21
 
21
22
  const DEFAULT_PORT = 8642;
22
23
  const MARP_FRONTMATTER_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
@@ -287,27 +288,41 @@ async function convertToPdf(inputPath, outputPath, styleArg, pdfOptionsPath) {
287
288
  }
288
289
 
289
290
  /**
290
- * Convert Marp presentation to PDF using marp-cli
291
+ * Format a PDF tool error for CLI output.
292
+ * @param {Error} err
293
+ * @returns {string} Error message including install hint when applicable.
294
+ */
295
+ function formatPdfToolError(err) {
296
+ if (err.code === 'PDF_TOOL_UNAVAILABLE') {
297
+ return `Error: ${err.message}\n Run: npm install --include=optional`;
298
+ }
299
+ if (err.stderr) {
300
+ return `Error: PDF conversion failed\n${err.stderr}`;
301
+ }
302
+ return `Error: PDF conversion failed\n${err.message || err}`;
303
+ }
304
+
305
+ /**
306
+ * Convert Marp presentation to PDF using the shared service.
307
+ *
291
308
  * @param {string} inputPath - Resolved input file path
292
309
  * @param {string} outputPath - Resolved output file path
293
310
  * @returns {Promise<number>} Exit code
294
311
  */
295
312
  async function convertMarpToPdf(inputPath, outputPath) {
296
313
  try {
297
- execFileSync('npx', ['@marp-team/marp-cli', '--no-stdin', inputPath, '--pdf', '--html', '--allow-local-files', '-o', outputPath], {
298
- encoding: 'utf-8',
299
- stdio: 'inherit'
300
- });
314
+ await exportMarpPdf(inputPath, outputPath);
301
315
  console.log(`PDF saved: ${outputPath}`);
302
316
  return 0;
303
- } catch {
304
- console.error('Error: PDF conversion failed');
317
+ } catch (err) {
318
+ console.error(formatPdfToolError(err));
305
319
  return 1;
306
320
  }
307
321
  }
308
322
 
309
323
  /**
310
- * Convert regular markdown to PDF using md-to-pdf
324
+ * Convert regular markdown to PDF using the shared service.
325
+ *
311
326
  * @param {string} inputPath - Resolved input file path
312
327
  * @param {string} outputPath - Resolved output file path
313
328
  * @param {import('../src/styles/index.js').StyleConfig} styleConfig - Style preset
@@ -315,40 +330,12 @@ async function convertMarpToPdf(inputPath, outputPath) {
315
330
  */
316
331
  async function convertMarkdownToPdf(inputPath, outputPath, styleConfig) {
317
332
  console.log('Converting as document (A4 portrait)...');
318
-
319
333
  try {
320
- const args = ['md-to-pdf', inputPath, '--pdf-options', JSON.stringify(styleConfig.pdfOptions)];
321
- const stylesheetPaths = styleConfig.stylesheets ?? (styleConfig.stylesheet ? [styleConfig.stylesheet] : []);
322
-
323
- for (const stylesheetPath of stylesheetPaths) {
324
- args.push('--stylesheet', stylesheetPath);
325
- }
326
-
327
- if (styleConfig.highlightStyle) {
328
- args.push('--highlight-style', styleConfig.highlightStyle);
329
- }
330
-
331
- if (styleConfig.css) {
332
- args.push('--css', styleConfig.css);
333
- }
334
-
335
- execFileSync('npx', args, {
336
- encoding: 'utf-8',
337
- stdio: 'inherit',
338
- cwd: path.dirname(inputPath),
339
- });
340
-
341
- // md-to-pdf outputs to same directory with .pdf extension
342
- const generatedPdf = inputPath.replace(/\.(md|markdown)$/i, '.pdf');
343
- if (generatedPdf !== outputPath) {
344
- await fs.rename(generatedPdf, outputPath);
345
- }
346
-
334
+ await exportMarkdownPdf(inputPath, outputPath, { styleConfig });
347
335
  console.log(`PDF saved: ${outputPath}`);
348
336
  return 0;
349
- } catch {
350
- console.error('Error: PDF conversion failed');
351
- console.error('Make sure md-to-pdf is available (npx md-to-pdf)');
337
+ } catch (err) {
338
+ console.error(formatPdfToolError(err));
352
339
  return 1;
353
340
  }
354
341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -51,13 +51,13 @@
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",
58
57
  "ws": "^8.18.0"
59
58
  },
60
59
  "optionalDependencies": {
61
- "@marp-team/marp-cli": "^4.0.3"
60
+ "@marp-team/marp-cli": "^4.0.3",
61
+ "md-to-pdf": "^5.2.5"
62
62
  }
63
63
  }
package/src/api/pdf.js CHANGED
@@ -1,94 +1,13 @@
1
- import { spawn } from 'child_process';
2
1
  import fs from 'fs/promises';
3
2
  import path from 'path';
4
3
  import os from 'os';
5
- import { createRequire } from 'module';
6
4
  import { isMarp } from '../rendering/markdown.js';
7
5
  import { validatePath, validatePathReal } from '../utils/path.js';
8
- import { resolvePdfOptions } from '../styles/index.js';
9
-
10
- const require = createRequire(import.meta.url);
11
- const highlightStylesheet = path.resolve(path.dirname(require.resolve('highlight.js')), '..', 'styles', 'atom-one-dark.css');
12
- const PDF_EXPORT_TIMEOUT_MS = 180000;
13
-
14
- /**
15
- * Lazily resolve a package's bin script via require.resolve.
16
- *
17
- * 0.5.10〜0.5.12 の hoist 罠 + optionalDependencies 欠如対応:
18
- * - npm hoisting で実体パスが top-level / nested いずれにもなる
19
- * - optionalDep が欠ける環境 (--omit=optional) では import 時に throw すると
20
- * サーバー起動が壊れる → request 時に lazy で解決し、欠ければ 503
21
- *
22
- * @param {string} pkgName - npm package name (e.g. '@marp-team/marp-cli')
23
- * @param {string} binName - bin entry key (matches package.json bin)
24
- * @returns {string} Absolute path to the bin script
25
- * @throws {Error} `code === 'PDF_TOOL_UNAVAILABLE'` if package missing
26
- */
27
- function resolvePkgBin(pkgName, binName) {
28
- let pkgPath;
29
- try {
30
- pkgPath = require.resolve(`${pkgName}/package.json`);
31
- } catch (err) {
32
- const e = new Error(`${pkgName} is not installed.`);
33
- e.code = 'PDF_TOOL_UNAVAILABLE';
34
- e.cause = err;
35
- throw e;
36
- }
37
- const pkg = require(`${pkgName}/package.json`);
38
- const bin = pkg.bin;
39
- const binRel = typeof bin === 'string' ? bin : bin?.[binName];
40
- if (!binRel) {
41
- const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
42
- e.code = 'PDF_TOOL_UNAVAILABLE';
43
- throw e;
44
- }
45
- return path.join(path.dirname(pkgPath), binRel);
46
- }
47
-
48
- /**
49
- * Spawn a PDF tool with stdin closed.
50
- *
51
- * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
52
- * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
53
- * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
54
- */
55
- function runPdfTool(bin, args, { cwd } = {}) {
56
- return new Promise((resolve, reject) => {
57
- const child = spawn(process.execPath, [bin, ...args], {
58
- cwd,
59
- stdio: ['ignore', 'pipe', 'pipe'],
60
- });
61
- let stdout = '';
62
- let stderr = '';
63
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
64
- child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
65
-
66
- const timer = setTimeout(() => {
67
- child.kill('SIGTERM');
68
- }, PDF_EXPORT_TIMEOUT_MS);
69
-
70
- child.on('error', (err) => {
71
- clearTimeout(timer);
72
- reject(err);
73
- });
74
- child.on('close', (code, signal) => {
75
- clearTimeout(timer);
76
- if (code === 0) {
77
- resolve({ stdout, stderr });
78
- } else {
79
- const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
80
- err.code = code;
81
- err.signal = signal;
82
- err.stdout = stdout;
83
- err.stderr = stderr;
84
- reject(err);
85
- }
86
- });
87
- });
88
- }
6
+ import { exportMarpPdf, exportMarkdownPdf } from '../services/pdf.js';
89
7
 
90
8
  /**
91
9
  * Resolve an optional user-selected file path under the server root.
10
+ *
92
11
  * @param {string | undefined} relativePath - Path supplied by the web UI.
93
12
  * @param {string} rootDir - Server root directory.
94
13
  * @returns {Promise<string | null>} Absolute path or null.
@@ -106,65 +25,15 @@ async function resolveOptionalUserFile(relativePath, rootDir) {
106
25
  return fullPath;
107
26
  }
108
27
 
109
- /**
110
- * Export a regular markdown document with md-to-pdf.
111
- *
112
- * 注意 1: md-to-pdf CLI は出力ファイルを **ソース隣に同名 .pdf で書く** 仕様
113
- * (例: foo.md → foo.pdf next to source)。既存の foo.pdf があると上書き
114
- * してから temp に rename = ユーザーのワークスペース汚染 + 読み取り専用
115
- * dir で失敗。これを避けるためソースを **temp dir にコピー** してそこで
116
- * md-to-pdf を実行し、生成 PDF だけを最終 outputPath に rename する。
117
- *
118
- * 注意 2: JS API (mdToPdf()) を使う方法もあるが、puppeteer プロセスが
119
- * 残って Node test runner が event loop drain 待ちで cancel される。
120
- * spawn 経由なら子プロセスが exit するので確実に cleanup される。
121
- *
122
- * 注意 3: temp copy なので markdown 内の **相対パス画像/CSS** は壊れる。
123
- * Style 機能は CSS で見た目を整える用途を想定しており、相対パス画像を
124
- * 多用する markdown は対象外として割り切る。
125
- */
126
- async function exportMarkdownPdf(inputPath, outputPath, stylesheetPath, pdfOptionsPath) {
127
- const mdToPdfBin = resolvePkgBin('md-to-pdf', 'md-to-pdf');
128
- const pdfOptions = await resolvePdfOptions(pdfOptionsPath || undefined);
129
-
130
- const tempSourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mdv-md-'));
131
- try {
132
- const tempSourcePath = path.join(tempSourceDir, path.basename(inputPath));
133
- await fs.copyFile(inputPath, tempSourcePath);
134
-
135
- const args = [tempSourcePath, '--pdf-options', JSON.stringify(pdfOptions)];
136
- if (stylesheetPath) {
137
- args.push('--stylesheet', highlightStylesheet);
138
- args.push('--stylesheet', stylesheetPath);
139
- args.push('--highlight-style', 'atom-one-dark');
140
- }
141
-
142
- await runPdfTool(mdToPdfBin, args, { cwd: tempSourceDir });
143
-
144
- const generatedPdf = tempSourcePath.replace(/\.(md|markdown)$/i, '.pdf');
145
- await fs.rename(generatedPdf, outputPath);
146
- } finally {
147
- await fs.rm(tempSourceDir, { recursive: true, force: true });
148
- }
149
- }
150
-
151
- /**
152
- * Export a Marp slide deck with marp-cli.
153
- *
154
- * `@marp-team/marp-cli` is an optionalDependency. When missing the route
155
- * returns a 503; that is surfaced by the caller.
156
- */
157
- async function exportMarpPdf(inputPath, outputPath) {
158
- const marpBin = resolvePkgBin('@marp-team/marp-cli', 'marp');
159
- await runPdfTool(marpBin, [inputPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin']);
160
- }
161
-
162
28
  /**
163
29
  * Setup PDF export routes.
164
30
  *
165
31
  * - Marp files → `marp-cli` (slide PDF, landscape, themed)
166
32
  * - Plain Markdown → `md-to-pdf` (document PDF, optional CSS / PDF options)
167
33
  *
34
+ * 実装は `src/services/pdf.js` に集約。CLI (`bin/mdv.js convert`) も同じ
35
+ * service を使う。
36
+ *
168
37
  * @param {Express} app - Express application
169
38
  * @returns {void}
170
39
  */
@@ -210,7 +79,10 @@ export function setupPdfRoutes(app) {
210
79
  resolveOptionalUserFile(stylePath, rootDir),
211
80
  resolveOptionalUserFile(pdfOptionsPath, rootDir),
212
81
  ]);
213
- await exportMarkdownPdf(fullPath, outputPath, stylesheetPath, resolvedPdfOptionsPath);
82
+ await exportMarkdownPdf(fullPath, outputPath, {
83
+ stylesheetPath,
84
+ pdfOptionsPath: resolvedPdfOptionsPath,
85
+ });
214
86
  }
215
87
 
216
88
  res.download(outputPath, outputFileName, async (err) => {
@@ -224,7 +96,7 @@ export function setupPdfRoutes(app) {
224
96
  try { await fs.unlink(outputPath); } catch { /* ignore */ }
225
97
  if (err.code === 'PDF_TOOL_UNAVAILABLE') {
226
98
  return res.status(503).json({
227
- error: `PDF tool unavailable: ${err.message} Run \`npm install\` (without --omit=optional) and retry.`,
99
+ error: `PDF tool unavailable: ${err.message} Run \`npm install --include=optional\` and retry.`,
228
100
  });
229
101
  }
230
102
  res.status(500).json({ error: 'PDF export failed' });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * PDF generation service.
3
+ *
4
+ * 中央集約された PDF 化ロジック:
5
+ * - サーバー HTTP route (`src/api/pdf.js`) と CLI (`bin/mdv.js convert`) の
6
+ * 両方が同じ実装を共有することで、bug fix・security check・hoist 対応・
7
+ * stdin pipe ハング対応・workspace 汚染回避ロジックを 1 箇所に集める
8
+ * - Marp は `@marp-team/marp-cli` (optionalDependency)
9
+ * - Plain markdown は `md-to-pdf` (optionalDependency: 0.5.15 で降格)
10
+ * - どちらも欠如時は `PDF_TOOL_UNAVAILABLE` code を投げ、caller 側で
11
+ * ユーザー向けメッセージに変換 (HTTP 503 / CLI exit 1)
12
+ */
13
+
14
+ import { spawn } from 'child_process';
15
+ import { randomUUID } from 'crypto';
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
18
+ import os from 'os';
19
+ import { createRequire } from 'module';
20
+ import { resolvePdfOptions } from '../styles/index.js';
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ const highlightStylesheet = path.resolve(
25
+ path.dirname(require.resolve('highlight.js')),
26
+ '..',
27
+ 'styles',
28
+ 'atom-one-dark.css',
29
+ );
30
+
31
+ const PDF_EXPORT_TIMEOUT_MS = 180000;
32
+
33
+ /**
34
+ * Lazily resolve a package's bin script via require.resolve.
35
+ *
36
+ * 0.5.10〜0.5.12 の hoist 罠 + optionalDependencies 欠如対応:
37
+ * - npm hoisting で実体パスが top-level / nested いずれにもなる
38
+ * - optionalDep が欠ける環境 (--omit=optional) では import 時に throw すると
39
+ * サーバー起動が壊れる → request 時に lazy で解決し、欠ければ 503 / exit 1
40
+ *
41
+ * @param {string} pkgName - npm package name (e.g. '@marp-team/marp-cli')
42
+ * @param {string} binName - bin entry key (matches package.json bin)
43
+ * @returns {string} Absolute path to the bin script
44
+ * @throws {Error} `code === 'PDF_TOOL_UNAVAILABLE'` if package missing
45
+ */
46
+ export function resolvePkgBin(pkgName, binName) {
47
+ let pkgPath;
48
+ try {
49
+ pkgPath = require.resolve(`${pkgName}/package.json`);
50
+ } catch (err) {
51
+ const e = new Error(`${pkgName} is not installed.`);
52
+ e.code = 'PDF_TOOL_UNAVAILABLE';
53
+ e.cause = err;
54
+ throw e;
55
+ }
56
+ const pkg = require(`${pkgName}/package.json`);
57
+ const bin = pkg.bin;
58
+ const binRel = typeof bin === 'string' ? bin : bin?.[binName];
59
+ if (!binRel) {
60
+ const e = new Error(`${pkgName} does not declare a "${binName}" bin entry.`);
61
+ e.code = 'PDF_TOOL_UNAVAILABLE';
62
+ throw e;
63
+ }
64
+ return path.join(path.dirname(pkgPath), binRel);
65
+ }
66
+
67
+ /**
68
+ * Spawn a PDF tool with stdin closed.
69
+ *
70
+ * 注意: execFile は stdio オプションを受け付けない (Node 仕様)。md-to-pdf は
71
+ * 内部で get-stdin を呼ぶため、stdin が pipe のままだと EOF を永遠に待ち続けて
72
+ * ハングする。spawn で stdin を 'ignore' (= /dev/null) に明示的に縛る。
73
+ */
74
+ function runPdfTool(bin, args, { cwd } = {}) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawn(process.execPath, [bin, ...args], {
77
+ cwd,
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ });
80
+ let stdout = '';
81
+ let stderr = '';
82
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
83
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
84
+
85
+ const timer = setTimeout(() => {
86
+ child.kill('SIGTERM');
87
+ }, PDF_EXPORT_TIMEOUT_MS);
88
+
89
+ child.on('error', (err) => {
90
+ clearTimeout(timer);
91
+ reject(err);
92
+ });
93
+ child.on('close', (code, signal) => {
94
+ clearTimeout(timer);
95
+ if (code === 0) {
96
+ resolve({ stdout, stderr });
97
+ } else {
98
+ const err = new Error(`${path.basename(bin)} exited with code=${code} signal=${signal}`);
99
+ err.code = code;
100
+ err.signal = signal;
101
+ err.stdout = stdout;
102
+ err.stderr = stderr;
103
+ reject(err);
104
+ }
105
+ });
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Export a regular markdown document with md-to-pdf.
111
+ *
112
+ * 課題:
113
+ * - md-to-pdf CLI は出力ファイルを **ソース隣に同名 .pdf で書く** 仕様
114
+ * (例: foo.md → foo.pdf next to source)。既存の foo.pdf を上書きしないこと
115
+ * - 同時に、`![logo](images/logo.png)` のような **相対 asset 参照** を解決
116
+ * するためには md-to-pdf を **source dir で実行** する必要がある
117
+ * (`--basedir` を別dir にすると md-to-pdf が input path から URL を逆算
118
+ * して basedir 外と判定し asset がロードされない、codex round 2 P2)
119
+ *
120
+ * 解決:
121
+ * - source dir 内に **隠し一意名 (`.mdv-pdf-tmp.<stamp>.md`)** で copy 配置
122
+ * - md-to-pdf を source dir で実行 → 隠しPDFが source dir に生まれる
123
+ * - その隠し PDF を outputPath に rename
124
+ * - finally で隠し md / 隠し pdf を確実に削除 (source dir 残留物ゼロ)
125
+ *
126
+ * 副作用 / 制約:
127
+ * - source dir に書き込み権が必要 (read-only dir では fs.copyFile が
128
+ * EACCES/EROFS で throw → caller 側で 503/exit 1 を返す扱いに任せる)
129
+ * - 一意名 + mdv prefix なので既存ファイル衝突なし
130
+ *
131
+ * @param {string} inputPath - Source markdown file (absolute).
132
+ * @param {string} outputPath - Destination PDF file (absolute).
133
+ * @param {object} [options]
134
+ * @param {string|null} [options.stylesheetPath] - Custom CSS file path.
135
+ * @param {string|null} [options.pdfOptionsPath] - PDF options JSON path.
136
+ * @param {object} [options.styleConfig] - Pre-resolved style config (CLI use).
137
+ * When supplied, supersedes stylesheetPath / pdfOptionsPath.
138
+ * @returns {Promise<void>}
139
+ */
140
+ export async function exportMarkdownPdf(inputPath, outputPath, options = {}) {
141
+ const { stylesheetPath = null, pdfOptionsPath = null, styleConfig = null } = options;
142
+
143
+ const mdToPdfBin = resolvePkgBin('md-to-pdf', 'md-to-pdf');
144
+ const pdfOptions = styleConfig?.pdfOptions
145
+ ?? await resolvePdfOptions(pdfOptionsPath || undefined);
146
+
147
+ const sourceDir = path.dirname(inputPath);
148
+ const ext = path.extname(inputPath); // .md / .markdown
149
+ // randomUUID で temp 名を一意に。process.pid + Date.now() だと同一 ms の
150
+ // concurrent 呼出 (例: 並行 /api/pdf/export) で衝突して別リクエストの
151
+ // 入力/出力を上書きする race があった (codex round 3 P2)
152
+ const stamp = `${process.pid}-${randomUUID()}`;
153
+ const tempSourcePath = path.join(sourceDir, `.mdv-pdf-tmp.${stamp}${ext}`);
154
+ const tempPdfPath = tempSourcePath.replace(/\.(md|markdown)$/i, '.pdf');
155
+
156
+ try {
157
+ await fs.copyFile(inputPath, tempSourcePath);
158
+
159
+ const args = [tempSourcePath, '--pdf-options', JSON.stringify(pdfOptions)];
160
+
161
+ // CLI 経路: styleConfig (resolveStyle 済み) を優先
162
+ if (styleConfig) {
163
+ const stylesheetPaths = styleConfig.stylesheets
164
+ ?? (styleConfig.stylesheet ? [styleConfig.stylesheet] : []);
165
+ for (const ssPath of stylesheetPaths) {
166
+ args.push('--stylesheet', ssPath);
167
+ }
168
+ if (styleConfig.highlightStyle) {
169
+ args.push('--highlight-style', styleConfig.highlightStyle);
170
+ }
171
+ if (styleConfig.css) {
172
+ args.push('--css', styleConfig.css);
173
+ }
174
+ } else if (stylesheetPath) {
175
+ // Web UI 経路: 単一 CSS path + デフォルト highlight
176
+ args.push('--stylesheet', highlightStylesheet);
177
+ args.push('--stylesheet', stylesheetPath);
178
+ args.push('--highlight-style', 'atom-one-dark');
179
+ }
180
+
181
+ await runPdfTool(mdToPdfBin, args, { cwd: sourceDir });
182
+ await fs.rename(tempPdfPath, outputPath);
183
+ } finally {
184
+ // 隠し md / 隠し pdf を確実に削除 (例外発生時も cleanup)
185
+ await fs.unlink(tempSourcePath).catch(() => {});
186
+ await fs.unlink(tempPdfPath).catch(() => {});
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Export a Marp slide deck with marp-cli.
192
+ *
193
+ * @param {string} inputPath - Source Marp markdown file (absolute).
194
+ * @param {string} outputPath - Destination PDF file (absolute).
195
+ * @returns {Promise<void>}
196
+ */
197
+ export async function exportMarpPdf(inputPath, outputPath) {
198
+ const marpBin = resolvePkgBin('@marp-team/marp-cli', 'marp');
199
+ await runPdfTool(marpBin, [
200
+ inputPath,
201
+ '-o', outputPath,
202
+ '--html',
203
+ '--allow-local-files',
204
+ '--no-stdin',
205
+ ]);
206
+ }
package/src/static/app.js CHANGED
@@ -273,9 +273,12 @@
273
273
  this.loadPreviewCss();
274
274
  },
275
275
 
276
- // Style 設定があるか (PDF dispatch server vs print dialog で切り替えるため)
277
- hasStyle() {
278
- return !!(normalizeUserPath(state.pdfStylePath) || normalizeUserPath(state.pdfOptionsPath));
276
+ // PDF dispatch 切替: PDF options JSON が指定されている時だけサーバー
277
+ // md-to-pdf を使う。CSS のみ (or 何もなし) の場合は印刷ダイアログ経由
278
+ // OS のページ設定を活かしつつ、preview に当たっている CSS が
279
+ // そのまま print engine に渡って styled PDF が出る。
280
+ shouldUseServerPdf() {
281
+ return !!normalizeUserPath(state.pdfOptionsPath);
279
282
  },
280
283
 
281
284
  getExportOptions() {
@@ -317,7 +320,12 @@
317
320
 
318
321
  try {
319
322
  const response = await fetch(`/raw/${state.pdfStylePath}`);
320
- if (!response.ok) throw new Error('CSS file not found');
323
+ if (!response.ok) {
324
+ if (response.status === 404) {
325
+ throw new Error(`CSS not found: ${state.pdfStylePath}`);
326
+ }
327
+ throw new Error(`CSS load error (HTTP ${response.status}): ${state.pdfStylePath}`);
328
+ }
321
329
  const cssText = await response.text();
322
330
  const style = document.createElement('style');
323
331
  style.id = this.scopedCssId;
@@ -327,8 +335,10 @@
327
335
  setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 1600);
328
336
  } catch (error) {
329
337
  console.error('PDF style preview error:', error);
330
- elements.statusText.textContent = 'PDF style failed';
331
- setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 2500);
338
+ // エラー詳細を status に出す (Claude Code 連携時の自己解決を助ける)
339
+ const detail = (error.message || 'unknown error').slice(0, 100);
340
+ elements.statusText.textContent = `Style failed: ${detail}`;
341
+ setTimeout(() => { elements.statusText.textContent = 'Connected'; }, 4500);
332
342
  }
333
343
  },
334
344
 
@@ -1714,10 +1724,10 @@
1714
1724
  await this.exportPdf(tab.path);
1715
1725
  } else if (this.isHtmlPreview()) {
1716
1726
  this.printHtmlPreview(tab.name);
1717
- } else if (tab.fileType === 'markdown' && PdfStyleManager.hasStyle()) {
1718
- // Style パネルで CSS / PDF options が設定されている場合のみ
1719
- // サーバー側 md-to-pdf styled PDF を生成 (Watanabe 設計)。
1720
- // 設定がなければデフォルトの印刷ダイアログ経路に落とす。
1727
+ } else if (tab.fileType === 'markdown' && PdfStyleManager.shouldUseServerPdf()) {
1728
+ // PDF options JSON が指定されている時のみサーバー md-to-pdf。
1729
+ // CSS だけ / 何もなしの場合は printDialog OS にページ制御
1730
+ // を委ね、preview の CSS injection が styled PDF を作る。
1721
1731
  await this.exportPdf(tab.path);
1722
1732
  } else {
1723
1733
  this.browserPrint(tab.name);
@@ -1772,10 +1782,13 @@
1772
1782
  }, 2000);
1773
1783
  } catch (error) {
1774
1784
  console.error('PDF export error:', error);
1775
- statusText.textContent = 'PDF export failed';
1785
+ // サーバーが返したエラーメッセージを status に表示
1786
+ // (Claude Code 連携などで「何が悪いか分からない」を防ぐ)
1787
+ const detail = (error.message || 'unknown error').slice(0, 100);
1788
+ statusText.textContent = `PDF export failed: ${detail}`;
1776
1789
  setTimeout(() => {
1777
1790
  statusText.textContent = originalStatus;
1778
- }, 3000);
1791
+ }, 4500);
1779
1792
  }
1780
1793
  },
1781
1794
 
@@ -120,11 +120,11 @@
120
120
  <div class="pdf-style-panel hidden" id="pdfStylePanel">
121
121
  <label>
122
122
  <span>CSS</span>
123
- <input type="text" id="pdfStylePath" placeholder="src/styles/report.example.css">
123
+ <input type="text" id="pdfStylePath" placeholder="report.css (rootDir からの相対パス)" title="rootDir 配下の .css ファイルパス。例: report.css または subdir/style.css">
124
124
  </label>
125
125
  <label>
126
126
  <span>PDF options</span>
127
- <input type="text" id="pdfOptionsPath" placeholder="src/styles/report.pdf-options.example.json">
127
+ <input type="text" id="pdfOptionsPath" placeholder="pdf-options.json (省略可。空なら印刷ダイアログ)" title="rootDir 配下の .json ファイルパス。指定すると md-to-pdf が margin/format を適用してサーバー PDF 自動 DL。空なら印刷ダイアログ経路。">
128
128
  </label>
129
129
  <button class="toolbar-btn" id="pdfStyleApply">Apply</button>
130
130
  <button class="toolbar-btn" id="pdfStyleClear">Clear</button>
@@ -808,7 +808,7 @@ body {
808
808
  padding: 0 !important;
809
809
  }
810
810
 
811
- .sidebar, .resize-handle, .toolbar, .tab-bar { display: none !important; }
811
+ .sidebar, .resize-handle, .toolbar, .tab-bar, .pdf-style-panel { display: none !important; }
812
812
 
813
813
  body {
814
814
  height: auto !important;