init
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Core HTTP server for markdown-novel-viewer
|
||||
* Handles routing for markdown viewer and directory browser
|
||||
*
|
||||
* Routes:
|
||||
* - /view?file=<path> - Markdown file viewer
|
||||
* - /browse?dir=<path> - Directory browser
|
||||
* - /assets/* - Static assets
|
||||
* - /file/* - Local files (images, etc.)
|
||||
*
|
||||
* Security: Paths are validated to prevent directory traversal attacks
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
// Allowed base directories for file access (set at runtime)
|
||||
let allowedBaseDirs = [];
|
||||
|
||||
/**
|
||||
* Set allowed directories for file serving
|
||||
* @param {string[]} dirs - Array of allowed directory paths
|
||||
*/
|
||||
function setAllowedDirs(dirs) {
|
||||
allowedBaseDirs = dirs.map(d => path.resolve(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path is within allowed directories (prevents path traversal)
|
||||
* @param {string} filePath - Path to validate
|
||||
* @param {string[]} allowedDirs - Allowed base directories
|
||||
* @returns {boolean} - True if path is safe
|
||||
*/
|
||||
function isPathSafe(filePath, allowedDirs = allowedBaseDirs) {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (resolved.includes('..') || filePath.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no allowed dirs set, allow only project paths
|
||||
if (allowedDirs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Must be within one of the allowed directories
|
||||
return allowedDirs.some(dir => resolved.startsWith(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error message to prevent path disclosure
|
||||
*/
|
||||
function sanitizeErrorMessage(message) {
|
||||
return message.replace(/\/[^\s'"<>]+/g, '[path]');
|
||||
}
|
||||
|
||||
// MIME type mapping
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.md': 'text/markdown',
|
||||
'.txt': 'text/plain',
|
||||
'.pdf': 'application/pdf'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MIME type for file extension
|
||||
*/
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response with content
|
||||
*/
|
||||
function sendResponse(res, statusCode, contentType, content) {
|
||||
res.writeHead(statusCode, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response (sanitized)
|
||||
*/
|
||||
function sendError(res, statusCode, message) {
|
||||
const safeMessage = sanitizeErrorMessage(message);
|
||||
sendResponse(res, statusCode, 'text/html', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error ${statusCode}</title></head>
|
||||
<body style="font-family: system-ui; padding: 2rem;">
|
||||
<h1>Error ${statusCode}</h1>
|
||||
<p>${safeMessage}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static file with path validation
|
||||
*/
|
||||
function serveFile(res, filePath, skipValidation = false) {
|
||||
if (!skipValidation && !isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
sendResponse(res, 200, mimeType, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on extension
|
||||
*/
|
||||
function getFileIcon(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const iconMap = {
|
||||
'.md': '📄',
|
||||
'.txt': '📝',
|
||||
'.json': '📋',
|
||||
'.js': '📜',
|
||||
'.cjs': '📜',
|
||||
'.mjs': '📜',
|
||||
'.ts': '📘',
|
||||
'.css': '🎨',
|
||||
'.html': '🌐',
|
||||
'.png': '🖼️',
|
||||
'.jpg': '🖼️',
|
||||
'.jpeg': '🖼️',
|
||||
'.gif': '🖼️',
|
||||
'.svg': '🖼️',
|
||||
'.pdf': '📕',
|
||||
'.yaml': '⚙️',
|
||||
'.yml': '⚙️',
|
||||
'.toml': '⚙️',
|
||||
'.env': '🔐',
|
||||
'.sh': '💻',
|
||||
'.bash': '💻'
|
||||
};
|
||||
return iconMap[ext] || '📄';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render directory browser HTML
|
||||
*/
|
||||
function renderDirectoryBrowser(dirPath, assetsDir) {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
const displayPath = dirPath.length > 50 ? '...' + dirPath.slice(-47) : dirPath;
|
||||
|
||||
// Separate directories and files, sort alphabetically
|
||||
const dirs = [];
|
||||
const files = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Skip hidden files and deprecated folders
|
||||
if (item.startsWith('.') || item === 'deprecated') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
if (stats.isDirectory()) {
|
||||
dirs.push(item);
|
||||
} else {
|
||||
files.push(item);
|
||||
}
|
||||
} catch {
|
||||
// Skip items we can't stat
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Build file list HTML
|
||||
let listHtml = '';
|
||||
|
||||
// Parent directory link (if not root)
|
||||
const parentDir = path.dirname(dirPath);
|
||||
if (parentDir !== dirPath) {
|
||||
listHtml += `<li class="dir-item parent">
|
||||
<a href="/browse?dir=${encodeURIComponent(parentDir)}">
|
||||
<span class="icon">📁</span>
|
||||
<span class="name">..</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// Directories
|
||||
for (const dir of dirs) {
|
||||
const fullPath = path.join(dirPath, dir);
|
||||
listHtml += `<li class="dir-item folder">
|
||||
<a href="/browse?dir=${encodeURIComponent(fullPath)}">
|
||||
<span class="icon">📁</span>
|
||||
<span class="name">${dir}/</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// Files
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
const icon = getFileIcon(file);
|
||||
const isMarkdown = file.endsWith('.md');
|
||||
|
||||
if (isMarkdown) {
|
||||
listHtml += `<li class="dir-item file markdown">
|
||||
<a href="/view?file=${encodeURIComponent(fullPath)}">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${file}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
} else {
|
||||
listHtml += `<li class="dir-item file">
|
||||
<a href="/file${fullPath}" target="_blank">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${file}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty directory message
|
||||
if (dirs.length === 0 && files.length === 0) {
|
||||
listHtml = '<li class="empty">This directory is empty</li>';
|
||||
}
|
||||
|
||||
// Read CSS
|
||||
let css = '';
|
||||
const cssPath = path.join(assetsDir, 'directory-browser.css');
|
||||
if (fs.existsSync(cssPath)) {
|
||||
css = fs.readFileSync(cssPath, 'utf8');
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📁 ${path.basename(dirPath)}</title>
|
||||
<style>
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>📁 ${path.basename(dirPath)}</h1>
|
||||
<p class="path">${displayPath}</p>
|
||||
</header>
|
||||
<ul class="file-list">
|
||||
${listHtml}
|
||||
</ul>
|
||||
<footer>
|
||||
<p>${dirs.length} folder${dirs.length !== 1 ? 's' : ''}, ${files.length} file${files.length !== 1 ? 's' : ''}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP server with routing
|
||||
* @param {Object} options - Server options
|
||||
* @param {string} options.assetsDir - Static assets directory
|
||||
* @param {Function} options.renderMarkdown - Markdown render function (filePath) => html
|
||||
* @param {string[]} options.allowedDirs - Allowed directories for file access
|
||||
* @returns {http.Server} - HTTP server instance
|
||||
*/
|
||||
function createHttpServer(options) {
|
||||
const { assetsDir, renderMarkdown, allowedDirs = [] } = options;
|
||||
|
||||
// Set allowed directories for path validation
|
||||
if (allowedDirs.length > 0) {
|
||||
setAllowedDirs(allowedDirs);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
const pathname = decodeURIComponent(parsedUrl.pathname);
|
||||
|
||||
// Route: /assets/* - serve static files from assets directory
|
||||
if (pathname.startsWith('/assets/')) {
|
||||
const relativePath = pathname.replace('/assets/', '');
|
||||
if (relativePath.includes('..')) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
const assetPath = path.join(assetsDir, relativePath);
|
||||
serveFile(res, assetPath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /file/* - serve local files (images, etc.)
|
||||
if (pathname.startsWith('/file/')) {
|
||||
// Extract path after '/file/' prefix (slice(6) removes '/file/')
|
||||
// Path is already URL-decoded by decodeURIComponent above
|
||||
const filePath = pathname.slice(6);
|
||||
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
serveFile(res, filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /view?file=<path> - render markdown (query param)
|
||||
if (pathname === '/view') {
|
||||
const filePath = parsedUrl.query?.file;
|
||||
|
||||
if (!filePath) {
|
||||
sendError(res, 400, 'Missing ?file= parameter. Use /view?file=/path/to/file.md');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = renderMarkdown(filePath);
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Render error:', err.message);
|
||||
sendError(res, 500, 'Error rendering markdown');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /browse?dir=<path> - directory browser (query param)
|
||||
if (pathname === '/browse') {
|
||||
const dirPath = parsedUrl.query?.dir;
|
||||
|
||||
if (!dirPath) {
|
||||
sendError(res, 400, 'Missing ?dir= parameter. Use /browse?dir=/path/to/directory');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathSafe(dirPath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
sendError(res, 404, 'Directory not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = renderDirectoryBrowser(dirPath, assetsDir);
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Browse error:', err.message);
|
||||
sendError(res, 500, 'Error listing directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: / - show welcome/usage page
|
||||
if (pathname === '/') {
|
||||
sendResponse(res, 200, 'text/html', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Markdown Novel Viewer</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 1rem; }
|
||||
h1 { color: #8b4513; }
|
||||
code { background: #f5f5f5; padding: 0.2rem 0.4rem; border-radius: 3px; }
|
||||
.routes { background: #faf8f3; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📖 Markdown Novel Viewer</h1>
|
||||
<p>A calm, book-like viewer for markdown files.</p>
|
||||
<div class="routes">
|
||||
<h3>Routes</h3>
|
||||
<ul>
|
||||
<li><code>/view?file=/path/to/file.md</code> - View markdown</li>
|
||||
<li><code>/browse?dir=/path/to/dir</code> - Browse directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Use the <code>/ck:preview</code> skill invocation to start viewing files.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: 404
|
||||
sendError(res, 404, 'Not found');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createHttpServer,
|
||||
getMimeType,
|
||||
sendResponse,
|
||||
sendError,
|
||||
serveFile,
|
||||
isPathSafe,
|
||||
setAllowedDirs,
|
||||
sanitizeErrorMessage,
|
||||
MIME_TYPES,
|
||||
renderDirectoryBrowser,
|
||||
getFileIcon
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Markdown rendering engine with syntax highlighting and image resolution
|
||||
* Converts markdown to styled HTML for novel-reader UI
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Lazy load dependencies
|
||||
let marked = null;
|
||||
let hljs = null;
|
||||
let matter = null;
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent XSS in mermaid content
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} - Escaped string
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize markdown dependencies
|
||||
*/
|
||||
function initDependencies() {
|
||||
if (!marked) {
|
||||
const { Marked } = require('marked');
|
||||
hljs = require('highlight.js');
|
||||
|
||||
marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
// Custom extension for code blocks (handles mermaid specially)
|
||||
// marked v17+ requires extensions array for custom token handling
|
||||
const mermaidExtension = {
|
||||
name: 'mermaidCodeBlock',
|
||||
level: 'block',
|
||||
renderer(token) {
|
||||
// This is called for code tokens
|
||||
if (token.type === 'code') {
|
||||
const code = token.text || '';
|
||||
const language = token.lang || '';
|
||||
|
||||
// Handle mermaid code blocks - render as div for client-side processing
|
||||
if (language === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
|
||||
}
|
||||
|
||||
// Regular code blocks with syntax highlighting
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = hljs.highlight(code, { language }).value;
|
||||
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect language or plain text
|
||||
const highlighted = hljs.highlightAuto(code).value;
|
||||
return `<pre><code class="hljs">${highlighted}</code></pre>`;
|
||||
}
|
||||
return false; // Use default renderer for other tokens
|
||||
}
|
||||
};
|
||||
|
||||
// Use the renderer override approach for marked v17+
|
||||
marked.use({
|
||||
renderer: {
|
||||
code(token) {
|
||||
const code = typeof token === 'string' ? token : (token.text || '');
|
||||
const language = typeof token === 'string' ? '' : (token.lang || '');
|
||||
|
||||
// Handle mermaid code blocks - render as div for client-side processing
|
||||
if (language === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
|
||||
}
|
||||
|
||||
// Regular code blocks with syntax highlighting
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = hljs.highlight(code, { language }).value;
|
||||
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect language or plain text
|
||||
const highlighted = hljs.highlightAuto(code).value;
|
||||
return `<pre><code class="hljs">${highlighted}</code></pre>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
matter = require('gray-matter');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single image source path to /file/ route
|
||||
* @param {string} src - Image source path
|
||||
* @param {string} basePath - Base directory path
|
||||
* @returns {string} - Resolved path or original if absolute URL
|
||||
*/
|
||||
function resolveImageSrc(src, basePath) {
|
||||
// Skip absolute URLs
|
||||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/file')) {
|
||||
return src;
|
||||
}
|
||||
// Resolve relative path to absolute /file/ route
|
||||
// Use URL encoding to handle special chars and Windows paths (D:\...)
|
||||
const absolutePath = path.resolve(basePath, src);
|
||||
return `/file/${encodeURIComponent(absolutePath)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative image paths to /file/ routes
|
||||
* Supports both inline and reference-style markdown images
|
||||
* @param {string} markdown - Markdown content
|
||||
* @param {string} basePath - Base directory path
|
||||
* @returns {string} - Markdown with resolved image paths
|
||||
*/
|
||||
function resolveImages(markdown, basePath) {
|
||||
let result = markdown;
|
||||
|
||||
// 1. Handle inline images:  or 
|
||||
const inlineImgRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||
result = result.replace(inlineImgRegex, (match, alt, src) => {
|
||||
const resolvedSrc = resolveImageSrc(src, basePath);
|
||||
return ``;
|
||||
});
|
||||
|
||||
// 2. Handle reference-style image definitions: [label]: src or [label]: src "title"
|
||||
// These appear at the end of the document like: [Step 1 Initial]: ./screenshots/step1.png
|
||||
const refDefRegex = /^\[([^\]]+)\]:\s*(\S+)(?:\s+"[^"]*")?$/gm;
|
||||
result = result.replace(refDefRegex, (match, label, src) => {
|
||||
const resolvedSrc = resolveImageSrc(src, basePath);
|
||||
return `[${label}]: ${resolvedSrc}`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate table of contents from headings
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {Array<{level: number, id: string, text: string}>} - TOC items
|
||||
*/
|
||||
function generateTOC(html) {
|
||||
const headings = [];
|
||||
// Match h1-h3 with id attribute
|
||||
const regex = /<h([1-3])[^>]*id="([^"]+)"[^>]*>([^<]+)<\/h\1>/gi;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
headings.push({
|
||||
level: parseInt(match[1], 10),
|
||||
id: match[2],
|
||||
text: match[3].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from text for use as anchor ID (matches plan-navigator.cjs)
|
||||
* @param {string} text - Text to slugify
|
||||
* @returns {string} - URL-safe slug
|
||||
*/
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add IDs to headings for anchor links
|
||||
* Also adds phase-specific IDs for inline phases in plan.md
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {string} - HTML with heading IDs
|
||||
*/
|
||||
function addHeadingIds(html) {
|
||||
const usedIds = new Set();
|
||||
|
||||
return html.replace(/<h([1-6])>([^<]+)<\/h\1>/gi, (match, level, text) => {
|
||||
// Check if this is a phase heading (e.g., "Phase 01: Name" or contains phase table row content)
|
||||
const phaseMatch = text.match(/^Phase\s*(\d+)[:\s]+(.+)/i);
|
||||
|
||||
let id;
|
||||
if (phaseMatch) {
|
||||
// Generate phase-specific anchor ID that matches plan-navigator.cjs format
|
||||
const phaseNum = parseInt(phaseMatch[1], 10);
|
||||
const phaseName = phaseMatch[2].trim();
|
||||
id = `phase-${String(phaseNum).padStart(2, '0')}-${slugify(phaseName)}`;
|
||||
} else {
|
||||
// Standard heading ID generation
|
||||
id = slugify(text);
|
||||
}
|
||||
|
||||
// Handle duplicate IDs
|
||||
let uniqueId = id;
|
||||
let counter = 1;
|
||||
while (usedIds.has(uniqueId)) {
|
||||
uniqueId = `${id}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
usedIds.add(uniqueId);
|
||||
|
||||
return `<h${level} id="${uniqueId}">${text}</h${level}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anchor IDs to phase table rows
|
||||
* Matches table rows with phase numbers: | 01 | Description | Status |
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {string} - HTML with phase anchor IDs in table rows
|
||||
*/
|
||||
function addPhaseTableAnchors(html) {
|
||||
const usedIds = new Set();
|
||||
|
||||
// Match table rows with phase pattern: <tr><td>01</td><td>Description</td>...
|
||||
// This handles the "Phase Summary" table format
|
||||
return html.replace(/<tr>\s*<td>(\d{2})<\/td>\s*<td>([^<]+)<\/td>/gi, (match, phaseNum, description) => {
|
||||
const num = parseInt(phaseNum, 10);
|
||||
const slug = slugify(description.trim());
|
||||
const id = `phase-${String(num).padStart(2, '0')}-${slug}`;
|
||||
|
||||
// Handle duplicates
|
||||
let uniqueId = id;
|
||||
let counter = 1;
|
||||
while (usedIds.has(uniqueId)) {
|
||||
uniqueId = `${id}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
usedIds.add(uniqueId);
|
||||
|
||||
// Add anchor span at the start of the row
|
||||
return `<tr id="${uniqueId}"><td>${phaseNum}</td><td>${description}</td>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frontmatter from markdown
|
||||
* @param {string} content - Raw markdown content
|
||||
* @returns {{data: Object, content: string}} - Parsed frontmatter and content
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
initDependencies();
|
||||
return matter(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown file to HTML
|
||||
* @param {string} filePath - Path to markdown file
|
||||
* @param {Object} options - Render options
|
||||
* @returns {{html: string, toc: Array, frontmatter: Object, title: string}}
|
||||
*/
|
||||
function renderMarkdownFile(filePath, options = {}) {
|
||||
initDependencies();
|
||||
|
||||
const rawContent = fs.readFileSync(filePath, 'utf8');
|
||||
const basePath = path.dirname(filePath);
|
||||
|
||||
// Parse frontmatter
|
||||
const { data: frontmatter, content } = parseFrontmatter(rawContent);
|
||||
|
||||
// Resolve image paths
|
||||
const resolvedContent = resolveImages(content, basePath);
|
||||
|
||||
// Render markdown to HTML
|
||||
let html = marked.parse(resolvedContent);
|
||||
|
||||
// Add IDs to headings
|
||||
html = addHeadingIds(html);
|
||||
|
||||
// Add anchor IDs to phase table rows (for inline phases in plan.md)
|
||||
html = addPhaseTableAnchors(html);
|
||||
|
||||
// Generate TOC
|
||||
const toc = generateTOC(html);
|
||||
|
||||
// Extract title from frontmatter or first h1
|
||||
let title = frontmatter.title;
|
||||
if (!title) {
|
||||
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||
title = h1Match ? h1Match[1] : path.basename(filePath, '.md');
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
toc,
|
||||
frontmatter,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TOC as HTML sidebar
|
||||
* @param {Array} toc - TOC items
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
function renderTOCHtml(toc) {
|
||||
if (!toc.length) return '';
|
||||
|
||||
const items = toc.map(({ level, id, text }) => {
|
||||
const indent = (level - 1) * 12;
|
||||
return `<li style="padding-left: ${indent}px"><a href="#${id}">${text}</a></li>`;
|
||||
}).join('\n');
|
||||
|
||||
return `<ul class="toc-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderMarkdownFile,
|
||||
resolveImages,
|
||||
resolveImageSrc,
|
||||
generateTOC,
|
||||
addHeadingIds,
|
||||
addPhaseTableAnchors,
|
||||
parseFrontmatter,
|
||||
renderTOCHtml,
|
||||
initDependencies
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Plan navigation system - detects plan structure and generates navigation
|
||||
* Delegates parsing to shared plan-table-parser module
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePlanPhases, normalizeStatus, filenameToTitle } = require('../../../_shared/lib/plan-table-parser.cjs');
|
||||
|
||||
/** Escape HTML special characters to prevent XSS */
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Generate a slug from text for anchor IDs */
|
||||
function slugify(text) {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a file is part of a plan directory
|
||||
* @param {string} filePath
|
||||
* @returns {{isPlan: boolean, planDir: string, planFile: string, phases: Array}}
|
||||
*/
|
||||
function detectPlan(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
const planFile = path.join(dir, 'plan.md');
|
||||
if (!fs.existsSync(planFile)) return { isPlan: false };
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
const phases = files
|
||||
.filter(f => f.startsWith('phase-') && f.endsWith('.md'))
|
||||
.sort((a, b) => {
|
||||
const matchA = a.match(/phase-(\d+)([a-z]?)/);
|
||||
const matchB = b.match(/phase-(\d+)([a-z]?)/);
|
||||
const numA = parseInt(matchA?.[1] || '0', 10);
|
||||
const numB = parseInt(matchB?.[1] || '0', 10);
|
||||
if (numA !== numB) return numA - numB;
|
||||
return (matchA?.[2] || '').localeCompare(matchB?.[2] || '');
|
||||
});
|
||||
|
||||
return { isPlan: true, planDir: dir, planFile, phases: phases.map(f => path.join(dir, f)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse plan.md delegating to shared parser with anchor generation
|
||||
* @param {string} planFilePath
|
||||
* @returns {Array}
|
||||
*/
|
||||
function parsePlanTable(planFilePath) {
|
||||
const content = fs.readFileSync(planFilePath, 'utf8');
|
||||
const dir = path.dirname(planFilePath);
|
||||
const phases = parsePlanPhases(content, dir, { generateAnchors: true, slugify });
|
||||
|
||||
// Enhancement: resolve files from "Phase Files" section for heading-based phases
|
||||
if (phases.length > 0) {
|
||||
const phaseFilesSection = content.match(/##\s*Phase\s*Files[\s\S]*?(?=##|$)/i);
|
||||
if (phaseFilesSection) {
|
||||
const linkRegex = /\d+\.\s*\[([^\]]+)\]\(([^)]+\.md)\)/g;
|
||||
let linkMatch;
|
||||
while ((linkMatch = linkRegex.exec(phaseFilesSection[0])) !== null) {
|
||||
const [, , linkPath] = linkMatch;
|
||||
const phaseNum = parseInt(linkMatch[1].match(/phase-0?(\d+)/i)?.[1] || '0', 10);
|
||||
const phase = phases.find(p => p.phase === phaseNum);
|
||||
if (phase && (!phase.file || phase.file === planFilePath)) {
|
||||
phase.file = path.resolve(dir, linkPath);
|
||||
phase.anchor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out inline-only phases (no separate file)
|
||||
return phases.filter(p => p.file && p.file !== planFilePath);
|
||||
}
|
||||
|
||||
/** Get navigation context for a file */
|
||||
function getNavigationContext(filePath) {
|
||||
const planInfo = detectPlan(filePath);
|
||||
if (!planInfo.isPlan) return { planInfo, currentIndex: -1, prev: null, next: null, allPhases: [] };
|
||||
|
||||
const phaseMeta = parsePlanTable(planInfo.planFile);
|
||||
const allPhases = [{ phase: 0, phaseId: '0', name: 'Plan Overview', status: 'overview', file: planInfo.planFile }, ...phaseMeta];
|
||||
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const currentIndex = allPhases.findIndex(p => path.normalize(p.file) === normalizedPath);
|
||||
const prev = currentIndex > 0 ? allPhases[currentIndex - 1] : null;
|
||||
const next = currentIndex < allPhases.length - 1 && currentIndex >= 0 ? allPhases[currentIndex + 1] : null;
|
||||
|
||||
return { planInfo, currentIndex, prev, next, allPhases };
|
||||
}
|
||||
|
||||
/** Get status badge HTML for a phase group */
|
||||
function getGroupBadge(phases) {
|
||||
const completed = phases.filter(p => p.status === 'completed').length;
|
||||
const inProgress = phases.filter(p => p.status === 'in-progress').length;
|
||||
if (completed === phases.length) return '<span class="phase-badge badge-done">✓</span>';
|
||||
if (inProgress > 0) return '<span class="phase-badge badge-progress">●</span>';
|
||||
return '<span class="phase-badge badge-pending">○</span>';
|
||||
}
|
||||
|
||||
/** Render a single phase item as HTML */
|
||||
function renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath) {
|
||||
const isActive = index === currentIndex;
|
||||
const statusClass = phase.status.replace(/\s+/g, '-');
|
||||
const normalizedPhasePath = path.normalize(phase.file);
|
||||
const isSameFile = normalizedPhasePath === normalizedCurrentPath;
|
||||
const fileExists = fs.existsSync(phase.file);
|
||||
|
||||
// M7: escape statusClass and phase.anchor to prevent XSS in HTML attributes
|
||||
const safeStatusClass = escapeHtml(statusClass);
|
||||
const safeAnchor = phase.anchor ? escapeHtml(phase.anchor) : null;
|
||||
|
||||
if (!fileExists) {
|
||||
return `<li class="phase-item unavailable" data-status="${safeStatusClass}" title="Phase planned but not yet implemented">
|
||||
<span class="phase-link-disabled">
|
||||
<span class="status-dot ${safeStatusClass}"></span>
|
||||
<span class="phase-name">${escapeHtml(phase.name)}</span>
|
||||
<span class="unavailable-badge">Planned</span>
|
||||
</span></li>`;
|
||||
}
|
||||
|
||||
let href, isInlineSection = false;
|
||||
if (isSameFile && safeAnchor) { href = `#${safeAnchor}`; isInlineSection = true; }
|
||||
else if (safeAnchor) { href = `/view?file=${encodeURIComponent(phase.file)}#${safeAnchor}`; }
|
||||
else { href = `/view?file=${encodeURIComponent(phase.file)}`; }
|
||||
|
||||
const dataAnchor = safeAnchor ? `data-anchor="${safeAnchor}"` : '';
|
||||
const inlineSectionClass = isInlineSection ? 'inline-section' : '';
|
||||
const typeIcon = isInlineSection
|
||||
? `<svg class="phase-type-icon" viewBox="0 0 16 16" fill="currentColor"><path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-.5 9.45a.75.75 0 01-1.06-1.06l-1.25 1.25a2 2 0 01-2.83-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25z"/></svg>`
|
||||
: `<svg class="phase-type-icon" viewBox="0 0 16 16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM2 1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0112.25 16h-8.5A1.75 1.75 0 012 14.25V1.75z"/></svg>`;
|
||||
|
||||
return `<li class="phase-item ${isActive ? 'active' : ''} ${inlineSectionClass}" data-status="${safeStatusClass}" ${dataAnchor}>
|
||||
<a href="${href}">${typeIcon}<span class="status-dot ${safeStatusClass}"></span><span class="phase-name">${escapeHtml(phase.name)}</span></a></li>`;
|
||||
}
|
||||
|
||||
/** Generate navigation sidebar HTML */
|
||||
function generateNavSidebar(filePath) {
|
||||
const { planInfo, currentIndex, allPhases } = getNavigationContext(filePath);
|
||||
if (!planInfo.isPlan) return '';
|
||||
|
||||
const planName = path.basename(planInfo.planDir);
|
||||
const normalizedCurrentPath = path.normalize(filePath);
|
||||
|
||||
// Flat list when <= 15 phases (no accordion grouping needed)
|
||||
if (allPhases.length <= 15) {
|
||||
const items = allPhases.map((phase, index) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join('');
|
||||
return `<nav class="plan-nav" id="plan-nav">
|
||||
<div class="plan-title"><span class="plan-icon">📖</span><span>${escapeHtml(planName)}</span></div>
|
||||
<ul class="phase-list">${items}</ul></nav>`;
|
||||
}
|
||||
|
||||
// Accordion groups for large plans (> 15 phases)
|
||||
const groups = [];
|
||||
let currentGroup = [], groupStart = 0;
|
||||
allPhases.forEach((phase, index) => {
|
||||
if (currentGroup.length === 0) groupStart = phase.phase;
|
||||
currentGroup.push({ phase, index });
|
||||
if (currentGroup.length === 10 || index === allPhases.length - 1 || (phase.phase % 10 === 0 && phase.phase !== groupStart)) {
|
||||
groups.push({ start: groupStart, end: phase.phase, phases: [...currentGroup] });
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
|
||||
const groupsHtml = groups.map(group => {
|
||||
const groupId = `phase-group-${group.start}-${group.end}`;
|
||||
const groupLabel = group.start === 0 ? 'Overview' : group.start === group.end ? `Phase ${group.start}` : `Phases ${group.start}-${group.end}`;
|
||||
const badge = getGroupBadge(group.phases.map(p => p.phase));
|
||||
const items = group.phases.map(({ phase, index }) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join('');
|
||||
return `<div class="phase-group" data-phase-id="${groupId}">
|
||||
<button class="phase-header" tabindex="0" aria-expanded="true" aria-controls="${groupId}-items">
|
||||
<span class="phase-chevron">▼</span><span class="phase-name">${escapeHtml(groupLabel)}</span>${badge}
|
||||
</button>
|
||||
<ul class="phase-items" id="${groupId}-items">${items}</ul></div>`;
|
||||
}).join('');
|
||||
|
||||
return `<nav class="plan-nav" id="plan-nav">
|
||||
<div class="plan-title"><span class="plan-icon">📖</span><span>${escapeHtml(planName)}</span></div>
|
||||
${groupsHtml}</nav>`;
|
||||
}
|
||||
|
||||
/** Generate prev/next navigation footer HTML */
|
||||
function generateNavFooter(filePath) {
|
||||
const { prev, next } = getNavigationContext(filePath);
|
||||
if (!prev && !next) return '';
|
||||
|
||||
const prevExists = prev && fs.existsSync(prev.file);
|
||||
const nextExists = next && fs.existsSync(next.file);
|
||||
|
||||
const prevHtml = prev ? (prevExists
|
||||
? `<a href="/view?file=${encodeURIComponent(prev.file)}" class="nav-prev"><span class="nav-arrow">←</span><span class="nav-label">${escapeHtml(prev.name)}</span></a>`
|
||||
: `<span class="nav-prev nav-unavailable" title="Phase planned but not yet implemented"><span class="nav-arrow">←</span><span class="nav-label">${escapeHtml(prev.name)}</span><span class="nav-badge">Planned</span></span>`)
|
||||
: '<span></span>';
|
||||
|
||||
const nextHtml = next ? (nextExists
|
||||
? `<a href="/view?file=${encodeURIComponent(next.file)}" class="nav-next"><span class="nav-label">${escapeHtml(next.name)}</span><span class="nav-arrow">→</span></a>`
|
||||
: `<span class="nav-next nav-unavailable" title="Phase planned but not yet implemented"><span class="nav-label">${escapeHtml(next.name)}</span><span class="nav-badge">Planned</span><span class="nav-arrow">→</span></span>`)
|
||||
: '<span></span>';
|
||||
|
||||
return `<footer class="nav-footer">${prevHtml}${nextHtml}</footer>`;
|
||||
}
|
||||
|
||||
module.exports = { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar, generateNavFooter };
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Port finder utility - finds available port in range
|
||||
* Used by markdown-novel-viewer server
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
|
||||
const DEFAULT_PORT = 3456;
|
||||
const PORT_RANGE_END = 3500;
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
* @param {number} port - Port to check
|
||||
* @returns {Promise<boolean>} - True if available
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first available port in range
|
||||
* @param {number} startPort - Starting port (default: 3456)
|
||||
* @returns {Promise<number>} - Available port
|
||||
* @throws {Error} - If no port available in range
|
||||
*/
|
||||
async function findAvailablePort(startPort = DEFAULT_PORT) {
|
||||
for (let port = startPort; port <= PORT_RANGE_END; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port in range ${startPort}-${PORT_RANGE_END}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
DEFAULT_PORT,
|
||||
PORT_RANGE_END
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Process manager - handles PID files and server lifecycle
|
||||
* Used by markdown-novel-viewer server
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PID_DIR = '/tmp';
|
||||
const PID_PREFIX = 'md-novel-viewer-';
|
||||
|
||||
/**
|
||||
* Get PID file path for a port
|
||||
* @param {number} port - Server port
|
||||
* @returns {string} - PID file path
|
||||
*/
|
||||
function getPidFilePath(port) {
|
||||
return path.join(PID_DIR, `${PID_PREFIX}${port}.pid`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write PID file for running server
|
||||
* @param {number} port - Server port
|
||||
* @param {number} pid - Process ID
|
||||
*/
|
||||
function writePidFile(port, pid) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
fs.writeFileSync(pidPath, String(pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PID from file
|
||||
* @param {number} port - Server port
|
||||
* @returns {number|null} - PID or null if not found
|
||||
*/
|
||||
function readPidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
const pid = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
return parseInt(pid, 10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove PID file
|
||||
* @param {number} port - Server port
|
||||
*/
|
||||
function removePidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
fs.unlinkSync(pidPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all running server instances
|
||||
* @returns {Array<{port: number, pid: number}>} - Running instances
|
||||
*/
|
||||
function findRunningInstances() {
|
||||
const instances = [];
|
||||
const files = fs.readdirSync(PID_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(PID_PREFIX) && file.endsWith('.pid')) {
|
||||
const port = parseInt(file.replace(PID_PREFIX, '').replace('.pid', ''), 10);
|
||||
const pid = readPidFile(port);
|
||||
if (pid) {
|
||||
// Check if process is actually running
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
instances.push({ port, pid });
|
||||
} catch {
|
||||
// Process not running, clean up stale PID file
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop server by port
|
||||
* @param {number} port - Server port
|
||||
* @returns {boolean} - True if stopped successfully
|
||||
*/
|
||||
function stopServer(port) {
|
||||
const pid = readPidFile(port);
|
||||
if (!pid) return false;
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
removePidFile(port);
|
||||
return true;
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running servers
|
||||
* @returns {number} - Number of servers stopped
|
||||
*/
|
||||
function stopAllServers() {
|
||||
const instances = findRunningInstances();
|
||||
let stopped = 0;
|
||||
|
||||
for (const { port, pid } of instances) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
removePidFile(port);
|
||||
stopped++;
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup graceful shutdown handlers
|
||||
* @param {number} port - Server port
|
||||
* @param {Function} cleanup - Additional cleanup function
|
||||
*/
|
||||
function setupShutdownHandlers(port, cleanup) {
|
||||
const handler = (signal) => {
|
||||
if (cleanup) cleanup();
|
||||
removePidFile(port);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', handler);
|
||||
process.on('SIGINT', handler);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPidFilePath,
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
findRunningInstances,
|
||||
stopServer,
|
||||
stopAllServers,
|
||||
setupShutdownHandlers,
|
||||
PID_PREFIX
|
||||
};
|
||||
411
.opencode/skills/markdown-novel-viewer/scripts/server.cjs
Executable file
411
.opencode/skills/markdown-novel-viewer/scripts/server.cjs
Executable file
@@ -0,0 +1,411 @@
|
||||
#!/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> Path to markdown file
|
||||
* --dir <path> Path to directory (browse mode)
|
||||
* --port <number> Server port (default: 3456, auto-increment if busy)
|
||||
* --host <addr> 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 = `
|
||||
<a href="/browse?dir=${encodeURIComponent(parentDir)}" class="icon-btn back-btn" title="Back to folder">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</a>`;
|
||||
|
||||
// Generate header nav (prev/next) for plan files
|
||||
let headerNav = '';
|
||||
if (navContext.prev || navContext.next) {
|
||||
const prevBtn = navContext.prev && fs.existsSync(navContext.prev.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.prev.file)}" class="header-nav-btn prev" title="${navContext.prev.name}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<span>Prev</span>
|
||||
</a>`
|
||||
: '';
|
||||
const nextBtn = navContext.next && fs.existsSync(navContext.next.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.next.file)}" class="header-nav-btn next" title="${navContext.next.name}">
|
||||
<span>Next</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</a>`
|
||||
: '';
|
||||
headerNav = `<div class="header-nav">${prevBtn}${nextBtn}</div>`;
|
||||
}
|
||||
|
||||
// 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 <path.md> [--port 3456] [--open]');
|
||||
console.error(' node server.cjs --dir <path> [--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);
|
||||
});
|
||||
327
.opencode/skills/markdown-novel-viewer/scripts/tests/server.test.cjs
Executable file
327
.opencode/skills/markdown-novel-viewer/scripts/tests/server.test.cjs
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Tests for markdown-novel-viewer
|
||||
* Run: node scripts/tests/server.test.cjs
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
const { isPortAvailable, findAvailablePort, DEFAULT_PORT } = require('../lib/port-finder.cjs');
|
||||
const { writePidFile, readPidFile, removePidFile, findRunningInstances } = require('../lib/process-mgr.cjs');
|
||||
const { getMimeType, MIME_TYPES, isPathSafe, sanitizeErrorMessage } = require('../lib/http-server.cjs');
|
||||
const { resolveImages, addHeadingIds, generateTOC, renderTOCHtml } = require('../lib/markdown-renderer.cjs');
|
||||
const { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar } = require('../lib/plan-navigator.cjs');
|
||||
|
||||
// Test utilities
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected "${expected}", got "${actual}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(value, message) {
|
||||
if (!value) {
|
||||
throw new Error(`${message}: expected truthy value`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFalse(value, message) {
|
||||
if (value) {
|
||||
throw new Error(`${message}: expected falsy value`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(str, substr, message) {
|
||||
if (!str.includes(substr)) {
|
||||
throw new Error(`${message}: expected to include "${substr}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test suites
|
||||
console.log('\n--- Port Finder Tests ---');
|
||||
|
||||
test('DEFAULT_PORT is 3456', () => {
|
||||
assertEqual(DEFAULT_PORT, 3456, 'Default port');
|
||||
});
|
||||
|
||||
test('isPortAvailable returns boolean', () => {
|
||||
// Sync test - function exists
|
||||
assertTrue(typeof isPortAvailable === 'function', 'Should be function');
|
||||
});
|
||||
|
||||
test('findAvailablePort returns number', () => {
|
||||
// Sync test - actual async behavior tested in integration
|
||||
assertTrue(typeof findAvailablePort === 'function', 'Should be function');
|
||||
});
|
||||
|
||||
console.log('\n--- Process Manager Tests ---');
|
||||
|
||||
test('writePidFile and readPidFile work correctly', () => {
|
||||
const testPort = 9876;
|
||||
const testPid = 12345;
|
||||
|
||||
writePidFile(testPort, testPid);
|
||||
const readPid = readPidFile(testPort);
|
||||
assertEqual(readPid, testPid, 'PID should match');
|
||||
|
||||
removePidFile(testPort);
|
||||
const afterRemove = readPidFile(testPort);
|
||||
assertEqual(afterRemove, null, 'Should be null after remove');
|
||||
});
|
||||
|
||||
test('findRunningInstances returns array', () => {
|
||||
const instances = findRunningInstances();
|
||||
assertTrue(Array.isArray(instances), 'Should return array');
|
||||
});
|
||||
|
||||
console.log('\n--- HTTP Server Tests ---');
|
||||
|
||||
test('getMimeType returns correct types', () => {
|
||||
assertEqual(getMimeType('test.html'), 'text/html', 'HTML type');
|
||||
assertEqual(getMimeType('test.css'), 'text/css', 'CSS type');
|
||||
assertEqual(getMimeType('test.js'), 'application/javascript', 'JS type');
|
||||
assertEqual(getMimeType('test.png'), 'image/png', 'PNG type');
|
||||
assertEqual(getMimeType('test.jpg'), 'image/jpeg', 'JPG type');
|
||||
assertEqual(getMimeType('test.unknown'), 'application/octet-stream', 'Unknown type');
|
||||
});
|
||||
|
||||
test('MIME_TYPES has common extensions', () => {
|
||||
assertTrue(MIME_TYPES['.html'], 'Has .html');
|
||||
assertTrue(MIME_TYPES['.css'], 'Has .css');
|
||||
assertTrue(MIME_TYPES['.js'], 'Has .js');
|
||||
assertTrue(MIME_TYPES['.png'], 'Has .png');
|
||||
assertTrue(MIME_TYPES['.md'], 'Has .md');
|
||||
});
|
||||
|
||||
console.log('\n--- Security Tests ---');
|
||||
|
||||
test('isPathSafe blocks path traversal', () => {
|
||||
assertFalse(isPathSafe('/etc/../etc/passwd', ['/home']), 'Should block .. traversal');
|
||||
assertFalse(isPathSafe('/path\0/file', ['/path']), 'Should block null bytes');
|
||||
});
|
||||
|
||||
test('isPathSafe allows valid paths', () => {
|
||||
assertTrue(isPathSafe('/tmp/test.md', ['/tmp']), 'Should allow path in allowed dir');
|
||||
});
|
||||
|
||||
test('sanitizeErrorMessage removes paths', () => {
|
||||
const sanitized = sanitizeErrorMessage('Error: /etc/passwd not found');
|
||||
assertFalse(sanitized.includes('/etc/passwd'), 'Should not contain path');
|
||||
assertIncludes(sanitized, '[path]', 'Should replace with placeholder');
|
||||
});
|
||||
|
||||
console.log('\n--- Markdown Renderer Tests ---');
|
||||
|
||||
test('resolveImages converts relative paths', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertIncludes(resolved, '/file/', 'Should include /file/ route');
|
||||
// Path is URL-encoded; decode to verify base path is present
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/path', 'Should include base path');
|
||||
});
|
||||
|
||||
test('resolveImages preserves absolute URLs', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertEqual(resolved, md, 'Should preserve absolute URL');
|
||||
});
|
||||
|
||||
test('resolveImages handles reference-style definitions', () => {
|
||||
const md = '![Step 1 Initial]\n\n[Step 1 Initial]: ./screenshots/step1.png';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertIncludes(resolved, '/file/', 'Should include /file/ route in ref definition');
|
||||
// Path is URL-encoded; decode to verify resolved path
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/path/screenshots/step1.png', 'Should resolve relative path');
|
||||
});
|
||||
|
||||
test('resolveImages handles reference-style with titles', () => {
|
||||
const md = '[logo]: ./images/logo.png "Company Logo"';
|
||||
const resolved = resolveImages(md, '/project');
|
||||
// Path is URL-encoded; decode to verify
|
||||
assertIncludes(decodeURIComponent(resolved), '/project/images/logo.png', 'Should resolve path with title');
|
||||
});
|
||||
|
||||
test('resolveImages handles inline images with titles', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base');
|
||||
// Path is URL-encoded; decode to verify
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/image.png', 'Should resolve inline with title');
|
||||
});
|
||||
|
||||
test('addHeadingIds adds id attributes', () => {
|
||||
const html = '<h1>Test Heading</h1><h2>Another</h2>';
|
||||
const withIds = addHeadingIds(html);
|
||||
assertIncludes(withIds, 'id="test-heading"', 'Should add id to h1');
|
||||
assertIncludes(withIds, 'id="another"', 'Should add id to h2');
|
||||
});
|
||||
|
||||
test('addHeadingIds handles duplicates', () => {
|
||||
const html = '<h1>Test</h1><h2>Test</h2>';
|
||||
const withIds = addHeadingIds(html);
|
||||
assertIncludes(withIds, 'id="test"', 'Should have first id');
|
||||
assertIncludes(withIds, 'id="test-1"', 'Should have unique second id');
|
||||
});
|
||||
|
||||
test('generateTOC extracts headings', () => {
|
||||
const html = '<h1 id="one">One</h1><h2 id="two">Two</h2><h3 id="three">Three</h3>';
|
||||
const toc = generateTOC(html);
|
||||
assertEqual(toc.length, 3, 'Should find 3 headings');
|
||||
assertEqual(toc[0].level, 1, 'First should be h1');
|
||||
assertEqual(toc[0].id, 'one', 'First id should be "one"');
|
||||
});
|
||||
|
||||
test('renderTOCHtml generates list', () => {
|
||||
const toc = [{ level: 1, id: 'test', text: 'Test' }];
|
||||
const html = renderTOCHtml(toc);
|
||||
assertIncludes(html, '<ul', 'Should have ul');
|
||||
assertIncludes(html, 'href="#test"', 'Should have anchor');
|
||||
assertIncludes(html, 'Test', 'Should have text');
|
||||
});
|
||||
|
||||
test('renderTOCHtml handles empty array', () => {
|
||||
const html = renderTOCHtml([]);
|
||||
assertEqual(html, '', 'Should return empty string');
|
||||
});
|
||||
|
||||
console.log('\n--- Plan Navigator Tests ---');
|
||||
|
||||
// Create temp plan structure for testing
|
||||
const testPlanDir = '/tmp/test-novel-viewer-plan';
|
||||
const testPlanFile = path.join(testPlanDir, 'plan.md');
|
||||
const testPhaseFile = path.join(testPlanDir, 'phase-01-test.md');
|
||||
|
||||
function setupTestPlan() {
|
||||
if (!fs.existsSync(testPlanDir)) {
|
||||
fs.mkdirSync(testPlanDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(testPlanFile, `# Test Plan
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Test Phase](./phase-01-test.md) | Pending |
|
||||
`);
|
||||
|
||||
fs.writeFileSync(testPhaseFile, `# Phase 1: Test Phase
|
||||
|
||||
Content here.
|
||||
`);
|
||||
}
|
||||
|
||||
function cleanupTestPlan() {
|
||||
if (fs.existsSync(testPlanDir)) {
|
||||
fs.rmSync(testPlanDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
setupTestPlan();
|
||||
|
||||
test('detectPlan identifies plan directory', () => {
|
||||
const result = detectPlan(testPlanFile);
|
||||
assertTrue(result.isPlan, 'Should detect as plan');
|
||||
assertEqual(result.planDir, testPlanDir, 'Should have correct dir');
|
||||
assertTrue(result.phases.length >= 1, 'Should find phases');
|
||||
});
|
||||
|
||||
test('detectPlan returns false for non-plan', () => {
|
||||
const result = detectPlan('/tmp/random-file.md');
|
||||
assertFalse(result.isPlan, 'Should not be plan');
|
||||
});
|
||||
|
||||
test('parsePlanTable extracts phases', () => {
|
||||
const phases = parsePlanTable(testPlanFile);
|
||||
assertTrue(phases.length >= 1, 'Should find phases');
|
||||
assertEqual(phases[0].phase, 1, 'First phase number');
|
||||
assertEqual(phases[0].name, 'Test Phase', 'Phase name');
|
||||
assertEqual(phases[0].status, 'pending', 'Status should be lowercase');
|
||||
});
|
||||
|
||||
test('getNavigationContext returns correct structure', () => {
|
||||
const ctx = getNavigationContext(testPlanFile);
|
||||
assertTrue(ctx.planInfo.isPlan, 'Should be plan');
|
||||
assertTrue(ctx.allPhases.length >= 1, 'Should have phases');
|
||||
assertEqual(ctx.currentIndex, 0, 'Plan.md should be index 0');
|
||||
});
|
||||
|
||||
test('generateNavSidebar returns HTML', () => {
|
||||
const html = generateNavSidebar(testPlanFile);
|
||||
assertIncludes(html, '<nav', 'Should have nav element');
|
||||
assertIncludes(html, 'phase-list', 'Should have phase list');
|
||||
});
|
||||
|
||||
test('generateNavSidebar returns empty for non-plan', () => {
|
||||
const html = generateNavSidebar('/tmp/random.md');
|
||||
assertEqual(html, '', 'Should return empty string');
|
||||
});
|
||||
|
||||
test('detectPlan sorts alphanumeric phase files (1a before 1b before 2)', () => {
|
||||
const alphaDir = '/tmp/test-alpha-plan';
|
||||
fs.mkdirSync(alphaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(alphaDir, 'plan.md'), '# Plan\n');
|
||||
['phase-02-core.md', 'phase-01b-config.md', 'phase-01a-setup.md'].forEach(f =>
|
||||
fs.writeFileSync(path.join(alphaDir, f), `# ${f}\n`));
|
||||
|
||||
const result = detectPlan(path.join(alphaDir, 'plan.md'));
|
||||
assertTrue(result.isPlan, 'Should be plan');
|
||||
assertEqual(result.phases.length, 3, 'Should find 3 phases');
|
||||
assertTrue(result.phases[0].endsWith('phase-01a-setup.md'), '1a first');
|
||||
assertTrue(result.phases[1].endsWith('phase-01b-config.md'), '1b second');
|
||||
assertTrue(result.phases[2].endsWith('phase-02-core.md'), '2 third');
|
||||
|
||||
fs.rmSync(alphaDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('generateNavSidebar uses flat list when <= 15 phases', () => {
|
||||
const smallDir = '/tmp/test-small-plan';
|
||||
fs.mkdirSync(smallDir, { recursive: true });
|
||||
// Create 3 phase files + plan.md (4 total — well under 15)
|
||||
const planContent = `# Plan
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Alpha](./phase-01-alpha.md) | Pending |
|
||||
| 2 | [Beta](./phase-02-beta.md) | Pending |
|
||||
| 3 | [Gamma](./phase-03-gamma.md) | Pending |
|
||||
`;
|
||||
fs.writeFileSync(path.join(smallDir, 'plan.md'), planContent);
|
||||
['phase-01-alpha.md', 'phase-02-beta.md', 'phase-03-gamma.md'].forEach(f =>
|
||||
fs.writeFileSync(path.join(smallDir, f), `# ${f}\n`));
|
||||
|
||||
const html = generateNavSidebar(path.join(smallDir, 'plan.md'));
|
||||
assertIncludes(html, 'phase-list', 'Should use flat phase-list class');
|
||||
assertFalse(html.includes('phase-group'), 'Should NOT use accordion groups');
|
||||
|
||||
fs.rmSync(smallDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
cleanupTestPlan();
|
||||
|
||||
// Summary
|
||||
console.log('\n--- Test Results ---');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nAll tests passed!');
|
||||
Reference in New Issue
Block a user