This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -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
};

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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: ![alt](src) or ![alt](src "title")
const inlineImgRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
result = result.replace(inlineImgRegex, (match, alt, src) => {
const resolvedSrc = resolveImageSrc(src, basePath);
return `![${alt}](${resolvedSrc})`;
});
// 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
};

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 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">&#10003;</span>';
if (inProgress > 0) return '<span class="phase-badge badge-progress">&#9679;</span>';
return '<span class="phase-badge badge-pending">&#9675;</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">&#128214;</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">&#9660;</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">&#128214;</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">&larr;</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">&larr;</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">&rarr;</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">&rarr;</span></span>`)
: '<span></span>';
return `<footer class="nav-footer">${prevHtml}${nextHtml}</footer>`;
}
module.exports = { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar, generateNavFooter };

View File

@@ -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
};

View File

@@ -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
};