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.
@@ -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(/&lt;/g, '<')
80
+ .replace(/&gt;/g, '>')
81
+ .replace(/&amp;/g, '&')
82
+ .replace(/&quot;/g, '"')
83
+ .replace(/&#39;/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, '&amp;')
167
+ .replace(/</g, '&lt;')
168
+ .replace(/>/g, '&gt;')
169
+ .replace(/"/g, '&quot;');
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
+ }