#!/usr/bin/env node /** * Markdown Novel Viewer Server * Background HTTP server rendering markdown files with calm, book-like UI * * Universal viewer - pass ANY path and view it: * - Markdown files → novel-reader UI * - Directories → file listing browser * * Usage: * node server.cjs --file ./plan.md [--port 3456] [--no-open] [--stop] [--host 0.0.0.0] * node server.cjs --dir ./plans [--port 3456] # Browse directory * * Options: * --file Path to markdown file * --dir Path to directory (browse mode) * --port Server port (default: 3456, auto-increment if busy) * --host Host to bind (default: localhost, use 0.0.0.0 for all interfaces) * --no-open Disable auto-open browser (opens by default) * --stop Stop all running servers * --background Run in background (detached) - legacy mode * --foreground Run in foreground (for CC background tasks) */ const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawn, execSync } = require('child_process'); const { findAvailablePort, DEFAULT_PORT } = require('./lib/port-finder.cjs'); const { writePidFile, stopAllServers, setupShutdownHandlers, findRunningInstances } = require('./lib/process-mgr.cjs'); const { createHttpServer } = require('./lib/http-server.cjs'); const { renderMarkdownFile, renderTOCHtml } = require('./lib/markdown-renderer.cjs'); const { generateNavSidebar, generateNavFooter, detectPlan, getNavigationContext } = require('./lib/plan-navigator.cjs'); /** * Parse command line arguments */ function parseArgs(argv) { const args = { file: null, dir: null, port: DEFAULT_PORT, host: 'localhost', open: true, // Auto-open browser by default stop: false, background: false, foreground: false, isChild: false }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === '--file' && argv[i + 1]) { args.file = argv[++i]; } else if (arg === '--dir' && argv[i + 1]) { args.dir = argv[++i]; } else if (arg === '--port' && argv[i + 1]) { args.port = parseInt(argv[++i], 10); } else if (arg === '--host' && argv[i + 1]) { args.host = argv[++i]; } else if (arg === '--open') { args.open = true; } else if (arg === '--no-open') { args.open = false; } else if (arg === '--stop') { args.stop = true; } else if (arg === '--background') { args.background = true; } else if (arg === '--foreground') { args.foreground = true; } else if (arg === '--child') { args.isChild = true; } else if (!arg.startsWith('--') && !args.file && !args.dir) { // Positional argument - could be file or directory args.file = arg; } } return args; } /** * Resolve input path - simple logic, no smart detection * @param {string} input - Input path * @param {string} cwd - Current working directory * @returns {{type: 'file'|'directory'|null, path: string|null}} */ function resolveInput(input, cwd) { if (!input) return { type: null, path: null }; // Resolve relative to CWD const resolved = path.isAbsolute(input) ? input : path.resolve(cwd, input); if (!fs.existsSync(resolved)) { return { type: null, path: null }; } const stats = fs.statSync(resolved); // File mode if (stats.isFile()) { return { type: 'file', path: resolved }; } // Directory mode - browse, no auto-detection of plan.md if (stats.isDirectory()) { return { type: 'directory', path: resolved }; } return { type: null, path: null }; } /** * Open browser with URL */ function openBrowser(url) { const platform = process.platform; let cmd; if (platform === 'darwin') { cmd = `open "${url}"`; } else if (platform === 'win32') { // On Windows, start command treats first quoted arg as window title // Use empty title "" before the URL to prevent this cmd = `start "" "${url}"`; } else { cmd = `xdg-open "${url}"`; } try { execSync(cmd, { stdio: 'ignore' }); } catch { // Ignore browser open errors } } /** * Generate full HTML page from markdown */ function generateFullPage(filePath, assetsDir) { const { html, toc, frontmatter, title } = renderMarkdownFile(filePath); const tocHtml = renderTOCHtml(toc); const navSidebar = generateNavSidebar(filePath); const navFooter = generateNavFooter(filePath); const planInfo = detectPlan(filePath); const navContext = getNavigationContext(filePath); // Read template const templatePath = path.join(assetsDir, 'template.html'); let template = fs.readFileSync(templatePath, 'utf8'); // Generate back button (links to parent directory browser) const parentDir = path.dirname(filePath); const backButton = ` `; // Generate header nav (prev/next) for plan files let headerNav = ''; if (navContext.prev || navContext.next) { const prevBtn = navContext.prev && fs.existsSync(navContext.prev.file) ? `` : ''; const nextBtn = navContext.next && fs.existsSync(navContext.next.file) ? `` : ''; headerNav = `
${prevBtn}${nextBtn}
`; } // Replace placeholders template = template .replace(/\{\{title\}\}/g, title) .replace('{{toc}}', tocHtml) .replace('{{nav-sidebar}}', navSidebar) .replace('{{nav-footer}}', navFooter) .replace('{{content}}', html) .replace('{{has-plan}}', planInfo.isPlan ? 'has-plan' : '') .replace('{{frontmatter}}', JSON.stringify(frontmatter || {})) .replace('{{back-button}}', backButton) .replace('{{header-nav}}', headerNav); return template; } /** * Get local network IP address for remote access * @returns {string|null} - Local IP or null if not found */ function getLocalIP() { const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const iface of interfaces[name]) { // Skip internal (loopback) and non-IPv4 addresses if (iface.family === 'IPv4' && !iface.internal) { return iface.address; } } } return null; } /** * Build URL with query parameters (fixes path conflicts) * @returns {{url: string, networkUrl: string|null}} - Local and network URLs */ function buildUrl(host, port, type, filePath) { const displayHost = host === '0.0.0.0' ? 'localhost' : host; const baseUrl = `http://${displayHost}:${port}`; let urlPath = ''; if (type === 'file') { urlPath = `/view?file=${encodeURIComponent(filePath)}`; } else if (type === 'directory') { urlPath = `/browse?dir=${encodeURIComponent(filePath)}`; } const url = baseUrl + urlPath; // If binding to all interfaces, provide network URL for remote access let networkUrl = null; if (host === '0.0.0.0') { const localIP = getLocalIP(); if (localIP) { networkUrl = `http://${localIP}:${port}${urlPath}`; } } return { url, networkUrl }; } /** * Main function */ async function main() { const args = parseArgs(process.argv); const cwd = process.cwd(); const assetsDir = path.join(__dirname, '..', 'assets'); // Handle --stop if (args.stop) { const instances = findRunningInstances(); if (instances.length === 0) { console.log('No server running to stop'); process.exit(0); } const stopped = stopAllServers(); console.log(`Stopped ${stopped} server(s)`); process.exit(0); } // Determine input const input = args.dir || args.file; // Validate input if (!input) { console.error('Error: --file or --dir argument required'); console.error('Usage:'); console.error(' node server.cjs --file [--port 3456] [--open]'); console.error(' node server.cjs --dir [--port 3456] [--open] # Browse directory'); process.exit(1); } // Resolve input path - simple logic let resolved = resolveInput(input, cwd); // If --dir was explicitly used, force directory mode if (args.dir && resolved.type === null) { const dirPath = path.isAbsolute(args.dir) ? args.dir : path.resolve(cwd, args.dir); if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { resolved = { type: 'directory', path: dirPath }; } } if (resolved.type === null) { console.error(`Error: Invalid path: ${input}`); console.error('Path must be a file or directory.'); process.exit(1); } // Background mode - spawn child and exit (legacy mode for manual runs) // Skip if --foreground is set (for Claude Code background tasks) if (args.background && !args.foreground && !args.isChild) { const childArgs = ['--port', String(args.port), '--host', args.host, '--child']; if (resolved.type === 'file') { childArgs.unshift('--file', resolved.path); } else { childArgs.unshift('--dir', resolved.path); } if (args.open) childArgs.push('--open'); const child = spawn(process.execPath, [__filename, ...childArgs], { detached: true, stdio: 'ignore', cwd: cwd }); child.unref(); // Wait briefly for child to start await new Promise(r => setTimeout(r, 500)); // Find the port the child is using const instances = findRunningInstances(); const instance = instances.find(i => i.port >= args.port); const port = instance ? instance.port : args.port; const { url, networkUrl } = buildUrl(args.host, port, resolved.type, resolved.path); const result = { success: true, url, path: resolved.path, port, host: args.host, mode: resolved.type }; if (networkUrl) result.networkUrl = networkUrl; console.log(JSON.stringify(result)); process.exit(0); } // Find available port const port = await findAvailablePort(args.port); if (port !== args.port) { console.error(`Port ${args.port} in use, using ${port}`); } // Determine allowed directories for security const allowedDirs = [assetsDir, cwd]; if (resolved.path) { const targetDir = resolved.type === 'file' ? path.dirname(resolved.path) : resolved.path; if (!allowedDirs.includes(targetDir)) { allowedDirs.push(targetDir); } } // Create server const server = createHttpServer({ assetsDir, renderMarkdown: (fp) => generateFullPage(fp, assetsDir), allowedDirs }); // Start server server.listen(port, args.host, () => { const { url, networkUrl } = buildUrl(args.host, port, resolved.type, resolved.path); // Write PID file writePidFile(port, process.pid); // Setup shutdown handlers setupShutdownHandlers(port, () => { server.close(); }); // Output for CLI/command integration // In foreground mode (CC background task), always output JSON if (args.foreground || args.isChild || process.env.CLAUDE_COMMAND) { const result = { success: true, url, path: resolved.path, port, host: args.host, mode: resolved.type }; if (networkUrl) result.networkUrl = networkUrl; console.log(JSON.stringify(result)); } else { console.log(`\nMarkdown Novel Viewer`); console.log(`${'─'.repeat(40)}`); console.log(`URL: ${url}`); if (networkUrl) { console.log(`Network: ${networkUrl}`); } console.log(`Path: ${resolved.path}`); console.log(`Port: ${port}`); console.log(`Host: ${args.host}`); console.log(`Mode: ${resolved.type === 'file' ? 'File Viewer' : 'Directory Browser'}`); console.log(`\nPress Ctrl+C to stop\n`); } // Open browser if (args.open) { openBrowser(url); } }); server.on('error', (err) => { console.error(`Server error: ${err.message}`); process.exit(1); }); } // Run main().catch(err => { console.error(`Error: ${err.message}`); process.exit(1); });