mdv-live 0.5.5 → 0.5.8

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,108 @@ 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.8] - 2026-05-08
9
+
10
+ ### Fixed
11
+
12
+ - **Symlink TOCTOU on note auto-save** (codex-loop で 4 round 連鎖修正):
13
+ - 旧コードの TOCTOU guard が `earlyDeck.realPath` (lock 取得前) と比較
14
+ していた → 進入後 swap、戻し、書き込みで別ファイル読み出しが original
15
+ path に書ける race を塞ぐため `deck.realPath` (in-lock) と比較に修正
16
+ - in-lock で realpath が変わったら mutex 範囲外の書込みになる →
17
+ detection を入れて再 lock 取得
18
+ - 再 lock 取得を server-side 自動 retry で実装 (client は STALE 以外を
19
+ terminal 扱いするため)
20
+ - retarget retry の入れ子 lock が opposite retarget で deadlock し得る
21
+ → trampoline で **outer lock 解放後に新 realpath を取得**
22
+
23
+ ### Added
24
+
25
+ - TOCTOU 正常系の API regression test
26
+ - SaveQueue coalesce/serialize/dropPath/例外耐性 5 件
27
+ - Sec-Fetch-Site=same-origin の B 受理パス + cross-site 拒否
28
+
29
+ ### Architecture (refactor)
30
+
31
+ - `src/api/marpNote.js` orchestration を 38 行に。実装は `src/api/marpNote/`
32
+ 配下の `guards.js` / `readDeck.js` / `handleGet.js` / `handlePut.js` に分割
33
+ - `src/static/lib/saveQueue.js` (per-deck queue + per-slide coalesce、純 JS)
34
+ - `src/static/lib/tabRegistry.js` (tab close hook → メモリリーク解消)
35
+ - `src/static/lib/apiClient.js` を deck/file/tree/info/pdf 用に拡張、
36
+ app.js の fetch 直叩きを 13 → 2 (WebSocket / /raw/ のみ残存)
37
+ - `src/concurrency/pathLock.js`: promise-chain ベースの正しい mutex
38
+ (旧 naive Map 実装の thundering-herd race を排除)
39
+ - `src/utils/errors.js`: mkError + ERROR_STATUS テーブル + sendError SSOT
40
+ - `src/utils/etag.js`: ETag 計算 SSOT
41
+ - placeholder を CSS pseudo-element 化 (`:empty::before`) で
42
+ contenteditable に placeholder text が混入する罠を構造的に解消
43
+ - STALE 通知時に編集テキストを localStorage に自動退避
44
+
45
+ ### Tests
46
+
47
+ - 222 → **236 件 (+14)**、全 PASS
48
+
49
+ ## [0.5.7] - 2026-05-08
50
+
51
+ ### Added
52
+
53
+ - **Presenter View** (Marp スピーカーノート別ウィンドウ表示・編集)
54
+ - P キー (Cmd/Ctrl 修飾なし) または Marp ナビボタンで起動
55
+ - Current / Next スライド + Speaker Notes + 経過タイマーを並列表示
56
+ - パネルサイズはドラッグハンドルで変更可能 (localStorage 永続化)
57
+ - BroadcastChannel `mdv-marp-presenter` でメイン⇄presenter 双方向同期
58
+ - **スピーカーノートの自動保存**: presenter ノートパネルをクリック→編集→
59
+ 800ms デバウンスでサーバへ PUT。ソース markdown の HTML コメントを書き換え
60
+ - **`/api/marp/decks/:path` エンドポイント** (GET/PUT/OPTIONS)
61
+ - **ETag 楽観ロック** (`sha256:`) で外部編集との衝突検出
62
+ - **per-path 非同期 mutex** で同時 PUT を直列化
63
+ - **Multi-note Guard**: 1 slide に複数ノートがある場合は read-only
64
+ - **CSRF**: Origin + Sec-Fetch-Site + Content-Type 厳密検証
65
+ - **PNA preflight 拒否** (localhost 同一オリジン要求)
66
+ - **128KB body limit + 専用 413 ハンドラ** で情報漏洩防止
67
+
68
+ ### Architecture
69
+
70
+ - Marp スライド範囲・ノート位置の特定を **Marpit token** に委譲する
71
+ `marpitAdapter` を新設。regex 再実装の脆弱性を構造的に解消
72
+ - `validatePathReal` + `O_NOFOLLOW` + realpath 二重解決で symlink swap
73
+ best-effort 防御
74
+ - `atomicWrite` で `O_EXCL` temp + chmod EPERM 限定 + EXDEV 二段 rename +
75
+ uid+mtime sweep
76
+ - BOM/CRLF/CR/UTF-8 surrogate pair 安全な行↔バイト変換ヘルパに集約
77
+ - 共通 error コード/HTTP status マッピングを `utils/errors.js` に SSOT 化
78
+ - promise-chain ベースの正しい mutex (`concurrency/pathLock.js`) で
79
+ thundering-herd race を排除
80
+ - HTTP client / BroadcastChannel 名 / message schema を専用ライブラリに分離
81
+ - セキュリティ脆弱性 5 件 (basic-ftp / ip-address / postcss) を `npm audit fix`
82
+
83
+ ### Tests
84
+
85
+ - 既存 119 → **228 件 (+109)** すべて PASS
86
+ - 性能: 500 slides / 155 KiB ファイルで parseDeck+rewrite 86ms
87
+
88
+ ## [0.5.6] - 2026-04-27
89
+
90
+ ### Added
91
+
92
+ - Markdown PDF変換用の `mdv convert` サブコマンドを追加
93
+ - `-s <css-file>` によるPDF変換用CSS指定を追加
94
+ - `--pdf-options <json-file>` によるPuppeteer PDF options指定を追加
95
+ - Web UIのStyleパネルを追加
96
+ - CSSファイルパスを指定可能
97
+ - PDF options JSONファイルパスを指定可能
98
+ - 指定CSSをMarkdownプレビューに反映
99
+ - `Clear` でスタイル指定を解除可能
100
+ - 通常MarkdownのWeb UI PDF exportを `md-to-pdf` に対応
101
+ - PDFスタイル指定のサンプルを追加
102
+ - `src/styles/report.example.css`
103
+ - `src/styles/report.pdf-options.example.json`
104
+
105
+ ### Changed
106
+
107
+ - PDF出力設定をCSSとPDF options JSONに分離
108
+ - Marp PDF出力は従来どおりMarp CLIを使用し、通常Markdown PDF出力のみ `md-to-pdf` を使用
109
+
8
110
  ## [0.5.5] - 2026-04-05
