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/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/bin/mdv.js +400 -0
- package/package.json +62 -0
- package/scripts/setup-macos-app.sh +172 -0
- package/src/api/file.js +243 -0
- package/src/api/pdf.js +74 -0
- package/src/api/tree.js +111 -0
- package/src/api/upload.js +70 -0
- package/src/rendering/index.js +98 -0
- package/src/rendering/markdown.js +126 -0
- package/src/rendering/marp.js +43 -0
- package/src/server.js +109 -0
- package/src/static/app.js +1883 -0
- package/src/static/favicon.ico +0 -0
- package/src/static/images/icon-128.png +0 -0
- package/src/static/images/icon-256.png +0 -0
- package/src/static/images/icon-32.png +0 -0
- package/src/static/images/icon-512.png +0 -0
- package/src/static/images/icon-64.png +0 -0
- package/src/static/images/icon.png +0 -0
- package/src/static/index.html +123 -0
- package/src/static/styles.css +1026 -0
- package/src/utils/fileTypes.js +148 -0
- package/src/utils/path.js +48 -0
- package/src/watcher.js +97 -0
- package/src/websocket.js +75 -0
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 ""
|
package/src/api/file.js
ADDED
|
@@ -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;
|
package/src/api/tree.js
ADDED
|
@@ -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;
|