mdv-live 0.3.0

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/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "mdv-live",
3
+ "version": "0.3.0",
4
+ "description": "Markdown Viewer - File tree + Live preview + Marp support + Hot reload",
5
+ "main": "src/server.js",
6
+ "bin": {
7
+ "mdv": "./bin/mdv.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/mdv.js",
11
+ "dev": "node bin/mdv.js --dev",
12
+ "test": "node --test tests/*.js",
13
+ "setup-macos": "bash scripts/setup-macos-app.sh"
14
+ },
15
+ "keywords": [
16
+ "markdown",
17
+ "viewer",
18
+ "marp",
19
+ "slides",
20
+ "preview",
21
+ "live-reload",
22
+ "hot-reload",
23
+ "file-tree"
24
+ ],
25
+ "author": "PanHouse <hirono.okamoto@panhouse.jp>",
26
+ "license": "MIT",
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=18.0.0"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/panhouse/mdv.git"
34
+ },
35
+ "homepage": "https://github.com/panhouse/mdv#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/panhouse/mdv/issues"
38
+ },
39
+ "files": [
40
+ "bin/",
41
+ "src/",
42
+ "scripts/",
43
+ "README.md",
44
+ "CHANGELOG.md",
45
+ "LICENSE"
46
+ ],
47
+ "dependencies": {
48
+ "express": "^4.21.2",
49
+ "ws": "^8.18.0",
50
+ "chokidar": "^4.0.3",
51
+ "markdown-it": "^14.1.0",
52
+ "markdown-it-task-lists": "^2.1.1",
53
+ "@marp-team/marp-core": "^4.0.0",
54
+ "highlight.js": "^11.10.0",
55
+ "open": "^10.1.0",
56
+ "mime-types": "^2.1.35",
57
+ "multer": "^1.4.5-lts.1"
58
+ },
59
+ "optionalDependencies": {
60
+ "@marp-team/marp-cli": "^4.0.3"
61
+ }
62
+ }
@@ -0,0 +1,172 @@
1
+ #!/bin/bash
2
+ # MDV macOS App Setup Script
3
+ # Finderから.mdファイルをダブルクリックでMDVを開けるようにする
4
+
5
+ set -e
6
+
7
+ echo "=== MDV macOS App Setup ==="
8
+ echo ""
9
+
10
+ # mdvコマンドの場所を確認
11
+ MDV_PATH=$(which mdv 2>/dev/null || true)
12
+
13
+ if [ -z "$MDV_PATH" ]; then
14
+ echo "Error: mdv command not found."
15
+ echo "Please install mdv first: npm install -g mdv-live"
16
+ exit 1
17
+ fi
18
+
19
+ echo "Found mdv at: $MDV_PATH"
20
+ echo ""
21
+
22
+ # 一時ディレクトリ
23
+ TEMP_DIR=$(mktemp -d)
24
+ APP_NAME="MDV.app"
25
+ APP_PATH="/Applications/$APP_NAME"
26
+
27
+ # AppleScript作成
28
+ cat << EOF > "$TEMP_DIR/MDV.applescript"
29
+ -- MDV Markdown Viewer Launcher
30
+
31
+ on open theFiles
32
+ repeat with theFile in theFiles
33
+ set filePath to POSIX path of theFile
34
+ -- Run launcher (will return after opening browser)
35
+ do shell script "/Applications/MDV.app/Contents/Resources/launch.sh " & quoted form of filePath
36
+ end repeat
37
+ end open
38
+
39
+ on run
40
+ display dialog "MDV Markdown Viewer
41
+
42
+ Usage:
43
+ - Double-click any .md file
44
+ - Drag & drop .md files onto this app
45
+
46
+ Version: 0.3.0 (Node.js)" buttons {"OK"} default button "OK" with title "MDV"
47
+ end run
48
+ EOF
49
+
50
+ echo "Compiling AppleScript..."
51
+ osacompile -o "$TEMP_DIR/$APP_NAME" "$TEMP_DIR/MDV.applescript"
52
+
53
+ # Create launcher script
54
+ echo "Creating launcher script..."
55
+ cat > "$TEMP_DIR/$APP_NAME/Contents/Resources/launch.sh" << 'LAUNCHSCRIPT'
56
+ #!/bin/bash
57
+ # Set PATH for node (AppleScript environment doesn't have user's PATH)
58
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
59
+
60
+ exec >> /tmp/mdv-debug.log 2>&1
61
+ echo "=== $(date) ==="
62
+ echo "FILE_PATH: $1"
63
+
64
+ FILE_PATH="$1"
65
+ FILE_NAME=$(basename "$FILE_PATH")
66
+ LOG="/tmp/mdv-$$.log"
67
+
68
+ echo "Starting MDV..."
69
+ # Start MDV
70
+ MDV_PATH_HERE --no-browser "$FILE_PATH" > "$LOG" 2>&1 &
71
+
72
+ # Wait for server (max 5 sec)
73
+ for i in {1..25}; do
74
+ sleep 0.2
75
+ PORT=$(grep -o 'localhost:[0-9]*' "$LOG" 2>/dev/null | head -1 | cut -d: -f2)
76
+ if [ -n "$PORT" ]; then
77
+ echo "Found port: $PORT"
78
+ echo "Opening browser..."
79
+ # Use osascript to open URL (works better from AppleScript context)
80
+ osascript -e "open location \"http://localhost:$PORT?file=$FILE_NAME\""
81
+ echo "Done"
82
+ exit 0
83
+ fi
84
+ done
85
+
86
+ echo "Timeout - using fallback"
87
+ # Fallback
88
+ osascript -e "open location \"http://localhost:8642?file=$FILE_NAME\""
89
+ LAUNCHSCRIPT
90
+
91
+ # Replace placeholder with actual path
92
+ sed -i '' "s|MDV_PATH_HERE|$MDV_PATH|g" "$TEMP_DIR/$APP_NAME/Contents/Resources/launch.sh"
93
+ chmod +x "$TEMP_DIR/$APP_NAME/Contents/Resources/launch.sh"
94
+
95
+ # Info.plist設定
96
+ cat << 'EOF' > "$TEMP_DIR/$APP_NAME/Contents/Info.plist"
97
+ <?xml version="1.0" encoding="UTF-8"?>
98
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
+ <plist version="1.0">
100
+ <dict>
101
+ <key>CFBundleIdentifier</key>
102
+ <string>com.panhouse.mdv</string>
103
+ <key>CFBundleDocumentTypes</key>
104
+ <array>
105
+ <dict>
106
+ <key>CFBundleTypeName</key>
107
+ <string>Markdown Document</string>
108
+ <key>CFBundleTypeRole</key>
109
+ <string>Viewer</string>
110
+ <key>LSItemContentTypes</key>
111
+ <array>
112
+ <string>net.daringfireball.markdown</string>
113
+ <string>public.plain-text</string>
114
+ </array>
115
+ <key>CFBundleTypeExtensions</key>
116
+ <array>
117
+ <string>md</string>
118
+ <string>markdown</string>
119
+ <string>mdown</string>
120
+ </array>
121
+ </dict>
122
+ </array>
123
+ <key>CFBundleExecutable</key>
124
+ <string>droplet</string>
125
+ <key>CFBundleName</key>
126
+ <string>MDV</string>
127
+ <key>CFBundlePackageType</key>
128
+ <string>APPL</string>
129
+ <key>CFBundleShortVersionString</key>
130
+ <string>0.3.0</string>
131
+ <key>CFBundleVersion</key>
132
+ <string>0.3.0</string>
133
+ <key>LSMinimumSystemVersion</key>
134
+ <string>10.13</string>
135
+ </dict>
136
+ </plist>
137
+ EOF
138
+
139
+ echo "Signing app..."
140
+ codesign --force --deep --sign - "$TEMP_DIR/$APP_NAME"
141
+
142
+ # 既存のアプリを削除してインストール
143
+ if [ -d "$APP_PATH" ]; then
144
+ echo "Removing existing $APP_PATH..."
145
+ rm -rf "$APP_PATH"
146
+ fi
147
+
148
+ echo "Installing to $APP_PATH..."
149
+ cp -R "$TEMP_DIR/$APP_NAME" "$APP_PATH"
150
+
151
+ # Remove quarantine attribute to prevent Gatekeeper warning
152
+ echo "Removing quarantine attribute..."
153
+ xattr -cr "$APP_PATH"
154
+
155
+ # LaunchServices登録
156
+ echo "Registering with LaunchServices..."
157
+ /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$APP_PATH"
158
+
159
+ # クリーンアップ
160
+ rm -rf "$TEMP_DIR"
161
+
162
+ echo ""
163
+ echo "=== Setup Complete ==="
164
+ echo ""
165
+ echo "MDV.app has been installed to /Applications"
166
+ echo ""
167
+ echo "To set MDV as the default app for .md files:"
168
+ echo "1. Right-click any .md file in Finder"
169
+ echo "2. Select 'Get Info'"
170
+ echo "3. Under 'Open with', select 'MDV'"
171
+ echo "4. Click 'Change All...'"
172
+ echo ""
@@ -0,0 +1,243 @@
1
+ /**
2
+ * File operations API routes
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import { createReadStream } from 'fs';
7
+ import path from 'path';
8
+ import mime from 'mime-types';
9
+ import { getFileType } from '../utils/fileTypes.js';
10
+ import { renderFile } from '../rendering/index.js';
11
+ import { validatePath } from '../utils/path.js';
12
+
13
+ /**
14
+ * Broadcast tree_update to all WebSocket clients
15
+ * @param {Express} app - Express app instance
16
+ */
17
+ function broadcastTreeUpdate(app) {
18
+ const wss = app.locals.wss;
19
+ if (wss) {
20
+ const message = JSON.stringify({ type: 'tree_update' });
21
+ wss.clients.forEach(client => {
22
+ if (client.readyState === 1) { // WebSocket.OPEN
23
+ client.send(message);
24
+ }
25
+ });
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Setup file routes
31
+ * @param {Express} app - Express app instance
32
+ */
33
+ export function setupFileRoutes(app) {
34
+ // Get file content
35
+ app.get('/api/file', async (req, res) => {
36
+ try {
37
+ const { path: relativePath } = req.query;
38
+ if (!relativePath) {
39
+ return res.status(400).json({ error: 'Path is required' });
40
+ }
41
+
42
+ // Security check (must use relativePath, not fullPath)
43
+ if (!validatePath(relativePath, app.locals.rootDir)) {
44
+ return res.status(403).json({ error: 'Access denied' });
45
+ }
46
+
47
+ const fullPath = path.join(app.locals.rootDir, relativePath);
48
+ const stats = await fs.stat(fullPath);
49
+ if (stats.isDirectory()) {
50
+ return res.status(400).json({ error: 'Cannot read directory' });
51
+ }
52
+
53
+ const fileType = getFileType(relativePath);
54
+ const name = path.basename(relativePath);
55
+
56
+ // Handle binary files
57
+ if (fileType.binary) {
58
+ return res.json({
59
+ name,
60
+ fileType: fileType.type,
61
+ icon: fileType.icon,
62
+ downloadUrl: `/api/download?path=${encodeURIComponent(relativePath)}`,
63
+ // Special URLs for media types
64
+ ...(fileType.type === 'image' && {
65
+ imageUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
66
+ }),
67
+ ...(fileType.type === 'pdf' && {
68
+ pdfUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
69
+ }),
70
+ ...(['video', 'audio'].includes(fileType.type) && {
71
+ mediaUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
72
+ })
73
+ });
74
+ }
75
+
76
+ // Read and render text files
77
+ const rendered = await renderFile(fullPath);
78
+
79
+ res.json({
80
+ name,
81
+ ...rendered
82
+ });
83
+ } catch (err) {
84
+ if (err.code === 'ENOENT') {
85
+ return res.status(404).json({ error: 'File not found' });
86
+ }
87
+ res.status(500).json({ error: err.message });
88
+ }
89
+ });
90
+
91
+ // Save file content
92
+ app.post('/api/file', async (req, res) => {
93
+ try {
94
+ const { path: relativePath, content } = req.body;
95
+ if (!relativePath) {
96
+ return res.status(400).json({ error: 'Path is required' });
97
+ }
98
+
99
+ // Security check (must use relativePath, not fullPath)
100
+ if (!validatePath(relativePath, app.locals.rootDir)) {
101
+ return res.status(403).json({ error: 'Access denied' });
102
+ }
103
+
104
+ const fullPath = path.join(app.locals.rootDir, relativePath);
105
+ await fs.writeFile(fullPath, content, 'utf-8');
106
+ broadcastTreeUpdate(app);
107
+ res.json({ success: true });
108
+ } catch (err) {
109
+ res.status(500).json({ error: err.message });
110
+ }
111
+ });
112
+
113
+ // Delete file or directory
114
+ app.delete('/api/file', async (req, res) => {
115
+ try {
116
+ const { path: relativePath } = req.query;
117
+ if (!relativePath) {
118
+ return res.status(400).json({ error: 'Path is required' });
119
+ }
120
+
121
+ // Security check (must use relativePath, not fullPath)
122
+ if (!validatePath(relativePath, app.locals.rootDir)) {
123
+ return res.status(403).json({ error: 'Access denied' });
124
+ }
125
+
126
+ const fullPath = path.join(app.locals.rootDir, relativePath);
127
+ const stats = await fs.stat(fullPath);
128
+ if (stats.isDirectory()) {
129
+ await fs.rm(fullPath, { recursive: true });
130
+ } else {
131
+ await fs.unlink(fullPath);
132
+ }
133
+
134
+ broadcastTreeUpdate(app);
135
+ res.json({ success: true });
136
+ } catch (err) {
137
+ if (err.code === 'ENOENT') {
138
+ return res.status(404).json({ error: 'File not found' });
139
+ }
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ });
143
+
144
+ // Create directory
145
+ app.post('/api/mkdir', async (req, res) => {
146
+ try {
147
+ const { path: relativePath } = req.body;
148
+ if (!relativePath) {
149
+ return res.status(400).json({ error: 'Path is required' });
150
+ }
151
+
152
+ // Security check (must use relativePath, not fullPath)
153
+ if (!validatePath(relativePath, app.locals.rootDir)) {
154
+ return res.status(403).json({ error: 'Access denied' });
155
+ }
156
+
157
+ const fullPath = path.join(app.locals.rootDir, relativePath);
158
+ await fs.mkdir(fullPath, { recursive: true });
159
+ broadcastTreeUpdate(app);
160
+ res.json({ success: true });
161
+ } catch (err) {
162
+ res.status(500).json({ error: err.message });
163
+ }
164
+ });
165
+
166
+ // Move/rename file or directory
167
+ app.post('/api/move', async (req, res) => {
168
+ try {
169
+ const { source, destination } = req.body;
170
+ if (!source || !destination) {
171
+ return res.status(400).json({ error: 'Source and destination are required' });
172
+ }
173
+
174
+ // Security check (must use relative paths, not full paths)
175
+ if (!validatePath(source, app.locals.rootDir) ||
176
+ !validatePath(destination, app.locals.rootDir)) {
177
+ return res.status(403).json({ error: 'Access denied' });
178
+ }
179
+
180
+ const sourcePath = path.join(app.locals.rootDir, source);
181
+ const destPath = path.join(app.locals.rootDir, destination);
182
+ await fs.rename(sourcePath, destPath);
183
+ broadcastTreeUpdate(app);
184
+ res.json({ success: true });
185
+ } catch (err) {
186
+ res.status(500).json({ error: err.message });
187
+ }
188
+ });
189
+
190
+ // Download file (with Range Request support for video/audio streaming)
191
+ app.get('/api/download', async (req, res) => {
192
+ const { path: relativePath } = req.query;
193
+ if (!relativePath) {
194
+ return res.status(400).json({ error: 'Path is required' });
195
+ }
196
+
197
+ // Security check (must use relativePath, not fullPath)
198
+ if (!validatePath(relativePath, app.locals.rootDir)) {
199
+ return res.status(403).json({ error: 'Access denied' });
200
+ }
201
+
202
+ const fullPath = path.join(app.locals.rootDir, relativePath);
203
+
204
+ try {
205
+ const stat = await fs.stat(fullPath);
206
+ if (!stat.isFile()) {
207
+ return res.status(400).json({ error: 'Not a file' });
208
+ }
209
+
210
+ const fileSize = stat.size;
211
+ const range = req.headers.range;
212
+
213
+ if (range) {
214
+ // Range Request対応(動画/音声ストリーミング用)
215
+ const parts = range.replace(/bytes=/, '').split('-');
216
+ const start = parseInt(parts[0], 10);
217
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
218
+ const chunkSize = end - start + 1;
219
+
220
+ const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
221
+ res.writeHead(206, {
222
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
223
+ 'Accept-Ranges': 'bytes',
224
+ 'Content-Length': chunkSize,
225
+ 'Content-Type': mimeType,
226
+ });
227
+
228
+ const stream = createReadStream(fullPath, { start, end });
229
+ stream.pipe(res);
230
+ } else {
231
+ // 通常のファイル送信
232
+ res.sendFile(fullPath);
233
+ }
234
+ } catch (err) {
235
+ if (err.code === 'ENOENT') {
236
+ return res.status(404).json({ error: 'File not found' });
237
+ }
238
+ return res.status(500).json({ error: err.message });
239
+ }
240
+ });
241
+ }
242
+
243
+ export default setupFileRoutes;
package/src/api/pdf.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * PDF Export API
3
+ * Uses marp-cli for Marp presentations
4
+ */
5
+
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import path from 'path';
9
+ import fs from 'fs';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const execAsync = promisify(exec);
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ // Get path to local marp-cli binary
17
+ const marpBin = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'marp');
18
+
19
+ /**
20
+ * Setup PDF export routes
21
+ * @param {Express} app - Express application
22
+ */
23
+ export function setupPdfRoutes(app) {
24
+ // Export Marp presentation to PDF
25
+ app.post('/api/pdf/export', async (req, res) => {
26
+ const { filePath } = req.body;
27
+
28
+ if (!filePath) {
29
+ return res.status(400).json({ error: 'filePath is required' });
30
+ }
31
+
32
+ const rootDir = app.locals.rootDir;
33
+ const fullPath = path.join(rootDir, filePath);
34
+
35
+ // Security check: ensure path is within rootDir
36
+ const resolvedPath = path.resolve(fullPath);
37
+ if (!resolvedPath.startsWith(rootDir)) {
38
+ return res.status(403).json({ error: 'Access denied' });
39
+ }
40
+
41
+ // Check if file exists
42
+ if (!fs.existsSync(resolvedPath)) {
43
+ return res.status(404).json({ error: 'File not found' });
44
+ }
45
+
46
+ // Generate output path (same directory, .pdf extension)
47
+ const outputPath = resolvedPath.replace(/\.md$/, '.pdf');
48
+ const outputFileName = path.basename(outputPath);
49
+
50
+ try {
51
+ // Run marp-cli using local binary (faster than npx)
52
+ // --no-stdin prevents waiting for stdin input
53
+ const command = `"${marpBin}" "${resolvedPath}" -o "${outputPath}" --allow-local-files --no-stdin`;
54
+ await execAsync(command, { timeout: 60000 });
55
+
56
+ // Send PDF as download
57
+ res.download(outputPath, outputFileName, (err) => {
58
+ if (err) {
59
+ console.error('Download error:', err);
60
+ }
61
+ // Optionally delete the PDF after download
62
+ // fs.unlinkSync(outputPath);
63
+ });
64
+ } catch (error) {
65
+ console.error('PDF export error:', error);
66
+ res.status(500).json({
67
+ error: 'PDF export failed',
68
+ details: error.message
69
+ });
70
+ }
71
+ });
72
+ }
73
+
74
+ export default setupPdfRoutes;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * File tree API routes
3
+ */
4
+
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import { getFileType } from '../utils/fileTypes.js';
8
+ import { validatePath } from '../utils/path.js';
9
+
10
+ /**
11
+ * Build file tree for a directory
12
+ * @param {string} dirPath - Directory path
13
+ * @param {string} rootDir - Root directory for relative paths
14
+ * @param {number} depth - Current depth (for limiting recursion)
15
+ * @returns {Promise<Array>} Array of file/directory objects
16
+ */
17
+ export async function buildFileTree(dirPath, rootDir, depth = 0) {
18
+ const items = [];
19
+ const maxInitialDepth = 1; // Only expand first level initially
20
+
21
+ try {
22
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
23
+
24
+ // Sort: directories first, then files, alphabetically
25
+ entries.sort((a, b) => {
26
+ if (a.isDirectory() && !b.isDirectory()) return -1;
27
+ if (!a.isDirectory() && b.isDirectory()) return 1;
28
+ return a.name.localeCompare(b.name);
29
+ });
30
+
31
+ for (const entry of entries) {
32
+ // Skip hidden files and common ignore patterns
33
+ if (entry.name.startsWith('.')) continue;
34
+ if (entry.name === 'node_modules') continue;
35
+ if (entry.name === '__pycache__') continue;
36
+
37
+ const fullPath = path.join(dirPath, entry.name);
38
+ const relativePath = path.relative(rootDir, fullPath).split(path.sep).join('/');
39
+
40
+ if (entry.isDirectory()) {
41
+ const item = {
42
+ type: 'directory',
43
+ name: entry.name,
44
+ path: relativePath,
45
+ children: [],
46
+ loaded: false
47
+ };
48
+
49
+ // Load children for first level
50
+ if (depth < maxInitialDepth) {
51
+ item.children = await buildFileTree(fullPath, rootDir, depth + 1);
52
+ item.loaded = true;
53
+ }
54
+
55
+ items.push(item);
56
+ } else {
57
+ const fileType = getFileType(entry.name);
58
+ items.push({
59
+ type: 'file',
60
+ name: entry.name,
61
+ path: relativePath,
62
+ icon: fileType.icon
63
+ });
64
+ }
65
+ }
66
+ } catch (err) {
67
+ console.error(`Error reading directory ${dirPath}:`, err);
68
+ }
69
+
70
+ return items;
71
+ }
72
+
73
+ /**
74
+ * Setup file tree routes
75
+ * @param {Express} app - Express app instance
76
+ */
77
+ export function setupTreeRoutes(app) {
78
+ // Get full tree
79
+ app.get('/api/tree', async (req, res) => {
80
+ try {
81
+ const tree = await buildFileTree(app.locals.rootDir, app.locals.rootDir);
82
+ res.json(tree);
83
+ } catch (err) {
84
+ res.status(500).json({ error: err.message });
85
+ }
86
+ });
87
+
88
+ // Expand a directory (lazy loading)
89
+ app.get('/api/tree/expand', async (req, res) => {
90
+ try {
91
+ const { path: relativePath } = req.query;
92
+ if (!relativePath) {
93
+ return res.status(400).json({ error: 'Path is required' });
94
+ }
95
+
96
+ const fullPath = path.join(app.locals.rootDir, relativePath);
97
+
98
+ // Security: ensure path is within root
99
+ if (!validatePath(relativePath, app.locals.rootDir)) {
100
+ return res.status(403).json({ error: 'Access denied' });
101
+ }
102
+
103
+ const children = await buildFileTree(fullPath, app.locals.rootDir, 0);
104
+ res.json(children);
105
+ } catch (err) {
106
+ res.status(500).json({ error: err.message });
107
+ }
108
+ });
109
+ }
110
+
111
+ export default setupTreeRoutes;