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.
@@ -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, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#x27;');
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, '&amp;')
80
+ .replace(/</g, '&lt;')
81
+ .replace(/>/g, '&gt;');
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;