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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload API routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import multer from 'multer';
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { validatePath } from '../utils/path.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Setup upload routes
|
|
12
|
+
* @param {Express} app - Express app instance
|
|
13
|
+
*/
|
|
14
|
+
export function setupUploadRoutes(app) {
|
|
15
|
+
// Configure multer for file uploads
|
|
16
|
+
const storage = multer.diskStorage({
|
|
17
|
+
destination: async (req, file, cb) => {
|
|
18
|
+
const targetPath = req.body.path || '';
|
|
19
|
+
|
|
20
|
+
// Security check: validate relative path before joining
|
|
21
|
+
if (!validatePath(targetPath, app.locals.rootDir)) {
|
|
22
|
+
return cb(new Error('Access denied'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fullPath = path.join(app.locals.rootDir, targetPath);
|
|
26
|
+
|
|
27
|
+
// Ensure directory exists
|
|
28
|
+
try {
|
|
29
|
+
await fs.mkdir(fullPath, { recursive: true });
|
|
30
|
+
cb(null, fullPath);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
cb(err);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
filename: (req, file, cb) => {
|
|
36
|
+
// パストラバーサル防止: ベース名のみ使用
|
|
37
|
+
const safeName = path.basename(file.originalname);
|
|
38
|
+
// null byteや制御文字を除去
|
|
39
|
+
const sanitized = safeName.replace(/[\x00-\x1f]/g, '');
|
|
40
|
+
cb(null, sanitized || 'unnamed');
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const upload = multer({
|
|
45
|
+
storage,
|
|
46
|
+
limits: {
|
|
47
|
+
fileSize: 100 * 1024 * 1024 // 100MB limit
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Upload files
|
|
52
|
+
app.post('/api/upload', upload.array('files'), (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
if (!req.files || req.files.length === 0) {
|
|
55
|
+
return res.status(400).json({ error: 'No files uploaded' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const uploaded = req.files.map(f => ({
|
|
59
|
+
name: f.originalname,
|
|
60
|
+
size: f.size
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
res.json({ success: true, files: uploaded });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
res.status(500).json({ error: err.message });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default setupUploadRoutes;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering module - combines markdown-it and marp-core
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import { getFileType } from '../utils/fileTypes.js';
|
|
7
|
+
import { renderMarkdown, isMarp } from './markdown.js';
|
|
8
|
+
import { renderMarp } from './marp.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Escape HTML entities
|
|
12
|
+
* @param {string} text - Text to escape
|
|
13
|
+
* @returns {string} Escaped text
|
|
14
|
+
*/
|
|
15
|
+
function escapeHtml(text) {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render code with syntax highlighting markup
|
|
26
|
+
* @param {string} content - Code content
|
|
27
|
+
* @param {string} lang - Language for highlighting
|
|
28
|
+
* @returns {string} HTML
|
|
29
|
+
*/
|
|
30
|
+
function renderCode(content, lang) {
|
|
31
|
+
const escaped = escapeHtml(content);
|
|
32
|
+
const langClass = lang ? `language-${lang}` : '';
|
|
33
|
+
return `<pre><code class="${langClass}">${escaped}</code></pre>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render plain text
|
|
38
|
+
* @param {string} content - Text content
|
|
39
|
+
* @returns {string} HTML
|
|
40
|
+
*/
|
|
41
|
+
function renderText(content) {
|
|
42
|
+
const escaped = escapeHtml(content);
|
|
43
|
+
return `<pre class="plain-text">${escaped}</pre>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render a file and return content for the frontend
|
|
48
|
+
* @param {string} filePath - Full path to the file
|
|
49
|
+
* @returns {Promise<Object>} Rendered content and metadata
|
|
50
|
+
*/
|
|
51
|
+
export async function renderFile(filePath) {
|
|
52
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
53
|
+
const fileType = getFileType(filePath);
|
|
54
|
+
|
|
55
|
+
// Markdown files
|
|
56
|
+
if (fileType.type === 'markdown') {
|
|
57
|
+
// Check if it's a Marp presentation
|
|
58
|
+
if (isMarp(content)) {
|
|
59
|
+
const { html, css } = renderMarp(content);
|
|
60
|
+
return {
|
|
61
|
+
content: html,
|
|
62
|
+
css,
|
|
63
|
+
raw: content,
|
|
64
|
+
fileType: 'markdown',
|
|
65
|
+
isMarp: true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Regular markdown
|
|
70
|
+
const html = renderMarkdown(content);
|
|
71
|
+
return {
|
|
72
|
+
content: html,
|
|
73
|
+
raw: content,
|
|
74
|
+
fileType: 'markdown',
|
|
75
|
+
isMarp: false
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Code files
|
|
80
|
+
if (fileType.type === 'code') {
|
|
81
|
+
const html = renderCode(content, fileType.lang);
|
|
82
|
+
return {
|
|
83
|
+
content: html,
|
|
84
|
+
raw: content,
|
|
85
|
+
fileType: 'code'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Plain text
|
|
90
|
+
const html = renderText(content);
|
|
91
|
+
return {
|
|
92
|
+
content: html,
|
|
93
|
+
raw: content,
|
|
94
|
+
fileType: 'text'
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default { renderFile };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown rendering using markdown-it
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import MarkdownIt from 'markdown-it';
|
|
6
|
+
import taskLists from 'markdown-it-task-lists';
|
|
7
|
+
|
|
8
|
+
// Initialize markdown-it with options
|
|
9
|
+
const md = new MarkdownIt({
|
|
10
|
+
html: true,
|
|
11
|
+
typographer: true,
|
|
12
|
+
breaks: true,
|
|
13
|
+
linkify: true
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Enable tables and strikethrough
|
|
17
|
+
md.enable('table');
|
|
18
|
+
md.enable('strikethrough');
|
|
19
|
+
|
|
20
|
+
// Enable task lists (checkboxes)
|
|
21
|
+
md.use(taskLists, { enabled: true, label: true, labelAfter: true });
|
|
22
|
+
|
|
23
|
+
// Pattern to detect Marp frontmatter (must be at very start of file, not using 'm' flag)
|
|
24
|
+
const MARP_PATTERN = /^---\s*\n[\s\S]*?marp:\s*true[\s\S]*?\n---/;
|
|
25
|
+
|
|
26
|
+
// Pattern to detect YAML frontmatter
|
|
27
|
+
const FRONTMATTER_PATTERN = /^---\s*\n([\s\S]*?)\n---\s*(\n|$)/;
|
|
28
|
+
|
|
29
|
+
// Pattern for Mermaid code blocks
|
|
30
|
+
const MERMAID_PATTERN = /```mermaid\s*\n([\s\S]*?)\n```/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if content is a Marp presentation
|
|
34
|
+
* @param {string} content - Markdown content
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
export function isMarp(content) {
|
|
38
|
+
return MARP_PATTERN.test(content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert YAML frontmatter to code block for display
|
|
43
|
+
* @param {string} content - Markdown content
|
|
44
|
+
* @returns {string} Content with frontmatter converted
|
|
45
|
+
*/
|
|
46
|
+
function convertFrontmatter(content) {
|
|
47
|
+
const match = content.match(FRONTMATTER_PATTERN);
|
|
48
|
+
if (match) {
|
|
49
|
+
const frontmatter = match[1];
|
|
50
|
+
const rest = content.slice(match[0].length);
|
|
51
|
+
return `\`\`\`yaml\n${frontmatter}\n\`\`\`\n${rest}`;
|
|
52
|
+
}
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Protect Mermaid blocks from markdown processing
|
|
58
|
+
* @param {string} content - Markdown content
|
|
59
|
+
* @returns {{ content: string, blocks: string[] }}
|
|
60
|
+
*/
|
|
61
|
+
function protectMermaidBlocks(content) {
|
|
62
|
+
const blocks = [];
|
|
63
|
+
const protected_ = content.replace(MERMAID_PATTERN, (match, code) => {
|
|
64
|
+
blocks.push(code);
|
|
65
|
+
return `<!--MERMAID_PLACEHOLDER_${blocks.length - 1}-->`;
|
|
66
|
+
});
|
|
67
|
+
return { content: protected_, blocks };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Restore Mermaid blocks after markdown processing
|
|
72
|
+
* @param {string} html - Rendered HTML
|
|
73
|
+
* @param {string[]} blocks - Mermaid code blocks
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function restoreMermaidBlocks(html, blocks) {
|
|
77
|
+
blocks.forEach((code, i) => {
|
|
78
|
+
const escaped = code
|
|
79
|
+
.replace(/&/g, '&')
|
|
80
|
+
.replace(/</g, '<')
|
|
81
|
+
.replace(/>/g, '>');
|
|
82
|
+
const mermaidHtml = `<pre><code class="language-mermaid">${escaped}</code></pre>`;
|
|
83
|
+
|
|
84
|
+
// Replace both paragraph-wrapped and bare placeholders
|
|
85
|
+
html = html.replace(`<p><!--MERMAID_PLACEHOLDER_${i}--></p>`, mermaidHtml);
|
|
86
|
+
html = html.replace(`<!--MERMAID_PLACEHOLDER_${i}-->`, mermaidHtml);
|
|
87
|
+
});
|
|
88
|
+
return html;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Add line numbers to rendered elements for editor sync
|
|
93
|
+
* @param {string} html - Rendered HTML
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
function addLineNumbers(html) {
|
|
97
|
+
// This is a simplified version - the full implementation would
|
|
98
|
+
// track source positions during rendering
|
|
99
|
+
return html;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render markdown to HTML
|
|
104
|
+
* @param {string} content - Markdown content
|
|
105
|
+
* @returns {string} HTML
|
|
106
|
+
*/
|
|
107
|
+
export function renderMarkdown(content) {
|
|
108
|
+
// Convert frontmatter to code block
|
|
109
|
+
content = convertFrontmatter(content);
|
|
110
|
+
|
|
111
|
+
// Protect Mermaid blocks
|
|
112
|
+
const { content: protected_, blocks } = protectMermaidBlocks(content);
|
|
113
|
+
|
|
114
|
+
// Render markdown
|
|
115
|
+
let html = md.render(protected_);
|
|
116
|
+
|
|
117
|
+
// Restore Mermaid blocks
|
|
118
|
+
html = restoreMermaidBlocks(html, blocks);
|
|
119
|
+
|
|
120
|
+
// Add line numbers
|
|
121
|
+
html = addLineNumbers(html);
|
|
122
|
+
|
|
123
|
+
return html;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default { renderMarkdown, isMarp };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marp rendering using @marp-team/marp-core
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: Do not modify Marp's HTML output structure.
|
|
5
|
+
* The CSS depends on the exact structure: div.marpit > svg > foreignObject > section
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Marp } from '@marp-team/marp-core';
|
|
9
|
+
|
|
10
|
+
// Initialize Marp with options
|
|
11
|
+
const marp = new Marp({
|
|
12
|
+
html: true,
|
|
13
|
+
math: true, // Enable KaTeX math
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render Marp presentation to HTML
|
|
18
|
+
* @param {string} content - Markdown content with Marp frontmatter
|
|
19
|
+
* @returns {{ html: string, css: string, slideCount: number }}
|
|
20
|
+
*/
|
|
21
|
+
export function renderMarp(content) {
|
|
22
|
+
const { html, css } = marp.render(content);
|
|
23
|
+
|
|
24
|
+
// Count slides by counting <section> tags
|
|
25
|
+
const slideCount = (html.match(/<section[^>]*>/g) || []).length;
|
|
26
|
+
|
|
27
|
+
// Return Marp's HTML output AS-IS to preserve CSS selector compatibility
|
|
28
|
+
return {
|
|
29
|
+
html,
|
|
30
|
+
css,
|
|
31
|
+
slideCount
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get available Marp themes
|
|
37
|
+
* @returns {string[]} Theme names
|
|
38
|
+
*/
|
|
39
|
+
export function getThemes() {
|
|
40
|
+
return ['default', 'gaia', 'uncover'];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default { renderMarp, getThemes };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MDV - Markdown Viewer Server
|
|
3
|
+
* Express + WebSocket server with Marp support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import express from 'express';
|
|
7
|
+
import { createServer } from 'http';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
import { setupWebSocket } from './websocket.js';
|
|
11
|
+
import { setupWatcher } from './watcher.js';
|
|
12
|
+
import { setupTreeRoutes } from './api/tree.js';
|
|
13
|
+
import { setupFileRoutes } from './api/file.js';
|
|
14
|
+
import { setupUploadRoutes } from './api/upload.js';
|
|
15
|
+
import { setupPdfRoutes } from './api/pdf.js';
|
|
16
|
+
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create and configure the MDV server
|
|
22
|
+
* @param {Object} options - Server options
|
|
23
|
+
* @param {string} options.rootDir - Root directory to serve
|
|
24
|
+
* @param {number} options.port - Port to listen on
|
|
25
|
+
* @returns {Object} Server instance and control functions
|
|
26
|
+
*/
|
|
27
|
+
export function createMdvServer(options) {
|
|
28
|
+
const { rootDir, port = 8080 } = options;
|
|
29
|
+
|
|
30
|
+
const app = express();
|
|
31
|
+
const server = createServer(app);
|
|
32
|
+
|
|
33
|
+
// Store root directory in app locals for access in routes
|
|
34
|
+
app.locals.rootDir = path.resolve(rootDir);
|
|
35
|
+
|
|
36
|
+
// Middleware
|
|
37
|
+
app.use(express.json());
|
|
38
|
+
app.use(express.urlencoded({ extended: true }));
|
|
39
|
+
|
|
40
|
+
// Static files
|
|
41
|
+
const staticDir = path.join(__dirname, 'static');
|
|
42
|
+
app.use('/static', express.static(staticDir));
|
|
43
|
+
|
|
44
|
+
// API routes
|
|
45
|
+
setupTreeRoutes(app);
|
|
46
|
+
setupFileRoutes(app);
|
|
47
|
+
setupUploadRoutes(app);
|
|
48
|
+
setupPdfRoutes(app);
|
|
49
|
+
|
|
50
|
+
// Server info endpoint
|
|
51
|
+
app.get('/api/info', (req, res) => {
|
|
52
|
+
res.json({
|
|
53
|
+
rootPath: app.locals.rootDir,
|
|
54
|
+
version: '0.3.0'
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Shutdown endpoint
|
|
59
|
+
app.post('/api/shutdown', (req, res) => {
|
|
60
|
+
res.json({ success: true });
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}, 100);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Serve index.html for root
|
|
67
|
+
app.get('/', (req, res) => {
|
|
68
|
+
res.sendFile(path.join(staticDir, 'index.html'));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Setup WebSocket
|
|
72
|
+
const wss = setupWebSocket(server);
|
|
73
|
+
|
|
74
|
+
// Setup file watcher
|
|
75
|
+
const watcher = setupWatcher(app.locals.rootDir, wss);
|
|
76
|
+
|
|
77
|
+
// Store watcher reference
|
|
78
|
+
app.locals.watcher = watcher;
|
|
79
|
+
app.locals.wss = wss;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
app,
|
|
83
|
+
server,
|
|
84
|
+
watcher,
|
|
85
|
+
wss,
|
|
86
|
+
port,
|
|
87
|
+
|
|
88
|
+
start() {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
server.listen(port, () => {
|
|
91
|
+
console.log(`MDV server running at http://localhost:${port}`);
|
|
92
|
+
resolve({ port });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
stop() {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
watcher.close();
|
|
100
|
+
wss.close();
|
|
101
|
+
server.close(() => {
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default createMdvServer;
|