mdbrowse-cli 0.1.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/README.md +64 -0
- package/bin/mdnow.js +80 -0
- package/package.json +46 -0
- package/public/app.js +658 -0
- package/public/index.html +46 -0
- package/public/style.css +879 -0
- package/src/renderer.js +170 -0
- package/src/scanner.js +96 -0
- package/src/search.js +118 -0
- package/src/server.js +299 -0
- package/src/tunnel.js +61 -0
- package/src/watcher.js +40 -0
package/src/renderer.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { unified } from 'unified';
|
|
3
|
+
import remarkParse from 'remark-parse';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import remarkMath from 'remark-math';
|
|
6
|
+
import remarkHtml from 'remark-html';
|
|
7
|
+
import rehypeKatex from 'rehype-katex';
|
|
8
|
+
import rehypeStringify from 'rehype-stringify';
|
|
9
|
+
import matter from 'gray-matter';
|
|
10
|
+
import { createHighlighter } from 'shiki';
|
|
11
|
+
|
|
12
|
+
let highlighterPromise = null;
|
|
13
|
+
|
|
14
|
+
function getHighlighter() {
|
|
15
|
+
if (!highlighterPromise) {
|
|
16
|
+
highlighterPromise = createHighlighter({
|
|
17
|
+
themes: ['github-dark', 'github-light'],
|
|
18
|
+
langs: [
|
|
19
|
+
'javascript', 'typescript', 'python', 'rust', 'go', 'java',
|
|
20
|
+
'c', 'cpp', 'csharp', 'ruby', 'php', 'swift', 'kotlin',
|
|
21
|
+
'html', 'css', 'json', 'yaml', 'toml', 'xml', 'sql',
|
|
22
|
+
'bash', 'shell', 'markdown', 'dockerfile', 'graphql',
|
|
23
|
+
'lua', 'r', 'scala', 'haskell', 'elixir', 'zig',
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return highlighterPromise;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Map file extensions to shiki language IDs
|
|
31
|
+
const EXT_TO_LANG = {
|
|
32
|
+
'.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
|
|
33
|
+
'.ts': 'typescript', '.tsx': 'typescript', '.jsx': 'javascript',
|
|
34
|
+
'.py': 'python', '.rs': 'rust', '.go': 'go', '.java': 'java',
|
|
35
|
+
'.c': 'c', '.h': 'c', '.cpp': 'cpp', '.hpp': 'cpp', '.cc': 'cpp',
|
|
36
|
+
'.cs': 'csharp', '.rb': 'ruby', '.php': 'php', '.swift': 'swift',
|
|
37
|
+
'.kt': 'kotlin', '.kts': 'kotlin',
|
|
38
|
+
'.html': 'html', '.htm': 'html', '.css': 'css',
|
|
39
|
+
'.json': 'json', '.yaml': 'yaml', '.yml': 'yaml',
|
|
40
|
+
'.toml': 'toml', '.xml': 'xml', '.svg': 'xml',
|
|
41
|
+
'.sql': 'sql', '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash',
|
|
42
|
+
'.md': 'markdown', '.mdx': 'markdown',
|
|
43
|
+
'.dockerfile': 'dockerfile', '.graphql': 'graphql', '.gql': 'graphql',
|
|
44
|
+
'.lua': 'lua', '.r': 'r', '.scala': 'scala',
|
|
45
|
+
'.hs': 'haskell', '.ex': 'elixir', '.exs': 'elixir', '.zig': 'zig',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function detectLanguage(filePath) {
|
|
49
|
+
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
|
|
50
|
+
return EXT_TO_LANG[ext] || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render markdown content to HTML.
|
|
55
|
+
*/
|
|
56
|
+
export async function renderMarkdown(content, filePath) {
|
|
57
|
+
const { data: frontmatter, content: body } = matter(content);
|
|
58
|
+
const highlighter = await getHighlighter();
|
|
59
|
+
|
|
60
|
+
// Build the remark pipeline
|
|
61
|
+
// We use remark-html which outputs an HTML string directly,
|
|
62
|
+
// then do a second pass for math with rehype
|
|
63
|
+
const remarkResult = await unified()
|
|
64
|
+
.use(remarkParse)
|
|
65
|
+
.use(remarkGfm)
|
|
66
|
+
.use(remarkMath)
|
|
67
|
+
.use(remarkHtml, { sanitize: false })
|
|
68
|
+
.process(body);
|
|
69
|
+
|
|
70
|
+
let html = String(remarkResult);
|
|
71
|
+
|
|
72
|
+
// Highlight code blocks in the HTML
|
|
73
|
+
// Match <code class="language-xxx">...</code> blocks
|
|
74
|
+
html = html.replace(
|
|
75
|
+
/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
|
|
76
|
+
(match, lang, code) => {
|
|
77
|
+
// Decode HTML entities
|
|
78
|
+
const decoded = code
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/&/g, '&')
|
|
82
|
+
.replace(/"/g, '"')
|
|
83
|
+
.replace(/'/g, "'");
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return highlighter.codeToHtml(decoded, {
|
|
87
|
+
lang,
|
|
88
|
+
themes: { dark: 'github-dark', light: 'github-light' },
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
return match;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Handle KaTeX rendering for math blocks
|
|
97
|
+
// remark-math + remark-html produces <span class="math math-inline"> and <div class="math math-display">
|
|
98
|
+
// We need rehype-katex for proper rendering, but since we used remark-html directly,
|
|
99
|
+
// we handle math with a second pass
|
|
100
|
+
const mathInlineRegex = /<code class="language-math math-inline">([\s\S]*?)<\/code>/g;
|
|
101
|
+
const mathDisplayRegex = /<code class="language-math math-display">([\s\S]*?)<\/code>/g;
|
|
102
|
+
|
|
103
|
+
// For now, leave math as-is — KaTeX CSS + auto-render will handle it client-side
|
|
104
|
+
// The remark-math plugin wraps math in appropriate classes
|
|
105
|
+
|
|
106
|
+
// Rewrite relative image URLs to /raw/ paths
|
|
107
|
+
const fileDir = path.posix.dirname(filePath);
|
|
108
|
+
html = html.replace(/<img([^>]*?)src="([^"]*?)"([^>]*?)>/gi, (match, before, src, after) => {
|
|
109
|
+
if (/^(https?:\/\/|\/\/|data:)/.test(src)) return match;
|
|
110
|
+
const resolved = path.posix.normalize(path.posix.join(fileDir, src));
|
|
111
|
+
return `<img${before}src="/raw/${resolved}"${after}>`;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Extract title from frontmatter or first heading
|
|
115
|
+
let title = frontmatter?.title;
|
|
116
|
+
if (!title) {
|
|
117
|
+
const headingMatch = body.match(/^#\s+(.+)$/m);
|
|
118
|
+
if (headingMatch) title = headingMatch[1];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build frontmatter HTML block
|
|
122
|
+
let frontmatterHtml = '';
|
|
123
|
+
if (Object.keys(frontmatter).length > 0) {
|
|
124
|
+
frontmatterHtml = '<div class="frontmatter"><table>';
|
|
125
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
126
|
+
frontmatterHtml += `<tr><td class="fm-key">${escapeHtml(key)}</td><td class="fm-value">${escapeHtml(String(value))}</td></tr>`;
|
|
127
|
+
}
|
|
128
|
+
frontmatterHtml += '</table></div>';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
html: frontmatterHtml + html,
|
|
133
|
+
frontmatter,
|
|
134
|
+
title: title || filePath.split('/').pop(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render a non-markdown file with syntax highlighting.
|
|
140
|
+
*/
|
|
141
|
+
export async function renderCode(content, filePath) {
|
|
142
|
+
const highlighter = await getHighlighter();
|
|
143
|
+
const language = detectLanguage(filePath);
|
|
144
|
+
|
|
145
|
+
if (language) {
|
|
146
|
+
try {
|
|
147
|
+
const html = highlighter.codeToHtml(content, {
|
|
148
|
+
lang: language,
|
|
149
|
+
themes: { dark: 'github-dark', light: 'github-light' },
|
|
150
|
+
});
|
|
151
|
+
return { html, language };
|
|
152
|
+
} catch {
|
|
153
|
+
// Fall through to plain text
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Plain text fallback
|
|
158
|
+
return {
|
|
159
|
+
html: `<pre><code>${escapeHtml(content)}</code></pre>`,
|
|
160
|
+
language: 'text',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function escapeHtml(str) {
|
|
165
|
+
return str
|
|
166
|
+
.replace(/&/g, '&')
|
|
167
|
+
.replace(/</g, '<')
|
|
168
|
+
.replace(/>/g, '>')
|
|
169
|
+
.replace(/"/g, '"');
|
|
170
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build ignore filter by reading .gitignore files up the tree.
|
|
7
|
+
*/
|
|
8
|
+
export function buildIgnoreFilter(dirPath, rootDir) {
|
|
9
|
+
const ig = ignore();
|
|
10
|
+
// Always skip these
|
|
11
|
+
ig.add(['node_modules', '.git']);
|
|
12
|
+
|
|
13
|
+
// Walk from root to current dir, collecting .gitignore rules
|
|
14
|
+
const relative = path.relative(rootDir, dirPath);
|
|
15
|
+
const segments = relative ? relative.split(path.sep) : [];
|
|
16
|
+
const dirs = [rootDir];
|
|
17
|
+
let current = rootDir;
|
|
18
|
+
for (const seg of segments) {
|
|
19
|
+
current = path.join(current, seg);
|
|
20
|
+
dirs.push(current);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (const dir of dirs) {
|
|
24
|
+
const gitignorePath = path.join(dir, '.gitignore');
|
|
25
|
+
if (fs.existsSync(gitignorePath)) {
|
|
26
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
27
|
+
const prefix = path.relative(rootDir, dir);
|
|
28
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#'));
|
|
29
|
+
if (prefix) {
|
|
30
|
+
ig.add(lines.map(l => path.join(prefix, l)));
|
|
31
|
+
} else {
|
|
32
|
+
ig.add(lines);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return ig;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan a directory recursively and return a tree structure.
|
|
42
|
+
*/
|
|
43
|
+
export function scanDirectory(rootDir, respectIgnore = true) {
|
|
44
|
+
const ig = respectIgnore ? buildIgnoreFilter(rootDir, rootDir) : null;
|
|
45
|
+
|
|
46
|
+
function scan(dirPath) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const results = [];
|
|
55
|
+
|
|
56
|
+
// Sort: directories first, then alphabetical
|
|
57
|
+
entries.sort((a, b) => {
|
|
58
|
+
const aIsDir = a.isDirectory();
|
|
59
|
+
const bIsDir = b.isDirectory();
|
|
60
|
+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
61
|
+
return a.name.localeCompare(b.name);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
66
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
67
|
+
|
|
68
|
+
// Always skip .git and node_modules
|
|
69
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
70
|
+
|
|
71
|
+
if (ig && ig.ignores(relativePath + (entry.isDirectory() ? '/' : ''))) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
const children = scan(fullPath);
|
|
77
|
+
results.push({
|
|
78
|
+
name: entry.name,
|
|
79
|
+
path: relativePath,
|
|
80
|
+
type: 'directory',
|
|
81
|
+
children,
|
|
82
|
+
});
|
|
83
|
+
} else if (entry.isFile()) {
|
|
84
|
+
results.push({
|
|
85
|
+
name: entry.name,
|
|
86
|
+
path: relativePath,
|
|
87
|
+
type: 'file',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return scan(rootDir);
|
|
96
|
+
}
|
package/src/search.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { buildIgnoreFilter } from './scanner.js';
|
|
4
|
+
|
|
5
|
+
const MAX_FILES = 50;
|
|
6
|
+
const MAX_MATCHES_PER_FILE = 5;
|
|
7
|
+
const BINARY_CHECK_SIZE = 8192;
|
|
8
|
+
|
|
9
|
+
function isBinary(absPath) {
|
|
10
|
+
let fd;
|
|
11
|
+
try {
|
|
12
|
+
fd = fs.openSync(absPath, 'r');
|
|
13
|
+
const buf = Buffer.alloc(BINARY_CHECK_SIZE);
|
|
14
|
+
const bytesRead = fs.readSync(fd, buf, 0, BINARY_CHECK_SIZE, 0);
|
|
15
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
16
|
+
if (buf[i] === 0) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
} catch {
|
|
20
|
+
return true;
|
|
21
|
+
} finally {
|
|
22
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectFiles(dirPath, rootDir, ig) {
|
|
27
|
+
const files = [];
|
|
28
|
+
|
|
29
|
+
function walk(dir) {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
39
|
+
|
|
40
|
+
const fullPath = path.join(dir, entry.name);
|
|
41
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
42
|
+
|
|
43
|
+
if (ig && ig.ignores(relativePath + (entry.isDirectory() ? '/' : ''))) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
walk(fullPath);
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
files.push({ name: entry.name, path: relativePath, absPath: fullPath });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
walk(dirPath);
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function search(rootDir, query, respectIgnore = true) {
|
|
60
|
+
const lowerQuery = query.toLowerCase();
|
|
61
|
+
const ig = respectIgnore ? buildIgnoreFilter(rootDir, rootDir) : null;
|
|
62
|
+
const files = collectFiles(rootDir, rootDir, ig);
|
|
63
|
+
|
|
64
|
+
const results = [];
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (results.length >= MAX_FILES) break;
|
|
68
|
+
|
|
69
|
+
const nameMatch = file.name.toLowerCase().includes(lowerQuery);
|
|
70
|
+
|
|
71
|
+
if (isBinary(file.absPath)) {
|
|
72
|
+
if (nameMatch) {
|
|
73
|
+
results.push({ path: file.path, name: file.name, matches: [] });
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let content;
|
|
79
|
+
try {
|
|
80
|
+
content = fs.readFileSync(file.absPath, 'utf-8');
|
|
81
|
+
} catch {
|
|
82
|
+
if (nameMatch) {
|
|
83
|
+
results.push({ path: file.path, name: file.name, matches: [] });
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
const matches = [];
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < lines.length && matches.length < MAX_MATCHES_PER_FILE; i++) {
|
|
92
|
+
if (lines[i].toLowerCase().includes(lowerQuery)) {
|
|
93
|
+
const before = i > 0 ? lines[i - 1] : '';
|
|
94
|
+
const after = i < lines.length - 1 ? lines[i + 1] : '';
|
|
95
|
+
const contextParts = [];
|
|
96
|
+
if (i > 0) contextParts.push(before);
|
|
97
|
+
contextParts.push(lines[i]);
|
|
98
|
+
if (i < lines.length - 1) contextParts.push(after);
|
|
99
|
+
|
|
100
|
+
matches.push({
|
|
101
|
+
line: lines[i],
|
|
102
|
+
lineNumber: i + 1,
|
|
103
|
+
context: contextParts.join('\n'),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (nameMatch || matches.length > 0) {
|
|
109
|
+
results.push({ path: file.path, name: file.name, matches });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
results,
|
|
115
|
+
query,
|
|
116
|
+
totalFiles: results.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { basicAuth } from 'hono/basic-auth';
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { scanDirectory } from './scanner.js';
|
|
9
|
+
import { renderMarkdown, renderCode } from './renderer.js';
|
|
10
|
+
import { startWatcher } from './watcher.js';
|
|
11
|
+
import { search } from './search.js';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const publicDir = path.join(__dirname, '..', 'public');
|
|
15
|
+
|
|
16
|
+
const MAX_FILE_SIZE = 1024 * 1024; // 1 MB
|
|
17
|
+
|
|
18
|
+
const BINARY_EXTENSIONS = new Set([
|
|
19
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff', '.tif',
|
|
20
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm', '.wav', '.ogg',
|
|
21
|
+
'.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.xz',
|
|
22
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
23
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
24
|
+
'.exe', '.dll', '.so', '.dylib', '.o', '.a',
|
|
25
|
+
'.class', '.pyc', '.pyo',
|
|
26
|
+
'.sqlite', '.db',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const MIME_TYPES = {
|
|
30
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
31
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
32
|
+
'.ico': 'image/x-icon', '.bmp': 'image/bmp', '.tiff': 'image/tiff',
|
|
33
|
+
'.pdf': 'application/pdf',
|
|
34
|
+
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
|
|
35
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
36
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
37
|
+
'.zip': 'application/zip', '.gz': 'application/gzip',
|
|
38
|
+
'.json': 'application/json', '.xml': 'application/xml',
|
|
39
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
40
|
+
'.txt': 'text/plain', '.md': 'text/markdown',
|
|
41
|
+
'.yaml': 'text/yaml', '.yml': 'text/yaml',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate that a requested path stays within the root directory.
|
|
46
|
+
* Returns the resolved absolute path or null if invalid.
|
|
47
|
+
*/
|
|
48
|
+
function safePath(rootDir, requestedPath) {
|
|
49
|
+
const resolved = path.resolve(rootDir, requestedPath);
|
|
50
|
+
if (!resolved.startsWith(rootDir + path.sep) && resolved !== rootDir) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function startServer({ directory, port, host, respectIgnore, auth, readOnly }) {
|
|
57
|
+
const rootDir = path.resolve(directory);
|
|
58
|
+
|
|
59
|
+
if (!fs.existsSync(rootDir) || !fs.statSync(rootDir).isDirectory()) {
|
|
60
|
+
console.error(`Error: "${directory}" is not a valid directory`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const app = new Hono();
|
|
65
|
+
|
|
66
|
+
if (auth) {
|
|
67
|
+
app.use('*', basicAuth({ username: auth.username, password: auth.password }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// API: file tree
|
|
71
|
+
app.get('/api/tree', (c) => {
|
|
72
|
+
const tree = scanDirectory(rootDir, respectIgnore);
|
|
73
|
+
return c.json(tree);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// API: render file
|
|
77
|
+
app.get('/api/file', async (c) => {
|
|
78
|
+
const filePath = c.req.query('path');
|
|
79
|
+
if (!filePath) {
|
|
80
|
+
return c.json({ error: 'Missing path parameter' }, 400);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const absPath = safePath(rootDir, filePath);
|
|
84
|
+
if (!absPath) {
|
|
85
|
+
return c.json({ error: 'Invalid path' }, 403);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let stats;
|
|
89
|
+
try {
|
|
90
|
+
stats = fs.statSync(absPath);
|
|
91
|
+
} catch {
|
|
92
|
+
return c.json({ error: 'File not found' }, 404);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!stats.isFile()) {
|
|
96
|
+
return c.json({ error: 'File not found' }, 404);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
100
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(1);
|
|
101
|
+
return c.json({ type: 'notice', message: `File too large to preview (${sizeMB} MB, limit: 1 MB)` });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
105
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
106
|
+
return c.json({ type: 'notice', message: 'Binary file — cannot display' });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
110
|
+
const isMarkdown = /\.mdx?$/.test(filePath);
|
|
111
|
+
|
|
112
|
+
if (isMarkdown) {
|
|
113
|
+
const result = await renderMarkdown(content, filePath);
|
|
114
|
+
return c.json({ type: 'markdown', ...result });
|
|
115
|
+
} else {
|
|
116
|
+
const result = await renderCode(content, filePath);
|
|
117
|
+
return c.json({ type: 'code', ...result });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// API: save file
|
|
122
|
+
app.post('/api/file', async (c) => {
|
|
123
|
+
if (readOnly) {
|
|
124
|
+
return c.json({ error: 'Read-only mode' }, 403);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const filePath = c.req.query('path');
|
|
128
|
+
if (!filePath) {
|
|
129
|
+
return c.json({ error: 'Missing path parameter' }, 400);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const absPath = safePath(rootDir, filePath);
|
|
133
|
+
if (!absPath) {
|
|
134
|
+
return c.json({ error: 'Invalid path' }, 403);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const stats = fs.statSync(absPath);
|
|
139
|
+
if (!stats.isFile()) {
|
|
140
|
+
return c.json({ error: 'File not found' }, 404);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return c.json({ error: 'File not found' }, 404);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const body = await c.req.text();
|
|
147
|
+
await fs.promises.writeFile(absPath, body, 'utf-8');
|
|
148
|
+
return c.json({ ok: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// API: raw file content (for edit mode)
|
|
152
|
+
app.get('/api/raw-content', (c) => {
|
|
153
|
+
const filePath = c.req.query('path');
|
|
154
|
+
if (!filePath) {
|
|
155
|
+
return c.text('Missing path parameter', 400);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const absPath = safePath(rootDir, filePath);
|
|
159
|
+
if (!absPath) {
|
|
160
|
+
return c.text('Invalid path', 403);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let stats;
|
|
164
|
+
try {
|
|
165
|
+
stats = fs.statSync(absPath);
|
|
166
|
+
} catch {
|
|
167
|
+
return c.text('File not found', 404);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!stats.isFile()) {
|
|
171
|
+
return c.text('File not found', 404);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
175
|
+
return c.text(content);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// API: config
|
|
179
|
+
app.get('/api/config', (c) => {
|
|
180
|
+
return c.json({ readOnly: !!readOnly });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// API: search
|
|
184
|
+
app.get('/api/search', async (c) => {
|
|
185
|
+
const q = c.req.query('q');
|
|
186
|
+
if (!q) {
|
|
187
|
+
return c.json({ results: [], query: '', totalFiles: 0 });
|
|
188
|
+
}
|
|
189
|
+
const results = await search(rootDir, q, respectIgnore);
|
|
190
|
+
return c.json(results);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Static assets from public/
|
|
194
|
+
app.get('/assets/*', (c) => {
|
|
195
|
+
const assetPath = c.req.path.replace('/assets/', '');
|
|
196
|
+
const absPath = path.join(publicDir, assetPath);
|
|
197
|
+
|
|
198
|
+
// Prevent traversal
|
|
199
|
+
if (!absPath.startsWith(publicDir)) {
|
|
200
|
+
return c.text('Forbidden', 403);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!fs.existsSync(absPath)) {
|
|
204
|
+
return c.text('Not found', 404);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const content = fs.readFileSync(absPath);
|
|
208
|
+
const ext = path.extname(absPath);
|
|
209
|
+
const contentTypes = {
|
|
210
|
+
'.html': 'text/html',
|
|
211
|
+
'.css': 'text/css',
|
|
212
|
+
'.js': 'application/javascript',
|
|
213
|
+
'.json': 'application/json',
|
|
214
|
+
'.png': 'image/png',
|
|
215
|
+
'.jpg': 'image/jpeg',
|
|
216
|
+
'.svg': 'image/svg+xml',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return c.body(content, 200, {
|
|
220
|
+
'Content-Type': contentTypes[ext] || 'application/octet-stream',
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Raw file serving (images and other assets)
|
|
225
|
+
app.get('/raw/*', (c) => {
|
|
226
|
+
let reqPath;
|
|
227
|
+
try {
|
|
228
|
+
reqPath = decodeURIComponent(c.req.path.slice(5));
|
|
229
|
+
} catch {
|
|
230
|
+
return c.text('Bad request', 400);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const absPath = safePath(rootDir, reqPath);
|
|
234
|
+
if (!absPath) {
|
|
235
|
+
return c.text('Forbidden', 403);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
|
|
239
|
+
return c.text('Not found', 404);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const content = fs.readFileSync(absPath);
|
|
243
|
+
const ext = path.extname(absPath).toLowerCase();
|
|
244
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
245
|
+
|
|
246
|
+
return c.body(content, 200, {
|
|
247
|
+
'Content-Type': contentType,
|
|
248
|
+
'Cache-Control': 'public, max-age=300',
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// SPA catch-all: serve index.html
|
|
253
|
+
app.get('*', (c) => {
|
|
254
|
+
const indexPath = path.join(publicDir, 'index.html');
|
|
255
|
+
const html = fs.readFileSync(indexPath, 'utf-8');
|
|
256
|
+
return c.html(html);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Start HTTP server
|
|
260
|
+
const server = serve({
|
|
261
|
+
fetch: app.fetch,
|
|
262
|
+
port,
|
|
263
|
+
hostname: host,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Handle port-in-use errors
|
|
267
|
+
server.on('error', (err) => {
|
|
268
|
+
if (err.code === 'EADDRINUSE') {
|
|
269
|
+
console.error(`\n Error: Port ${port} is already in use.`);
|
|
270
|
+
console.error(` Try: mdnow --port ${port + 1}\n`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// WebSocket server
|
|
277
|
+
const wss = new WebSocketServer({ server });
|
|
278
|
+
|
|
279
|
+
const clients = new Set();
|
|
280
|
+
wss.on('connection', (ws) => {
|
|
281
|
+
clients.add(ws);
|
|
282
|
+
ws.on('close', () => clients.delete(ws));
|
|
283
|
+
ws.on('error', () => clients.delete(ws));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
function broadcast(message) {
|
|
287
|
+
const data = JSON.stringify(message);
|
|
288
|
+
for (const client of clients) {
|
|
289
|
+
if (client.readyState === 1) {
|
|
290
|
+
client.send(data);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Start file watcher
|
|
296
|
+
startWatcher(rootDir, broadcast, respectIgnore);
|
|
297
|
+
|
|
298
|
+
return server;
|
|
299
|
+
}
|