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 +102 -0
- package/README.md +154 -23
- package/bin/mdv.js +141 -81
- package/package.json +1 -1
- package/src/api/marpNote/guards.js +79 -0
- package/src/api/marpNote/handleGet.js +65 -0
- package/src/api/marpNote/handlePut.js +162 -0
- package/src/api/marpNote/readDeck.js +42 -0
- package/src/api/marpNote.js +40 -0
- package/src/api/pdf.js +65 -8
- package/src/concurrency/pathLock.js +39 -0
- package/src/rendering/index.js +9 -1
- package/src/rendering/markdown.js +4 -11
- package/src/rendering/marp.js +11 -32
- package/src/rendering/marpNoteWriter.js +156 -0
- package/src/rendering/marpitAdapter.js +139 -0
- package/src/server.js +29 -4
- package/src/static/app.js +369 -22
- package/src/static/index.html +24 -0
- package/src/static/lib/apiClient.js +73 -0
- package/src/static/lib/presenterChannel.js +33 -0
- package/src/static/lib/saveQueue.js +71 -0
- package/src/static/lib/tabRegistry.js +32 -0
- package/src/static/presenter.html +687 -0
- package/src/static/styles.css +34 -0
- package/src/styles/index.js +90 -0
- package/src/styles/report.example.css +201 -0
- package/src/styles/report.pdf-options.example.json +10 -0
- package/src/utils/atomicWrite.js +159 -0
- package/src/utils/errors.js +50 -0
- package/src/utils/etag.js +11 -0
- package/src/utils/lineMath.js +86 -0
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
|
|
63
|
-
|
|
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
|
|
300
|
+
├── bin/mdv.js # CLI entry point
|
|
196
301
|
├── src/
|
|
197
|
-
│ ├── server.js
|
|
198
|
-
│ ├── watcher.js
|
|
302
|
+
│ ├── server.js # Express server setup
|
|
303
|
+
│ ├── watcher.js # File watching (chokidar)
|
|
304
|
+
│ ├── websocket.js # WebSocket setup
|
|
199
305
|
│ ├── api/
|
|
200
|
-
│ │ ├── file.js
|
|
201
|
-
│ │ ├──
|
|
202
|
-
│ │
|
|
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
|
|
205
|
-
│ │ ├── markdown.js
|
|
206
|
-
│ │
|
|
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
|
-
│ │ ├──
|
|
209
|
-
│ │
|
|
210
|
-
│
|
|
211
|
-
│
|
|
212
|
-
│
|
|
213
|
-
│
|
|
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
|
|
216
|
-
└── tests/
|
|
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
|
|
24
|
-
port: {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
103
|
-
mdv /path/to/dir
|
|
104
|
-
mdv README.md
|
|
105
|
-
mdv
|
|
106
|
-
mdv -p 3000
|
|
107
|
-
mdv -l
|
|
108
|
-
mdv -k -a
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
312
|
-
|
|
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
|
|
457
|
+
* Parse arguments for the convert subcommand
|
|
435
458
|
* @returns {{values: object, positionals: string[]}}
|
|
436
459
|
*/
|
|
437
|
-
function
|
|
460
|
+
function parseConvertArgs() {
|
|
438
461
|
try {
|
|
439
462
|
return parseArgs({
|
|
440
|
-
|
|
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
|
|
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;
|