9
111
 
10
112
  ### Fixed
package/README.md CHANGED
@@ -10,16 +10,18 @@
10
10
  - 📁 左側にフォルダツリー表示(遅延読み込み対応)
11
11
  - 📄 Markdownをリアルタイムレンダリング
12
12
  - 🎬 **Marp完全対応**(公式テーマ・ディレクティブ・数式)
13
+ - 🎤 **Presenter View**(スピーカーノート別ウィンドウ・自動保存・タイマー) — `P` キーで起動
13
14
  - 🔄 ファイル更新時に自動リロード(WebSocket)
14
15
  - 🎨 シンタックスハイライト(highlight.js)
15
16
  - 📊 Mermaid図のレンダリング
16
17
  - 🌙 ダーク/ライトテーマ切り替え
17
18
  - ✏️ インラインエディタ(Cmd+E)
18
19
  - ✅ タスクリスト(チェックボックス)対応
19
- - 📥 PDF出力(Cmd+P)
20
+ - 📥 PDF出力(Cmd+P / CLI convert
21
+ - 🎛️ PDF用CSS・PDF options指定(CLI / Web UI)
20
22
  - 🎬 動画/音声ストリーミング再生(Range Request対応)
21
23
  - 📤 ファイルアップロード(ドラッグ&ドロップ)
22
- - 🔒 セキュリティ強化(パストラバーサル防止)
24
+ - 🔒 セキュリティ強化(パストラバーサル防止 + ETag 楽観ロック + CSRF 防御)
23
25
 
24
26
  ## Installation
25
27
 
@@ -59,13 +61,59 @@ mdv -k 12345
59
61
  mdv -k -a
60
62
 
61
63
  # PDFに変換
62
- mdv --pdf slide.md
63
- mdv --pdf slide.md -o output.pdf
64
+ mdv convert -i report.md -o report.pdf
65
+
66
+ # PDFに変換(CSSとPDF optionsを指定)
67
+ mdv convert \
68
+ -i report.md \
69
+ -o report.pdf \
70
+ -s ./src/styles/report.example.css \
71
+ --pdf-options ./src/styles/report.pdf-options.example.json
64
72
 
65
73
  # バージョン表示
66
74
  mdv -v
67
75
  ```
68
76
 
77
+ ## PDF Export
78
+
79
+ Markdown ファイルは CLI または Web UI から PDF に変換できます。
80
+
81
+ ### CLI
82
+
83
+ ```bash
84
+ mdv convert -i input.md -o output.pdf
85
+ ```
86
+
87
+ CSS を指定する場合は `-s` に CSS ファイルパスを渡します。
88
+
89
+ ```bash
90
+ mdv convert \
91
+ -i input.md \
92
+ -o output.pdf \
93
+ -s ./src/styles/report.example.css
94
+ ```
95
+
96
+ `printBackground` や余白などの PDF 生成オプションは、CSS と分離して JSON ファイルで指定できます。
97
+
98
+ ```bash
99
+ mdv convert \
100
+ -i input.md \
101
+ -o output.pdf \
102
+ -s ./src/styles/report.example.css \
103
+ --pdf-options ./src/styles/report.pdf-options.example.json
104
+ ```
105
+
106
+ `src/styles/report.example.css` と `src/styles/report.pdf-options.example.json` はサンプルです。必要に応じて任意の CSS / JSON ファイルを指定してください。
107
+
108
+ ### Web UI
109
+
110
+ ビューア上部の `Style` から以下を指定できます。
111
+
112
+ - CSS ファイルパス
113
+ - PDF options JSON ファイルパス
114
+
115
+ CSS は Markdown プレビューにも反映されます。指定を解除する場合は `Clear` を押してください。
116
+
69
117
  ### ポート自動増分
70
118
 
71
119
  ポートが使用中の場合、自動的に次のポート番号を試します。
@@ -118,6 +166,8 @@ paginate: true
118
166
 
119
167
  内容...
120
168
 
169
+ <!-- スピーカーノート (Presenter View で表示・編集できます) -->
170
+
121
171
  ---
122
172
 
123
173
  # 次のスライド
@@ -129,9 +179,55 @@ paginate: true
129
179
  ### サポートされるMarp機能
130
180
 
131
181
  - **テーマ**: default, gaia, uncover
132
- - **ディレクティブ**: paginate, header, footer, backgroundColor, etc.
182
+ - **ディレクティブ**: paginate, header, footer, backgroundColor, lang, headingDivider, etc.
183
+ - **headingDivider**: scalar (`headingDivider: 2`) / inline-array (`[1, 2]`) / block-array 全形式
184
+ - **スライド区切り**: `---` / `***` / `___` (CommonMark thematic break 全形式)
133
185
  - **画像構文**: `![bg]`, `![w:100px]`, `![bg left]`
134
186
  - **数式**: KaTeX対応(インライン `$...$`、ブロック `$$...$$`)
187
+ - **スピーカーノート**: HTML コメント (`<!-- ... -->`) で記述
188
+
189
+ ## Presenter View
190
+
191
+ Marp ファイルを開いた状態で **`P` キー** を押すと、別ウィンドウで登壇者ビューが起動します。
192
+
193
+ ### 機能
194
+
195
+ - **3 ペインレイアウト**: 現在のスライド (大) / 次のスライド (小) / スピーカーノート
196
+ - **経過タイマー**: 上部に MM:SS 表示、Reset ボタンで 0 にリセット
197
+ - **ノート編集 → 自動保存**: ノートパネルをクリックして編集 → 800ms デバウンスで markdown ソースのコメントを書き戻し
198
+ - **キーボードナビ**: ← / → でスライド移動、メイン画面と双方向同期
199
+ - **レイアウト調整**: ペイン境界をドラッグで自由に変更、ダブルクリックでデフォルト復元 (localStorage 永続化)
200
+ - **Multi-note Guard**: 1 スライドに複数のノートコメントがある場合は自動保存を無効化(先頭ノート消失防止)
201
+ - **STALE 検出**: 外部エディタによる変更を ETag 楽観ロックで検出、編集中テキストを localStorage に自動退避
202
+
203
+ ### スピーカーノートの書き方
204
+
205
+ ```markdown
206
+ # スライドタイトル
207
+
208
+ スライドの本文
209
+
210
+ <!-- ここがスピーカーノート。Presenter View で編集すると
211
+ このコメントが書き換わります。 -->
212
+ ```
213
+
214
+ 複数行のノートも OK:
215
+
216
+ ```markdown
217
+ <!--
218
+ - ポイント 1: 〜を強調する
219
+ - ポイント 2: ここで質問を投げかける
220
+ - 想定時間: 2 分
221
+ -->
222
+ ```
223
+
224
+ ### Presenter View ショートカット
225
+
226
+ | キー | 動作 |
227
+ |---|---|
228
+ | `← / →` | スライド移動 |
229
+ | `Space / PageDown` | 次のスライド |
230
+ | `Home / End` | 最初 / 最後のスライド |
135
231
 
136
232
  ## Keyboard Shortcuts
137
233
 
@@ -143,6 +239,10 @@ paginate: true
143
239
  | Cmd/Ctrl + P | PDF出力 |
144
240
  | Cmd/Ctrl + W | タブを閉じる |
145
241
  | ← / → | スライド移動(Marp時) |
242
+ | F | フルスクリーン切替(Marp時) |
243
+ | N | ナビバー表示切替(Marp時) |
244
+ | **P** | **Presenter View 起動(Marp時)** |
245
+ | Esc | フルスクリーン解除 |
146
246
  | F2 | ファイル名変更 |
147
247
  | Delete | ファイル削除 |
148
248
 
@@ -150,7 +250,7 @@ paginate: true
150
250
 
151
251
  | Endpoint | Method | Description |
152
252
  |----------|--------|-------------|
153
- | `/api/file` | GET | ファイル内容取得 |
253
+ | `/api/file` | GET | ファイル内容取得 (Marp 時は etag/notes/notesMultiplicity も同梱) |
154
254
  | `/api/file` | POST | ファイル保存 |
155
255
  | `/api/file` | DELETE | ファイル/ディレクトリ削除 |
156
256
  | `/api/tree` | GET | ファイルツリー取得 |
@@ -159,7 +259,12 @@ paginate: true
159
259
  | `/api/move` | POST | ファイル移動/リネーム |
160
260
  | `/api/download` | GET | ファイルダウンロード |
161
261
  | `/api/upload` | POST | ファイルアップロード |
262
+ | `/api/pdf/export` | POST | PDF出力 |
162
263
  | `/api/info` | GET | サーバー情報 |
264
+ | `/api/marp/decks/:path` | GET | Marp デッキ情報取得 (etag, notes, notesMultiplicity) |
265
+ | `/api/marp/decks/:path/slides/:N/note` | PUT | スピーカーノート更新 (`If-Match` 必須、ETag 楽観ロック) |
266
+
267
+ `/api/marp/decks/*` は Origin / Sec-Fetch-Site / Content-Type を厳密に検証し、cross-origin / cross-site / non-JSON リクエストは `403 ORIGIN_REJECTED` または `415 UNSUPPORTED_MEDIA_TYPE` で拒否します(CSRF / DNS rebinding 防御)。
163
268
 
164
269
  ## Tech Stack
165
270
 
@@ -192,28 +297,54 @@ npm test
192
297
 
193
298
  ```
194
299
  mdv/
195
- ├── bin/mdv.js # CLI entry point
300
+ ├── bin/mdv.js # CLI entry point
196
301
  ├── src/
197
- │ ├── server.js # Express server setup
198
- │ ├── watcher.js # File watching (chokidar)
302
+ │ ├── server.js # Express server setup
303
+ │ ├── watcher.js # File watching (chokidar)
304
+ │ ├── websocket.js # WebSocket setup
199
305
  │ ├── api/
200
- │ │ ├── file.js # File operations API
201
- │ │ ├── tree.js # File tree API
202
- │ │ └── upload.js # Upload API
306
+ │ │ ├── file.js # File operations API
307
+ │ │ ├── pdf.js # PDF export API
308
+ │ │ ├── tree.js # File tree API
309
+ │ │ ├── upload.js # Upload API
310
+ │ │ ├── marpNote.js # Marp note autosave routes (orchestration)
311
+ │ │ └── marpNote/
312
+ │ │ ├── guards.js # Origin / Host / Content-Type / If-Match guards
313
+ │ │ ├── readDeck.js # Path-safe deck reader (O_NOFOLLOW + realpath)
314
+ │ │ ├── handleGet.js # GET /api/marp/decks/:path
315
+ │ │ └── handlePut.js # PUT /api/marp/decks/:path/slides/:N/note
203
316
  │ ├── rendering/
204
- │ │ ├── index.js # Rendering entry
205
- │ │ ├── markdown.js # Markdown rendering
206
- │ │ └── marp.js # Marp rendering
317
+ │ │ ├── index.js # Rendering entry
318
+ │ │ ├── markdown.js # Markdown rendering
319
+ │ │ ├── marp.js # Marp rendering (delegates to adapter)
320
+ │ │ ├── marpitAdapter.js # Marpit token adapter (SSOT)
321
+ │ │ └── marpNoteWriter.js # Pure-function note splice
322
+ │ ├── concurrency/
323
+ │ │ └── pathLock.js # Promise-chain mutex (per-path serialization)
207
324
  │ ├── utils/
208
- │ │ ├── fileTypes.js # File type detection
209
- │ │ └── path.js # Path security utilities
210
- └── static/ # Frontend files
211
- ├── index.html
212
- ├── app.js
213
- └── styles.css
325
+ │ │ ├── errors.js # Error codes / status mapping (SSOT)
326
+ │ │ ├── etag.js # sha256 ETag (SSOT)
327
+ │ ├── lineMath.js # BOM / CRLF / line ↔ byte conversion
328
+ ├── atomicWrite.js # Atomic file write (O_EXCL + EXDEV fallback)
329
+ ├── fileTypes.js # File type detection
330
+ └── path.js # Path security (validatePath / validatePathReal)
331
+ │ ├── static/ # Frontend files
332
+ │ │ ├── index.html
333
+ │ │ ├── app.js
334
+ │ │ ├── presenter.html # Presenter View (3-pane + autosave)
335
+ │ │ ├── styles.css
336
+ │ │ └── lib/
337
+ │ │ ├── apiClient.js # HTTP client wrapper
338
+ │ │ ├── presenterChannel.js # BroadcastChannel SSOT
339
+ │ │ ├── saveQueue.js # Per-deck save queue + per-slide coalesce
340
+ │ │ └── tabRegistry.js # Tab life-cycle hooks
341
+ │ └── styles/
342
+ │ ├── index.js
343
+ │ ├── report.example.css
344
+ │ └── report.pdf-options.example.json
214
345
  ├── scripts/
215
- │ └── setup-macos-app.sh # macOS app setup
216
- └── tests/ # Test files
346
+ │ └── setup-macos-app.sh # macOS app setup
347
+ └── tests/ # Test files (236 件、全 PASS)
217
348
  ```
218
349
 
219
350
  ## Requirements
package/bin/mdv.js CHANGED
@@ -16,66 +16,39 @@ import { parseArgs } from 'node:util';
16
16
  import open from 'open';
17
17
 
18
18
  import { createMdvServer } from '../src/server.js';
19
+ import { resolvePdfOptions, resolveStyle } from '../src/styles/index.js';
19
20
 
20
21
  const DEFAULT_PORT = 8642;
21
22
  const MARP_FRONTMATTER_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
22
23
 
23
- const OPTIONS = {
24
- port: {
25
- type: 'string',
26
- short: 'p',
27
- },
28
- depth: {
29
- type: 'string',
30
- short: 'd',
31
- },
32
- 'no-browser': {
33
- type: 'boolean',
34
- default: false
35
- },
36
- list: {
37
- type: 'boolean',
38
- short: 'l',
39
- default: false
40
- },
41
- kill: {
42
- type: 'boolean',
43
- short: 'k',
44
- default: false
45
- },
46
- all: {
47
- type: 'boolean',
48
- short: 'a',
49
- default: false
50
- },
51
- pdf: {
52
- type: 'boolean',
53
- default: false
54
- },
55
- output: {
56
- type: 'string',
57
- short: 'o',
58
- },
59
- help: {
60
- type: 'boolean',
61
- short: 'h',
62
- default: false
63
- },
64
- version: {
65
- type: 'boolean',
66
- short: 'v',
67
- default: false
68
- }
24
+ const VIEWER_OPTIONS = {
25
+ port: { type: 'string', short: 'p' },
26
+ depth: { type: 'string', short: 'd' },
27
+ 'no-browser': { type: 'boolean', default: false },
28
+ list: { type: 'boolean', short: 'l', default: false },
29
+ kill: { type: 'boolean', short: 'k', default: false },
30
+ all: { type: 'boolean', short: 'a', default: false },
31
+ help: { type: 'boolean', short: 'h', default: false },
32
+ version: { type: 'boolean', short: 'v', default: false },
33
+ };
34
+
35
+ const CONVERT_OPTIONS = {
36
+ input: { type: 'string', short: 'i' },
37
+ output: { type: 'string', short: 'o' },
38
+ style: { type: 'string', short: 's' },
39
+ 'pdf-options': { type: 'string' },
40
+ help: { type: 'boolean', short: 'h', default: false },
69
41
  };
70
42
 
71
43
  /**
72
- * Display help message
44
+ * Display viewer help message
73
45
  */
74
46
  function showHelp() {
75
47
  console.log(`
76
48
  MDV - Markdown Viewer with file tree + live preview + Marp support
77
49
 
78
50
  Usage: mdv [options] [path]
51
+ mdv convert -i <file.md> -o <file.pdf>
79
52
 
80
53
  Arguments:
81
54
  path Directory or file path to view (default: current directory)
@@ -90,22 +63,42 @@ Server Management:
90
63
  -k, --kill [PID] Stop server (-k -a for all, -k <PID> for specific)
91
64
  -a, --all Use with -k to stop all servers
92
65
 
93
- PDF Conversion:
94
- --pdf Convert markdown file to PDF
95
- -o, --output <file> Output PDF file path
96
-
97
66
  Other:
98
67
  -h, --help Show this help message
99
68
  -v, --version Show version number
100
69
 
101
70
  Examples:
102
- mdv Start viewer in current directory
103
- mdv /path/to/dir Start viewer in specified directory
104
- mdv README.md Open specific file
105
- mdv --pdf README.md Convert markdown to PDF
106
- mdv -p 3000 Start on port 3000
107
- mdv -l List running servers
108
- mdv -k -a Stop all servers
71
+ mdv Start viewer in current directory
72
+ mdv /path/to/dir Start viewer in specified directory
73
+ mdv README.md Open specific file
74
+ mdv convert -i s.md -o s.pdf Convert markdown to PDF
75
+ mdv -p 3000 Start on port 3000
76
+ mdv -l List running servers
77
+ mdv -k -a Stop all servers
78
+ `);
79
+ }
80
+
81
+ /**
82
+ * Display convert subcommand help message
83
+ */
84
+ function showConvertHelp() {
85
+ console.log(`
86
+ MDV convert - Convert markdown to PDF
87
+
88
+ Usage: mdv convert -i <input.md> -o <output.pdf> [options]
89
+
90
+ Options:
91
+ -i, --input <file> Input markdown file (.md or .markdown)
92
+ -o, --output <file> Output PDF file (default: same name as input)
93
+ -s, --style <preset> Built-in preset or custom CSS file path
94
+ Built-in presets: default
95
+ --pdf-options <file> JSON file with Puppeteer PDF options
96
+ -h, --help Show this help message
97
+
98
+ Examples:
99
+ mdv convert -i slide.md -o slide.pdf
100
+ mdv convert -i README.md -s ./src/styles/report.example.css --pdf-options ./src/styles/report.pdf-options.example.json
101
+ mdv convert -i doc.md -o out.pdf -s ./my-style.css
109
102
  `);
110
103
  }
111
104
 
@@ -244,13 +237,15 @@ function isMarpFile(content) {
244
237
 
245
238
  /**
246
239
  * Convert markdown to PDF using appropriate tool
247
- * - Marp slides: use marp-cli
248
- * - Regular markdown: use md-to-pdf for A4 document format
240
+ * - Marp slides: use marp-cli (style option ignored)
241
+ * - Regular markdown: use md-to-pdf with optional style preset
249
242
  * @param {string} inputPath - Input markdown file path
250
243
  * @param {string} [outputPath] - Output PDF file path
244
+ * @param {string} [styleArg] - Style preset name or CSS file path
245
+ * @param {string} [pdfOptionsPath] - JSON file with Puppeteer PDF options
251
246
  * @returns {Promise<number>} Exit code (0 = success, 1 = error)
252
247
  */
253
- async function convertToPdf(inputPath, outputPath) {
248
+ async function convertToPdf(inputPath, outputPath, styleArg, pdfOptionsPath) {
254
249
  const resolved = path.resolve(inputPath);
255
250
 
256
251
  const fileExists = await fs.access(resolved).then(() => true).catch(() => false);
@@ -275,7 +270,20 @@ async function convertToPdf(inputPath, outputPath) {
275
270
  if (isMarp) {
276
271
  return convertMarpToPdf(resolved, finalOutput);
277
272
  }
278
- return convertMarkdownToPdf(resolved, finalOutput);
273
+
274
+ let styleConfig;
275
+ try {
276
+ styleConfig = await resolveStyle(styleArg);
277
+ styleConfig = {
278
+ ...styleConfig,
279
+ pdfOptions: await resolvePdfOptions(pdfOptionsPath, styleConfig.pdfOptions),
280
+ };
281
+ } catch {
282
+ console.error(`Error: Style or PDF options not found: ${styleArg || pdfOptionsPath}`);
283
+ return 1;
284
+ }
285
+
286
+ return convertMarkdownToPdf(resolved, finalOutput, styleConfig);
279
287
  }
280
288
 
281
289
  /**
@@ -299,20 +307,35 @@ async function convertMarpToPdf(inputPath, outputPath) {
299
307
  }
300
308
 
301
309
  /**
302
- * Convert regular markdown to PDF using md-to-pdf (A4 format)
310
+ * Convert regular markdown to PDF using md-to-pdf
303
311
  * @param {string} inputPath - Resolved input file path
304
312
  * @param {string} outputPath - Resolved output file path
313
+ * @param {import('../src/styles/index.js').StyleConfig} styleConfig - Style preset
305
314
  * @returns {Promise<number>} Exit code
306
315
  */
307
- async function convertMarkdownToPdf(inputPath, outputPath) {
316
+ async function convertMarkdownToPdf(inputPath, outputPath, styleConfig) {
308
317
  console.log('Converting as document (A4 portrait)...');
309
318
 
310
319
  try {
311
- const pdfOptions = '{"format":"A4","margin":{"top":"20mm","right":"20mm","bottom":"20mm","left":"20mm"}}';
312
- execFileSync('npx', ['md-to-pdf', inputPath, '--pdf-options', pdfOptions], {
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, {
313
336
  encoding: 'utf-8',
314
337
  stdio: 'inherit',
315
- cwd: path.dirname(inputPath)
338
+ cwd: path.dirname(inputPath),
316
339
  });
317
340
 
318
341
  // md-to-pdf outputs to same directory with .pdf extension
@@ -431,15 +454,34 @@ async function startViewer(targetPath, startPort, openBrowser, depth) {
431
454
  }
432
455
 
433
456
  /**
434
- * Parse command line arguments safely
457
+ * Parse arguments for the convert subcommand
435
458
  * @returns {{values: object, positionals: string[]}}
436
459
  */
437
- function parseCommandLineArgs() {
460
+ function parseConvertArgs() {
438
461
  try {
439
462
  return parseArgs({
440
- options: OPTIONS,
463
+ args: process.argv.slice(3), // skip node, mdv.js, "convert"
464
+ options: CONVERT_OPTIONS,
465
+ allowPositionals: false,
466
+ strict: false,
467
+ });
468
+ } catch (err) {
469
+ console.error('Error parsing arguments:', err.message);
470
+ showConvertHelp();
471
+ process.exit(1);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Parse viewer command line arguments safely
477
+ * @returns {{values: object, positionals: string[]}}
478
+ */
479
+ function parseViewerArgs() {
480
+ try {
481
+ return parseArgs({
482
+ options: VIEWER_OPTIONS,
441
483
  allowPositionals: true,
442
- strict: false
484
+ strict: false,
443
485
  });
444
486
  } catch (err) {
445
487
  console.error('Error parsing arguments:', err.message);
@@ -448,11 +490,38 @@ function parseCommandLineArgs() {
448
490
  }
449
491
  }
450
492
 
493
+ /**
494
+ * Handle the convert subcommand
495
+ */
496
+ async function runConvert() {
497
+ const { values } = parseConvertArgs();
498
+
499
+ if (values.help) {
500
+ showConvertHelp();
501
+ process.exit(0);
502
+ }
503
+
504
+ if (!values.input) {
505
+ console.error('Error: -i <file.md> is required');
506
+ showConvertHelp();
507
+ process.exit(1);
508
+ }
509
+
510
+ process.exit(await convertToPdf(values.input, values.output, values.style, values['pdf-options']));
511
+ }
512
+
451
513
  /**
452
514
  * Main entry point
453
515
  */
454
516
  async function main() {
455
- const { values, positionals } = parseCommandLineArgs();
517
+ const subcommand = process.argv[2];
518
+
519
+ if (subcommand === 'convert') {
520
+ await runConvert();
521
+ return;
522
+ }
523
+
524
+ const { values, positionals } = parseViewerArgs();
456
525
 
457
526
  if (values.help) {
458
527
  showHelp();
@@ -477,15 +546,6 @@ async function main() {
477
546
  process.exit(killServers(pid, values.all));
478
547
  }
479
548
 
480
- if (values.pdf) {
481
- const inputPath = positionals[0];
482
- if (!inputPath) {
483
- console.error('Error: --pdf requires a markdown file path');
484
- process.exit(1);
485
- }
486
- process.exit(await convertToPdf(inputPath, values.output));
487
- }
488
-
489
549
  // Default: start viewer
490
550
  const targetPath = positionals[0] || '.';
491
551
  const port = parseInt(values.port, 10) || DEFAULT_PORT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdv-live",
3
- "version": "0.5.5",
3
+ "version": "0.5.8",
4
4
  "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
5
  "main": "src/server.js",
6
6
  "bin": {