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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File type detection and classification
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// File extension to type mapping
|
|
6
|
+
const FILE_TYPES = {
|
|
7
|
+
// Markdown
|
|
8
|
+
md: { type: 'markdown', icon: 'markdown', lang: null, binary: false },
|
|
9
|
+
markdown: { type: 'markdown', icon: 'markdown', lang: null, binary: false },
|
|
10
|
+
|
|
11
|
+
// Code - Python
|
|
12
|
+
py: { type: 'code', icon: 'python', lang: 'python', binary: false },
|
|
13
|
+
pyw: { type: 'code', icon: 'python', lang: 'python', binary: false },
|
|
14
|
+
|
|
15
|
+
// Code - JavaScript/TypeScript
|
|
16
|
+
js: { type: 'code', icon: 'javascript', lang: 'javascript', binary: false },
|
|
17
|
+
mjs: { type: 'code', icon: 'javascript', lang: 'javascript', binary: false },
|
|
18
|
+
cjs: { type: 'code', icon: 'javascript', lang: 'javascript', binary: false },
|
|
19
|
+
ts: { type: 'code', icon: 'typescript', lang: 'typescript', binary: false },
|
|
20
|
+
tsx: { type: 'code', icon: 'react', lang: 'tsx', binary: false },
|
|
21
|
+
jsx: { type: 'code', icon: 'react', lang: 'jsx', binary: false },
|
|
22
|
+
|
|
23
|
+
// Code - Web
|
|
24
|
+
html: { type: 'code', icon: 'html', lang: 'html', binary: false },
|
|
25
|
+
htm: { type: 'code', icon: 'html', lang: 'html', binary: false },
|
|
26
|
+
css: { type: 'code', icon: 'css', lang: 'css', binary: false },
|
|
27
|
+
scss: { type: 'code', icon: 'css', lang: 'scss', binary: false },
|
|
28
|
+
less: { type: 'code', icon: 'css', lang: 'less', binary: false },
|
|
29
|
+
vue: { type: 'code', icon: 'vue', lang: 'vue', binary: false },
|
|
30
|
+
svelte: { type: 'code', icon: 'default', lang: 'svelte', binary: false },
|
|
31
|
+
|
|
32
|
+
// Data formats
|
|
33
|
+
json: { type: 'code', icon: 'json', lang: 'json', binary: false },
|
|
34
|
+
yaml: { type: 'code', icon: 'yaml', lang: 'yaml', binary: false },
|
|
35
|
+
yml: { type: 'code', icon: 'yaml', lang: 'yaml', binary: false },
|
|
36
|
+
toml: { type: 'code', icon: 'config', lang: 'toml', binary: false },
|
|
37
|
+
xml: { type: 'code', icon: 'default', lang: 'xml', binary: false },
|
|
38
|
+
|
|
39
|
+
// Shell/Config
|
|
40
|
+
sh: { type: 'code', icon: 'shell', lang: 'bash', binary: false },
|
|
41
|
+
bash: { type: 'code', icon: 'shell', lang: 'bash', binary: false },
|
|
42
|
+
zsh: { type: 'code', icon: 'shell', lang: 'bash', binary: false },
|
|
43
|
+
fish: { type: 'code', icon: 'shell', lang: 'bash', binary: false },
|
|
44
|
+
env: { type: 'code', icon: 'config', lang: 'bash', binary: false },
|
|
45
|
+
ini: { type: 'code', icon: 'config', lang: 'ini', binary: false },
|
|
46
|
+
conf: { type: 'code', icon: 'config', lang: 'ini', binary: false },
|
|
47
|
+
|
|
48
|
+
// Other languages
|
|
49
|
+
go: { type: 'code', icon: 'default', lang: 'go', binary: false },
|
|
50
|
+
rs: { type: 'code', icon: 'default', lang: 'rust', binary: false },
|
|
51
|
+
rb: { type: 'code', icon: 'default', lang: 'ruby', binary: false },
|
|
52
|
+
php: { type: 'code', icon: 'default', lang: 'php', binary: false },
|
|
53
|
+
java: { type: 'code', icon: 'default', lang: 'java', binary: false },
|
|
54
|
+
kt: { type: 'code', icon: 'default', lang: 'kotlin', binary: false },
|
|
55
|
+
swift: { type: 'code', icon: 'default', lang: 'swift', binary: false },
|
|
56
|
+
c: { type: 'code', icon: 'default', lang: 'c', binary: false },
|
|
57
|
+
cpp: { type: 'code', icon: 'default', lang: 'cpp', binary: false },
|
|
58
|
+
h: { type: 'code', icon: 'default', lang: 'c', binary: false },
|
|
59
|
+
cs: { type: 'code', icon: 'default', lang: 'csharp', binary: false },
|
|
60
|
+
sql: { type: 'code', icon: 'database', lang: 'sql', binary: false },
|
|
61
|
+
|
|
62
|
+
// Text files
|
|
63
|
+
txt: { type: 'text', icon: 'text', lang: null, binary: false },
|
|
64
|
+
log: { type: 'text', icon: 'text', lang: null, binary: false },
|
|
65
|
+
csv: { type: 'text', icon: 'text', lang: null, binary: false },
|
|
66
|
+
|
|
67
|
+
// Images
|
|
68
|
+
png: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
69
|
+
jpg: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
70
|
+
jpeg: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
71
|
+
gif: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
72
|
+
svg: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
73
|
+
webp: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
74
|
+
ico: { type: 'image', icon: 'image', lang: null, binary: true },
|
|
75
|
+
|
|
76
|
+
// PDF
|
|
77
|
+
pdf: { type: 'pdf', icon: 'pdf', lang: null, binary: true },
|
|
78
|
+
|
|
79
|
+
// Video
|
|
80
|
+
mp4: { type: 'video', icon: 'video', lang: null, binary: true },
|
|
81
|
+
webm: { type: 'video', icon: 'video', lang: null, binary: true },
|
|
82
|
+
mov: { type: 'video', icon: 'video', lang: null, binary: true },
|
|
83
|
+
avi: { type: 'video', icon: 'video', lang: null, binary: true },
|
|
84
|
+
mkv: { type: 'video', icon: 'video', lang: null, binary: true },
|
|
85
|
+
|
|
86
|
+
// Audio
|
|
87
|
+
mp3: { type: 'audio', icon: 'audio', lang: null, binary: true },
|
|
88
|
+
wav: { type: 'audio', icon: 'audio', lang: null, binary: true },
|
|
89
|
+
ogg: { type: 'audio', icon: 'audio', lang: null, binary: true },
|
|
90
|
+
m4a: { type: 'audio', icon: 'audio', lang: null, binary: true },
|
|
91
|
+
flac: { type: 'audio', icon: 'audio', lang: null, binary: true },
|
|
92
|
+
|
|
93
|
+
// Archives
|
|
94
|
+
zip: { type: 'archive', icon: 'archive', lang: null, binary: true },
|
|
95
|
+
tar: { type: 'archive', icon: 'archive', lang: null, binary: true },
|
|
96
|
+
gz: { type: 'archive', icon: 'archive', lang: null, binary: true },
|
|
97
|
+
rar: { type: 'archive', icon: 'archive', lang: null, binary: true },
|
|
98
|
+
'7z': { type: 'archive', icon: 'archive', lang: null, binary: true },
|
|
99
|
+
|
|
100
|
+
// Office
|
|
101
|
+
doc: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
102
|
+
docx: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
103
|
+
xls: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
104
|
+
xlsx: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
105
|
+
ppt: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
106
|
+
pptx: { type: 'office', icon: 'office', lang: null, binary: true },
|
|
107
|
+
|
|
108
|
+
// Executables
|
|
109
|
+
exe: { type: 'executable', icon: 'executable', lang: null, binary: true },
|
|
110
|
+
dmg: { type: 'executable', icon: 'executable', lang: null, binary: true },
|
|
111
|
+
app: { type: 'executable', icon: 'executable', lang: null, binary: true },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Special filenames
|
|
115
|
+
const SPECIAL_FILES = {
|
|
116
|
+
'Dockerfile': { type: 'code', icon: 'config', lang: 'dockerfile', binary: false },
|
|
117
|
+
'Makefile': { type: 'code', icon: 'config', lang: 'makefile', binary: false },
|
|
118
|
+
'.gitignore': { type: 'code', icon: 'config', lang: 'gitignore', binary: false },
|
|
119
|
+
'.env': { type: 'code', icon: 'config', lang: 'bash', binary: false },
|
|
120
|
+
'.env.local': { type: 'code', icon: 'config', lang: 'bash', binary: false },
|
|
121
|
+
'.env.example': { type: 'code', icon: 'config', lang: 'bash', binary: false },
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get file type information
|
|
126
|
+
* @param {string} filename - File name or path
|
|
127
|
+
* @returns {Object} File type info { type, icon, lang, binary }
|
|
128
|
+
*/
|
|
129
|
+
export function getFileType(filename) {
|
|
130
|
+
const basename = filename.split('/').pop();
|
|
131
|
+
|
|
132
|
+
// Check special filenames first
|
|
133
|
+
if (SPECIAL_FILES[basename]) {
|
|
134
|
+
return SPECIAL_FILES[basename];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Get extension
|
|
138
|
+
const ext = basename.split('.').pop()?.toLowerCase();
|
|
139
|
+
|
|
140
|
+
if (ext && FILE_TYPES[ext]) {
|
|
141
|
+
return FILE_TYPES[ext];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Default to text file
|
|
145
|
+
return { type: 'text', icon: 'text', lang: null, binary: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default { getFileType, FILE_TYPES };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path security utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate that a path is within the allowed root directory
|
|
9
|
+
* Prevents path traversal attacks
|
|
10
|
+
* @param {string} targetPath - Path to validate (must be relative)
|
|
11
|
+
* @param {string} rootDir - Allowed root directory
|
|
12
|
+
* @returns {boolean} True if path is valid
|
|
13
|
+
*/
|
|
14
|
+
export function validatePath(targetPath, rootDir) {
|
|
15
|
+
// Reject null bytes (null byte injection attack)
|
|
16
|
+
if (targetPath.includes('\0') || targetPath.includes('%00')) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Reject absolute paths (e.g., /etc/passwd)
|
|
21
|
+
if (path.isAbsolute(targetPath)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reject path traversal attempts
|
|
26
|
+
if (targetPath.includes('..')) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fullPath = path.join(rootDir, targetPath);
|
|
31
|
+
const resolved = path.resolve(fullPath);
|
|
32
|
+
const resolvedRoot = path.resolve(rootDir);
|
|
33
|
+
|
|
34
|
+
// Check if the resolved path starts with the root directory
|
|
35
|
+
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get relative path from root
|
|
40
|
+
* @param {string} fullPath - Full path
|
|
41
|
+
* @param {string} rootDir - Root directory
|
|
42
|
+
* @returns {string} Relative path with forward slashes
|
|
43
|
+
*/
|
|
44
|
+
export function getRelativePath(fullPath, rootDir) {
|
|
45
|
+
return path.relative(rootDir, fullPath).split(path.sep).join('/');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default { validatePath, getRelativePath };
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File watcher for MDV using chokidar
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import chokidar from 'chokidar';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { renderFile } from './rendering/index.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Setup file watcher
|
|
11
|
+
* @param {string} rootDir - Root directory to watch
|
|
12
|
+
* @param {WebSocketServer} wss - WebSocket server for broadcasting
|
|
13
|
+
* @returns {FSWatcher} Chokidar watcher instance
|
|
14
|
+
*/
|
|
15
|
+
export function setupWatcher(rootDir, wss) {
|
|
16
|
+
const watcher = chokidar.watch(rootDir, {
|
|
17
|
+
ignored: [
|
|
18
|
+
/(^|[\/\\])\../, // Ignore dotfiles
|
|
19
|
+
/node_modules/,
|
|
20
|
+
/\.git/,
|
|
21
|
+
/__pycache__/,
|
|
22
|
+
/\.pyc$/,
|
|
23
|
+
// Python cache/build
|
|
24
|
+
/\.cache/,
|
|
25
|
+
/\.pytest_cache/,
|
|
26
|
+
/\.mypy_cache/,
|
|
27
|
+
/\.ruff_cache/,
|
|
28
|
+
/venv/,
|
|
29
|
+
/\.venv/,
|
|
30
|
+
// Build outputs
|
|
31
|
+
/dist/,
|
|
32
|
+
/build/,
|
|
33
|
+
// Framework specific
|
|
34
|
+
/\.next/,
|
|
35
|
+
/\.nuxt/,
|
|
36
|
+
// Test coverage
|
|
37
|
+
/coverage/,
|
|
38
|
+
// OS generated files
|
|
39
|
+
/\.DS_Store/,
|
|
40
|
+
/Thumbs\.db/,
|
|
41
|
+
/desktop\.ini/,
|
|
42
|
+
],
|
|
43
|
+
persistent: true,
|
|
44
|
+
ignoreInitial: true,
|
|
45
|
+
awaitWriteFinish: {
|
|
46
|
+
stabilityThreshold: 100,
|
|
47
|
+
pollInterval: 50
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Helper to get relative path
|
|
52
|
+
const getRelativePath = (filePath) => {
|
|
53
|
+
return path.relative(rootDir, filePath).split(path.sep).join('/');
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// File change handler
|
|
57
|
+
watcher.on('change', async (filePath) => {
|
|
58
|
+
const relativePath = getRelativePath(filePath);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Render the file content
|
|
62
|
+
const rendered = await renderFile(filePath);
|
|
63
|
+
|
|
64
|
+
// Broadcast to clients watching this file
|
|
65
|
+
wss.broadcastFileUpdate(relativePath, {
|
|
66
|
+
type: 'file_update',
|
|
67
|
+
path: relativePath,
|
|
68
|
+
...rendered
|
|
69
|
+
});
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`Error rendering ${relativePath}:`, err);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Tree change handlers
|
|
76
|
+
const broadcastTreeUpdate = async () => {
|
|
77
|
+
// We'll implement getFileTree in api/tree.js
|
|
78
|
+
// For now, just notify clients to refresh
|
|
79
|
+
wss.broadcast({
|
|
80
|
+
type: 'tree_update',
|
|
81
|
+
tree: null // Client will fetch via API
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
watcher.on('add', broadcastTreeUpdate);
|
|
86
|
+
watcher.on('unlink', broadcastTreeUpdate);
|
|
87
|
+
watcher.on('addDir', broadcastTreeUpdate);
|
|
88
|
+
watcher.on('unlinkDir', broadcastTreeUpdate);
|
|
89
|
+
|
|
90
|
+
watcher.on('error', (err) => {
|
|
91
|
+
console.error('Watcher error:', err);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return watcher;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default setupWatcher;
|
package/src/websocket.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket management for MDV
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Setup WebSocket server
|
|
9
|
+
* @param {http.Server} server - HTTP server instance
|
|
10
|
+
* @returns {WebSocketServer} WebSocket server instance
|
|
11
|
+
*/
|
|
12
|
+
export function setupWebSocket(server) {
|
|
13
|
+
const wss = new WebSocketServer({ server });
|
|
14
|
+
|
|
15
|
+
// Track watched files per client
|
|
16
|
+
const clientWatches = new Map();
|
|
17
|
+
|
|
18
|
+
wss.on('connection', (ws) => {
|
|
19
|
+
clientWatches.set(ws, new Set());
|
|
20
|
+
|
|
21
|
+
ws.on('message', (data) => {
|
|
22
|
+
try {
|
|
23
|
+
const message = JSON.parse(data.toString());
|
|
24
|
+
|
|
25
|
+
if (message.type === 'watch') {
|
|
26
|
+
// Client wants to watch a file
|
|
27
|
+
const watches = clientWatches.get(ws);
|
|
28
|
+
watches.clear();
|
|
29
|
+
watches.add(message.path);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error('WebSocket message error:', err);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
ws.on('close', () => {
|
|
37
|
+
clientWatches.delete(ws);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
ws.on('error', (err) => {
|
|
41
|
+
console.error('WebSocket error:', err);
|
|
42
|
+
clientWatches.delete(ws);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Add broadcast helper
|
|
47
|
+
wss.broadcast = (data) => {
|
|
48
|
+
const message = JSON.stringify(data);
|
|
49
|
+
wss.clients.forEach((client) => {
|
|
50
|
+
if (client.readyState === 1) { // WebSocket.OPEN
|
|
51
|
+
client.send(message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Add targeted broadcast for file updates
|
|
57
|
+
wss.broadcastFileUpdate = (filePath, data) => {
|
|
58
|
+
const message = JSON.stringify(data);
|
|
59
|
+
wss.clients.forEach((client) => {
|
|
60
|
+
if (client.readyState === 1) {
|
|
61
|
+
const watches = clientWatches.get(client);
|
|
62
|
+
if (watches && watches.has(filePath)) {
|
|
63
|
+
client.send(message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Store clientWatches for external access
|
|
70
|
+
wss.clientWatches = clientWatches;
|
|
71
|
+
|
|
72
|
+
return wss;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default setupWebSocket;
|