
Markdown Novel Viewer
- 46 installs
- 7 repo stars
- Updated June 18, 2026
- duc01226/easyplatform
Runs a background HTTP server that renders markdown files with a calm, book-like reading experience.
About
A skill that serves markdown files through a local HTTP server with book-style reading formatting. A developer uses it to read long markdown documents comfortably.
- Background HTTP markdown server
- Book-like reading layout
Markdown Novel Viewer by the numbers
- 46 all-time installs (skills.sh)
- Ranked #828 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/duc01226/easyplatform --skill markdown-novel-viewerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 7 |
| Last updated | June 18, 2026 |
| Repository | duc01226/easyplatform ↗ |
What it does
Runs a background HTTP server that renders markdown files with a calm, book-like reading experience.
Files
Codex compatibility note:
>
- Invoke repository skills with$skill-namein Codex; this mirrored copy rewrites legacy Claude/skill-namereferences.
- Task tracker mandate: BEFORE executing any workflow or skill step, create/update task tracking for all steps and keep it synchronized as progress changes.
- User-question prompts mean to ask the user directly in Codex.
- Ignore Claude-specific mode-switch instructions when they appear.
- Strict execution contract: when a user explicitly invokes a skill, execute that skill protocol as written.
- Subagent authorization: when a skill is user-invoked or AI-detected and its protocol requires subagents, that skill activation authorizes use of the required spawn_agent subagent(s) for that task.- Do not skip, reorder, or merge protocol steps unless the user explicitly approves the deviation first.
- For workflow skills, execute each listed child-skill step explicitly and report step-by-step evidence.
- If a required step/tool cannot run in this environment, stop and ask the user before adapting.
<!-- CODEX:PROJECT-REFERENCE-LOADING:START -->
Codex Project-Reference Loading (No Hooks)
Codex does not receive Claude hook-based doc injection. When coding, planning, debugging, testing, or reviewing, open project docs explicitly using this routing.
Always read:
docs/project-config.json(project-specific paths, commands, modules, and workflow/test settings)docs/project-reference/docs-index-reference.md(routes to the fulldocs/project-reference/*catalog)docs/project-reference/lessons.md(always-on guardrails and anti-patterns)
Situation-based docs:
- Backend/CQRS/API/domain/entity changes:
backend-patterns-reference.md,domain-entities-reference.md,project-structure-reference.md - Frontend/UI/styling/design-system:
frontend-patterns-reference.md,scss-styling-guide.md,design-system/README.md - Spec/test-case planning or TC mapping:
feature-docs-reference.md - Integration test implementation/review:
integration-test-reference.md - E2E test implementation/review:
e2e-test-reference.md - Code review/audit work:
code-review-rules.mdplus domain docs above based on changed files
Do not read all docs blindly. Start from docs-index-reference.md, then open only relevant files for the task.
<!-- CODEX:PROJECT-REFERENCE-LOADING:END -->
Quick Summary
Goal: Background HTTP server that renders markdown files with a calm, book-like reading UI and browses directories.
Workflow:
1. Start Server — Point at a markdown file or directory with CLI options 2. View Content — Novel-themed reader (serif fonts, warm colors) or directory browser 3. Navigate Plans — Auto-detects plan structures with sidebar, phase status, keyboard shortcuts
Key Rules:
- Requires
npm installbefore first use (marked, highlight.js, gray-matter) - Use
/previewslash command for quick access - Supports background mode with local-only defaults
Be skeptical. Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence percentages (Idea should be more than 80%).
markdown-novel-viewer
Background HTTP server rendering markdown files with calm, book-like reading experience.
⚠️ Installation Required
This skill requires npm dependencies. Run one of the following:
# Option 1: Install via ClaudeKit CLI (recommended)
ck init # Runs install.sh which handles all skills
# Option 2: Manual installation
cd .claude/skills/markdown-novel-viewer
npm installDependencies: marked, highlight.js, gray-matter
Without installation, you'll get Error 500: Error rendering markdown.
Purpose
Universal viewer - pass ANY path and view it:
- Markdown files → novel-reader UI with serif fonts, warm theme
- Directories → file listing browser with clickable links
Quick Start
# View a markdown file
node .claude/skills/markdown-novel-viewer/scripts/server.cjs \
--file ./plans/my-plan/plan.md \
--open
# Browse a directory
node .claude/skills/markdown-novel-viewer/scripts/server.cjs \
--dir ./plans \
--host localhost \
--open
# Background mode
node .claude/skills/markdown-novel-viewer/scripts/server.cjs \
--file ./README.md \
--background
# Stop all running servers
node $HOME/.claude/skills/markdown-novel-viewer/scripts/server.cjs --stopSlash Command
Use /preview for quick access:
/preview plans/my-plan/plan.md # View markdown file
/preview plans/ # Browse directory
/preview --stop # Stop serverFeatures
Novel Theme
- Warm cream background (light mode)
- Dark mode with warm gold accents
- Libre Baskerville serif headings
- Inter body text, JetBrains Mono code
- Maximum 720px content width
Directory Browser
- Clean file listing with emoji icons
- Markdown files link to viewer
- Folders link to sub-directories
- Parent directory navigation (..)
- Light/dark mode support
Plan Navigation
- Auto-detects plan directory structure
- Sidebar shows all phases with status indicators
- Previous/Next navigation buttons
- Keyboard shortcuts: Arrow Left/Right
Keyboard Shortcuts
T- Toggle themeS- Toggle sidebarLeft/Right- Navigate phasesEscape- Close sidebar (mobile)
CLI Options
| Option | Description | Default |
|---|---|---|
--file <path> | Markdown file to view | - |
--dir <path> | Directory to browse | - |
--port <number> | Server port | 3456 |
--host <addr> | Host to bind | localhost |
--open | Auto-open browser | false |
--background | Run in background | false |
--stop | Stop all servers | - |
Architecture
scripts/
├── server.cjs # Main entry point
└── lib/
├── port-finder.cjs # Dynamic port allocation
├── process-mgr.cjs # PID file management
├── http-server.cjs # Core HTTP routing (/view, /browse)
├── markdown-renderer.cjs # MD→HTML conversion
└── plan-navigator.cjs # Plan detection & nav
assets/
├── template.html # Markdown viewer template
├── novel-theme.css # Combined light/dark theme
├── reader.js # Client-side interactivity
├── directory-browser.css # Directory browser stylesHTTP Routes
| Route | Description |
|---|---|
/view?file=<path> | Markdown file viewer |
/browse?dir=<path> | Directory browser |
/assets/* | Static assets |
/file/* | Local file serving (images) |
Dependencies
- Node.js built-in:
http,fs,path,net - npm:
marked,highlight.js,gray-matter(installed vianpm install)
Customization
Theme Colors (CSS Variables)
Light mode variables in assets/novel-theme.css:
--bg-primary: #faf8f3; /* Warm cream */
--accent: #8b4513; /* Saddle brown */Dark mode:
--bg-primary: #1a1a1a; /* Near black */
--accent: #d4a574; /* Warm gold */Content Width
--content-width: 720px;Local Access
Start on localhost unless you have explicitly accepted the network exposure of serving local files:
# Start locally
node server.cjs --file ./README.md --host localhost --port 3456The server returns the local URL in its output:
{
"success": true,
"url": "http://localhost:3456/view?file=...",
"port": 3456
}Troubleshooting
Port in use: Server auto-increments to next available port (3456-3500)
Images not loading: Ensure image paths are relative to markdown file
Server won't stop: Check the platform temp directory (os.tmpdir(), usually %TEMP% on Windows) for md-novel-viewer-*.pid stale PID files
Remote access denied: This viewer is intended for local use; keep --host localhost unless you have explicitly accepted the network exposure.
---
[IMPORTANT] Use task tracking to break ALL work into small tasks BEFORE starting — including tasks for each file read. This prevents context loss from long files. For simple tasks, AI MUST ATTENTION ask user whether to skip.
<!-- SYNC:ai-mistake-prevention -->
AI Mistake Prevention — Failure modes to avoid on every task: Check downstream references before deleting. Deleting components causes documentation and code staleness cascades. Map all referencing files before removal. Verify AI-generated content against actual code. AI hallucinates APIs, class names, and method signatures. Always grep to confirm existence before documenting or referencing. Trace full dependency chain after edits. Changing a definition misses downstream variables and consumers derived from it. Always trace the full chain. Trace ALL code paths when verifying correctness. Confirming code exists is not confirming it executes. Always trace early exits, error branches, and conditional skips — not just happy path. When debugging, ask "whose responsibility?" before fixing. Trace whether bug is in caller (wrong data) or callee (wrong handling). Fix at responsible layer — never patch symptom site. Assume existing values are intentional — ask WHY before changing. Before changing any constant, limit, flag, or pattern: read comments, check git blame, examine surrounding code. Verify ALL affected outputs, not just the first. Changes touching multiple stacks require verifying EVERY output. One green check is not all green checks. Holistic-first debugging — resist nearest-attention trap. When investigating any failure, list EVERY precondition first (config, env vars, DB names, endpoints, DI registrations, data preconditions), then verify each against evidence before forming any code-layer hypothesis. Surgical changes — apply the diff test. Bug fix: every changed line must trace directly to the bug. Don't restyle or improve adjacent code. Enhancement task: implement improvements AND announce them explicitly. Surface ambiguity before coding — don't pick silently. If request has multiple interpretations, present each with effort estimate and ask. Never assume all-records, file-based, or more complex path.
<!-- /SYNC:ai-mistake-prevention -->
<!-- SYNC:critical-thinking-mindset -->
Critical Thinking Mindset — Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act.
Anti-hallucination: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination.
<!-- /SYNC:critical-thinking-mindset -->
<!-- SYNC:critical-thinking-mindset:reminder -->
MUST ATTENTION apply critical thinking — every claim needs traced proof, confidence >80% to act. Anti-hallucination: never present guess as fact.
<!-- /SYNC:critical-thinking-mindset:reminder -->
<!-- SYNC:ai-mistake-prevention:reminder -->
MUST ATTENTION apply AI mistake prevention — holistic-first debugging, fix at responsible layer, surface ambiguity before coding, re-read files after compaction.
<!-- /SYNC:ai-mistake-prevention:reminder -->
Closing Reminders
IMPORTANT MUST ATTENTION break work into small todo tasks using task tracking BEFORE starting IMPORTANT MUST ATTENTION search codebase for 3+ similar patterns before creating new code IMPORTANT MUST ATTENTION cite file:line evidence for every claim (confidence >80% to act) IMPORTANT MUST ATTENTION add a final review todo task to verify work quality
[TASK-PLANNING] Before acting, analyze task scope and systematically break it into small todo tasks and sub-tasks using task tracking.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:START -->
Hookless Prompt Protocol Mirror (Auto-Synced)
Source: .claude/hooks/lib/prompt-injections.cjs + .claude/.ck.json
[WORKFLOW-EXECUTION-PROTOCOL] [BLOCKING] Workflow Execution Protocol — MANDATORY IMPORTANT MUST CRITICAL. Do not skip for any reason.
Generic portability boundary: Reusable skills and protocol text stay project-neutral; project-specific conventions are discovered from docs/project-config.json and docs/project-reference/. Apply shared AI-SDD from shared/sdd-artifact-contract.md. Read docs/project-config.json and docs/project-reference/docs-index-reference.md, then open the project reference docs named there. Any supported AI tool may execute when this shared context and local docs are available.
1. DETECT: Match prompt against workflow catalog 2. ANALYZE: Find best-match workflow AND evaluate if a custom step combination would fit better 3. ASK (REQUIRED FORMAT): Use a direct user question with this structure unless the user explicitly invoked a workflow/skill and the local protocol treats explicit invocation as confirmation:
- Question: "Which workflow do you want to activate?"
- Option 1: "Activate [BestMatch Workflow] (Recommended)"
- Option 2: "Activate custom workflow: [step1 → step2 → ...]" (include one-line rationale)
4. ACTIVATE (if confirmed): Call $workflow-start <workflowId> for standard; sequence custom steps manually 5. CREATE TASKS: task tracking for ALL workflow steps 6. EXECUTE: Follow each step in sequence [CRITICAL-THINKING-MINDSET] Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act. Anti-hallucination principle: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination. AI Attention principle (Primacy-Recency): Put the 3 most critical rules at both top and bottom of long prompts/protocols so instruction adherence survives long context windows. Goal-driven execution: Define success criteria first, loop until verified, and stop only when observable checks pass. Tests verify intent: Tests must protect business rules/invariants and fail when the protected intent breaks, not only mirror current behavior.
[LESSON-LEARNED-REMINDER] [BLOCKING] Task Planning & Continuous Improvement — MANDATORY. Do not skip.
Break work into small tasks (task tracking) before starting. Add final task: "Analyze AI mistakes & lessons learned".
Extract lessons — ROOT CAUSE ONLY, not symptom fixes:
1. Name the FAILURE MODE (reasoning/assumption failure), not symptom — "assumed API existed without reading source" not "used wrong enum value". 2. Generality test: does this failure mode apply to ≥3 contexts/codebases? If not, abstract one level up. 3. Write as a universal rule — strip project-specific names/paths/classes. Useful on any codebase. 4. Consolidate: multiple mistakes sharing one failure mode → ONE lesson. 5. Recurrence gate: "Would this recur in future session WITHOUT this reminder?" — No → skip $learn. 6. Auto-fix gate: "Could $code-review/$code-simplifier/$security/$lint catch this?" — Yes → improve review skill instead. 7. BOTH gates pass → ask user to run $learn. [TASK-PLANNING] [MANDATORY] BEFORE executing any workflow or skill step, create/update task tracking for all planned steps, then keep it synchronized as each step starts/completes.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:END -->
/**
* Directory Browser Styles
* Minimal, clean design matching novel-reader aesthetic
*/
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
background: #faf8f3;
color: #333;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e0dcd4;
}
header h1 {
font-family: 'Libre Baskerville', Georgia, serif;
font-size: 1.75rem;
font-weight: 400;
color: #8b4513;
margin-bottom: 0.5rem;
}
header .path {
font-size: 0.875rem;
color: #666;
font-family: 'JetBrains Mono', monospace;
word-break: break-all;
}
.file-list {
list-style: none;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.dir-item {
border-bottom: 1px solid #f0ebe3;
}
.dir-item:last-child {
border-bottom: none;
}
.dir-item a {
display: flex;
align-items: center;
padding: 0.875rem 1rem;
text-decoration: none;
color: #333;
transition: background 0.15s ease;
}
.dir-item a:hover {
background: #faf8f3;
}
.dir-item .icon {
font-size: 1.25rem;
margin-right: 0.75rem;
flex-shrink: 0;
}
.dir-item .name {
font-size: 0.9375rem;
word-break: break-word;
}
/* Folder styles */
.dir-item.folder .name {
font-weight: 500;
}
.dir-item.parent a {
color: #666;
}
.dir-item.parent a:hover {
color: #8b4513;
}
/* Markdown file styles */
.dir-item.markdown .name {
color: #8b4513;
}
.dir-item.markdown a:hover {
background: #f5f0e8;
}
/* Empty state */
.dir-item.empty {
padding: 2rem;
text-align: center;
color: #999;
font-style: italic;
}
footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #e0dcd4;
text-align: center;
}
footer p {
font-size: 0.8125rem;
color: #999;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
header {
border-bottom-color: #333;
}
header h1 {
color: #d4a574;
}
header .path {
color: #888;
}
.file-list {
background: #252525;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dir-item {
border-bottom-color: #333;
}
.dir-item a {
color: #e0e0e0;
}
.dir-item a:hover {
background: #2a2a2a;
}
.dir-item.parent a {
color: #888;
}
.dir-item.parent a:hover {
color: #d4a574;
}
.dir-item.markdown .name {
color: #d4a574;
}
.dir-item.markdown a:hover {
background: #2d2520;
}
.dir-item.empty {
color: #666;
}
footer {
border-top-color: #333;
}
footer p {
color: #666;
}
}
/* Responsive */
@media (max-width: 600px) {
.container {
padding: 1rem;
}
header h1 {
font-size: 1.5rem;
}
.dir-item a {
padding: 0.75rem;
}
.dir-item .icon {
font-size: 1.125rem;
}
.dir-item .name {
font-size: 0.875rem;
}
}
/**
* Novel Theme CSS
* Warm, book-like reading experience with dark/light modes
*/
/* CSS Custom Properties */
:root {
/* Light Theme (Default) */
--bg-primary: #faf8f3;
--bg-secondary: #f5f2eb;
--bg-tertiary: #ebe7de;
--text-heading: #3a3a3a;
--text-primary: #5a5a5a;
--text-secondary: #6a6a6a;
--text-muted: #8c8c8c;
--accent: #8b4513;
--accent-hover: #6d360f;
--border: #e8e4db;
--border-light: #f0ece3;
--shadow: rgba(0, 0, 0, 0.08);
--code-bg: #f8f5ef;
--link: #5c4033;
--link-hover: #8b4513;
/* Fonts */
--font-heading: 'Libre Baskerville', Georgia, serif;
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing */
--content-width: 720px;
--sidebar-width: 280px;
--header-height: 56px;
}
[data-theme='dark'] {
--bg-primary: #1a1a1a;
--bg-secondary: #252525;
--bg-tertiary: #303030;
--text-heading: #e0dcd3;
--text-primary: #b0aca3;
--text-secondary: #9a9a9a;
--text-muted: #707070;
--accent: #d4a574;
--accent-hover: #e0b98a;
--border: #3a3a3a;
--border-light: #2a2a2a;
--shadow: rgba(0, 0, 0, 0.3);
--code-bg: #2a2a2a;
--link: #d4a574;
--link-hover: #e8c9a0;
}
/* Base Reset */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 18px;
scroll-behavior: smooth;
}
html[data-font-size='S'] {
font-size: 16px;
}
html[data-font-size='M'] {
font-size: 18px;
}
html[data-font-size='L'] {
font-size: 20px;
}
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.7;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
/* Header */
.reader-header {
position: fixed;
top: 0;
left: var(--sidebar-width);
right: 0;
height: var(--header-height);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
z-index: 100;
transition: left 0.3s ease;
}
/* When sidebar is hidden, header spans full width */
body:has(.sidebar.hidden) .reader-header {
left: 0;
}
.header-left,
.header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.back-to-dashboard {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
border-radius: 6px;
transition: all 0.15s ease;
}
.back-to-dashboard:hover {
color: var(--accent);
background: var(--accent-bg);
}
.back-to-dashboard svg {
flex-shrink: 0;
}
.header-divider {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 0.25rem;
}
.header-center {
flex: 1;
text-align: center;
overflow: hidden;
}
.doc-title {
font-family: var(--font-heading);
font-size: 1rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-btn {
background: transparent;
border: none;
padding: 0.5rem;
cursor: pointer;
color: var(--text-secondary);
border-radius: 6px;
transition:
background 0.2s,
color 0.2s;
}
.icon-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/* Back button (link styled as icon-btn) */
a.icon-btn {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
padding: 0.5rem;
border-radius: 6px;
transition:
background 0.2s,
color 0.2s;
}
a.icon-btn.back-btn {
color: var(--text-secondary);
}
a.icon-btn.back-btn:hover {
color: var(--accent);
background: var(--bg-tertiary);
}
/* Header Navigation (prev/next in header) */
.header-nav {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding-left: 0.5rem;
border-left: 1px solid var(--border);
}
.header-nav-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 6px;
transition: all 0.15s ease;
white-space: nowrap;
}
.header-nav-btn:hover {
color: var(--accent);
background: var(--bg-tertiary);
text-decoration: none;
}
.header-nav-btn svg {
flex-shrink: 0;
}
.header-nav-btn.prev svg {
margin-right: 0.125rem;
}
.header-nav-btn.next svg {
margin-left: 0.125rem;
}
/* Hide header nav text on small screens */
@media (max-width: 600px) {
.header-nav-btn span {
display: none;
}
.header-nav-btn {
padding: 0.375rem;
}
}
/* Theme toggle icons */
.sun-icon {
display: block;
}
.moon-icon {
display: none;
}
[data-theme='dark'] .sun-icon {
display: none;
}
[data-theme='dark'] .moon-icon {
display: block;
}
/* Font controls */
.font-controls {
display: flex;
gap: 2px;
background: var(--bg-tertiary);
border-radius: 6px;
padding: 2px;
}
.font-btn {
background: transparent;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
transition: all 0.2s;
}
.font-btn:hover {
color: var(--text-primary);
}
.font-btn.active {
background: var(--bg-primary);
color: var(--accent);
box-shadow: 0 1px 2px var(--shadow);
}
/* Layout */
.layout {
display: flex;
margin-top: var(--header-height);
min-height: calc(100vh - var(--header-height));
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 1.5rem;
overflow-y: auto;
position: fixed;
top: var(--header-height);
bottom: 0;
left: 0;
transform: translateX(0);
transition: transform 0.3s ease;
}
.sidebar.hidden {
transform: translateX(-100%);
}
/* Plan Navigation */
.plan-nav {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.plan-title {
font-family: var(--font-heading);
font-size: 0.875rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.plan-icon {
font-size: 1rem;
}
.phase-list {
list-style: none;
}
.phase-item {
margin-bottom: 0.25rem;
}
/* Inline section items (anchors within same doc) - indented with visual distinction */
.phase-item.inline-section {
margin-left: 0.75rem;
position: relative;
}
.phase-item.inline-section::before {
content: '';
position: absolute;
left: -0.5rem;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
border-radius: 1px;
}
.phase-item a {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
border-radius: 6px;
transition: all 0.2s;
}
/* Inline sections have subtler styling */
.phase-item.inline-section a {
font-size: 0.8125rem;
padding: 0.375rem 0.625rem;
background: var(--bg-secondary);
border: 1px solid transparent;
}
.phase-item.inline-section a:hover {
background: var(--bg-tertiary);
border-color: var(--border);
}
.phase-item a:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.phase-item.active a {
background: var(--accent);
color: white;
}
.phase-item.inline-section.active a {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* Type indicator icon */
.phase-type-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
opacity: 0.6;
}
.phase-item.active .phase-type-icon {
opacity: 1;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.pending {
background: #d4a574;
}
.status-dot.in-progress {
background: #4a90d9;
}
.status-dot.completed,
.status-dot.done {
background: #5cb85c;
}
.status-dot.overview {
background: #8b4513;
}
/* Unavailable/Planned phases */
.phase-item.unavailable {
opacity: 0.6;
}
.phase-item.unavailable .phase-link-disabled {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--text-muted);
font-size: 0.875rem;
border-radius: 6px;
cursor: not-allowed;
background: var(--bg-tertiary);
border: 1px dashed var(--border);
}
.phase-item.unavailable .status-dot {
background: var(--text-muted);
opacity: 0.5;
}
.unavailable-badge,
.nav-badge {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.15rem 0.4rem;
border-radius: 3px;
background: var(--text-muted);
color: var(--bg-primary);
margin-left: auto;
}
/* Nav footer unavailable state */
.nav-unavailable {
opacity: 0.5;
cursor: not-allowed;
border-style: dashed !important;
}
.nav-unavailable .nav-badge {
font-size: 0.6rem;
margin: 0 0.25rem;
}
/* TOC */
.toc-section {
margin-top: 1rem;
}
.toc-title {
font-family: var(--font-heading);
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.toc-list {
list-style: none;
}
.toc-list li {
margin-bottom: 0.25rem;
}
.toc-list a {
display: block;
padding: 0.25rem 0;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: color 0.2s;
}
.toc-list a:hover {
color: var(--accent);
}
/* Main Content */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
padding: 2rem;
transition: margin-left 0.3s ease;
}
body:not(.has-plan) .main-content {
margin-left: var(--sidebar-width);
}
.sidebar.hidden + .main-content {
margin-left: 0;
}
.content {
max-width: var(--content-width);
margin: 0 auto;
padding-bottom: 4rem;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
font-weight: 700;
margin: 2rem 0 1rem;
line-height: 1.3;
text-align: center;
color: var(--text-heading);
}
h1 {
font-size: 2rem;
margin-top: 0;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border);
}
h2 {
font-size: 1.5rem;
margin-top: 3rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
h2::before,
h2::after {
content: '';
width: 20px;
height: 1px;
background: var(--border);
}
h3 {
font-size: 1.25rem;
}
h4,
h5,
h6 {
font-size: 1rem;
text-align: left;
}
p {
margin-bottom: 1rem;
}
a {
color: var(--link);
text-decoration: underline;
text-decoration-color: var(--border);
transition:
color 0.2s,
text-decoration-color 0.2s;
}
a:hover {
color: var(--link-hover);
text-decoration-color: var(--link-hover);
}
/* Lists */
ul,
ol {
margin: 1rem 0;
padding-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
li > ul,
li > ol {
margin: 0.5rem 0;
}
/* Task lists (GFM) */
ul:has(input[type='checkbox']) {
list-style: none;
padding-left: 0;
}
ul:has(input[type='checkbox']) li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
input[type='checkbox'] {
margin-top: 0.35rem;
accent-color: var(--accent);
}
/* Code */
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--code-bg);
padding: 0.15em 0.4em;
border-radius: 4px;
}
pre {
background: var(--code-bg);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-light);
}
pre code {
background: none;
padding: 0;
font-size: 0.85rem;
line-height: 1.6;
}
/* Blockquote */
blockquote {
border-left: 3px solid var(--accent);
margin: 1.5rem 0;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-radius: 0 8px 8px 0;
font-style: italic;
}
blockquote p:last-child {
margin-bottom: 0;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid var(--border);
}
th {
background: var(--bg-secondary);
font-weight: 600;
}
tr:nth-child(even) {
background: var(--bg-secondary);
}
/* Images */
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1.5rem auto;
display: block;
box-shadow: 0 4px 12px var(--shadow);
}
/* Horizontal rule */
hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
/* Navigation Footer */
.nav-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
}
.nav-prev,
.nav-next {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
color: var(--text-secondary);
text-decoration: none;
border: 1px solid var(--border);
border-radius: 8px;
transition: all 0.2s;
}
.nav-prev:hover,
.nav-next:hover {
background: var(--bg-secondary);
color: var(--accent);
border-color: var(--accent);
}
.nav-arrow {
font-size: 1.2rem;
}
.nav-label {
font-size: 0.875rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Responsive */
@media (max-width: 900px) {
.reader-header {
left: 0;
}
/* When sidebar becomes visible on mobile, shift header */
body:has(.sidebar.visible) .reader-header {
left: var(--sidebar-width);
}
.sidebar {
transform: translateX(-100%);
box-shadow: 2px 0 8px var(--shadow);
}
.sidebar.visible {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
}
@media (max-width: 600px) {
html {
font-size: 16px;
}
.reader-header {
padding: 0 0.75rem;
}
.content {
padding: 0 0.5rem;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
}
.font-controls {
display: none;
}
.nav-footer {
flex-direction: column;
gap: 1rem;
}
.nav-prev,
.nav-next {
width: 100%;
justify-content: center;
}
}
/* Print styles */
@media print {
.reader-header,
.sidebar,
.nav-footer,
.font-controls,
#theme-toggle,
#sidebar-toggle {
display: none !important;
}
.main-content {
margin-left: 0;
}
body {
background: white;
color: black;
}
a {
color: inherit;
text-decoration: underline;
}
pre {
border: 1px solid #ddd;
}
}
/**
* Reader.js - Client-side interactivity for novel viewer
* Handles theme toggle, font size, sidebar, and keyboard navigation
*/
(function () {
'use strict';
// DOM Elements
const html = document.documentElement;
const themeToggle = document.getElementById('theme-toggle');
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
const fontBtns = document.querySelectorAll('.font-btn');
const hljsLight = document.getElementById('hljs-light');
const hljsDark = document.getElementById('hljs-dark');
// Storage keys (shared with kanban dashboard for theme persistence)
const THEME_KEY = 'theme';
const FONT_KEY = 'novel-viewer-font';
const SIDEBAR_KEY = 'novel-viewer-sidebar';
// Initialize theme
function initTheme() {
const stored = localStorage.getItem(THEME_KEY);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
setTheme(theme);
}
// Set theme
function setTheme(theme) {
html.dataset.theme = theme;
localStorage.setItem(THEME_KEY, theme);
// Switch highlight.js theme
if (hljsLight && hljsDark) {
hljsLight.disabled = theme === 'dark';
hljsDark.disabled = theme === 'light';
}
}
// Toggle theme
function toggleTheme() {
const current = html.dataset.theme || 'light';
const next = current === 'light' ? 'dark' : 'light';
setTheme(next);
}
// Initialize font size
function initFontSize() {
const stored = localStorage.getItem(FONT_KEY) || 'M';
setFontSize(stored);
}
// Set font size
function setFontSize(size) {
html.dataset.fontSize = size;
localStorage.setItem(FONT_KEY, size);
// Update button states
fontBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.size === size);
});
}
// Initialize sidebar
function initSidebar() {
const stored = localStorage.getItem(SIDEBAR_KEY);
const isMobile = window.innerWidth <= 900;
if (isMobile) {
sidebar?.classList.add('hidden');
} else if (stored === 'hidden') {
sidebar?.classList.add('hidden');
}
}
// Toggle sidebar
function toggleSidebar() {
const isHidden = sidebar?.classList.toggle('hidden');
localStorage.setItem(SIDEBAR_KEY, isHidden ? 'hidden' : 'visible');
}
// Keyboard navigation
function handleKeydown(e) {
// Skip if in input/textarea
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
const navPrev = document.querySelector('.nav-prev');
const navNext = document.querySelector('.nav-next');
switch (e.key) {
case 'ArrowLeft':
if (navPrev) {
e.preventDefault();
window.location.href = navPrev.href;
}
break;
case 'ArrowRight':
if (navNext) {
e.preventDefault();
window.location.href = navNext.href;
}
break;
case 'Escape':
if (window.innerWidth <= 900 && sidebar && !sidebar.classList.contains('hidden')) {
toggleSidebar();
}
break;
case 't':
case 'T':
if (!e.ctrlKey && !e.metaKey) {
toggleTheme();
}
break;
case 's':
case 'S':
if (!e.ctrlKey && !e.metaKey) {
toggleSidebar();
}
break;
}
}
// Smooth scroll to anchor with sidebar active state update
function handleAnchorClick(e) {
const anchor = e.target.closest('a');
const href = anchor?.getAttribute('href');
if (href?.startsWith('#')) {
e.preventDefault();
const targetId = href.slice(1);
const target = document.getElementById(targetId);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.pushState(null, '', href);
// Update sidebar active state
updateSidebarActiveState(targetId);
}
}
}
// Update sidebar active state based on anchor
function updateSidebarActiveState(anchorId) {
const planNav = document.getElementById('plan-nav');
if (!planNav) return;
// Remove active from all items
planNav.querySelectorAll('.phase-item').forEach(item => {
item.classList.remove('active');
});
// Add active to matching item
const matchingItem = planNav.querySelector(`[data-anchor="${anchorId}"]`);
if (matchingItem) {
matchingItem.classList.add('active');
}
}
// Setup Intersection Observer for section tracking
function setupSectionObserver() {
const planNav = document.getElementById('plan-nav');
if (!planNav) return;
// Get all anchors from sidebar
const anchors = Array.from(planNav.querySelectorAll('[data-anchor]')).map(item => item.dataset.anchor);
if (anchors.length === 0) return;
// Find corresponding elements in content
const sections = anchors.map(id => document.getElementById(id)).filter(el => el !== null);
if (sections.length === 0) return;
// Create observer
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
updateSidebarActiveState(entry.target.id);
}
});
},
{
rootMargin: '-20% 0px -60% 0px', // Trigger when section is in upper portion of viewport
threshold: 0
}
);
// Observe all sections
sections.forEach(section => observer.observe(section));
}
// Handle hash change (browser back/forward)
function handleHashChange() {
const hash = window.location.hash;
if (hash) {
const targetId = hash.slice(1);
const target = document.getElementById(targetId);
if (target) {
updateSidebarActiveState(targetId);
}
}
}
// Initialize
function init() {
initTheme();
initFontSize();
initSidebar();
// Event listeners
themeToggle?.addEventListener('click', toggleTheme);
sidebarToggle?.addEventListener('click', toggleSidebar);
fontBtns.forEach(btn => {
btn.addEventListener('click', () => setFontSize(btn.dataset.size));
});
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleAnchorClick);
// Handle hash change for sidebar active state
window.addEventListener('hashchange', handleHashChange);
// Setup section observer for auto-highlighting sidebar
setupSectionObserver();
// Handle initial hash on page load
if (window.location.hash) {
handleHashChange();
}
// Handle resize
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
if (window.innerWidth > 900) {
sidebar?.classList.remove('visible');
}
}, 100);
});
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem(THEME_KEY)) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
// Run when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{title}} - Novel Viewer</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<!-- Apply stored preferences BEFORE CSS loads to prevent FOUC -->
<script>
(function () {
var h = document.documentElement;
var t = localStorage.getItem('theme');
var f = localStorage.getItem('novel-viewer-font');
if (t) h.dataset.theme = t;
else if (window.matchMedia('(prefers-color-scheme:dark)').matches) h.dataset.theme = 'dark';
if (f) h.dataset.fontSize = f;
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="/assets/novel-theme.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-light" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-dark" disabled />
</head>
<body class="{{has-plan}}">
<header class="reader-header">
<div class="header-left">
{{back-button}}
<button id="sidebar-toggle" class="icon-btn" aria-label="Toggle sidebar" title="Toggle sidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
{{header-nav}}
</div>
<div class="header-center">
<span class="doc-title">{{title}}</span>
</div>
<div class="header-right">
<div class="font-controls">
<button class="font-btn" data-size="S" title="Small font">S</button>
<button class="font-btn" data-size="M" title="Medium font">M</button>
<button class="font-btn" data-size="L" title="Large font">L</button>
</div>
<button id="theme-toggle" class="icon-btn" aria-label="Toggle theme" title="Toggle theme">
<svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
<svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</div>
</header>
<div class="layout">
<aside class="sidebar" id="sidebar">
{{nav-sidebar}}
<div class="toc-section">
<div class="toc-title">Contents</div>
{{toc}}
</div>
</aside>
<main class="main-content">
<article class="content">{{content}}</article>
{{nav-footer}}
</main>
</div>
<script>
window.__frontmatter = {{frontmatter}};
</script>
<script src="/assets/reader.js"></script>
</body>
</html>
{
"name": "markdown-novel-viewer",
"version": "1.0.0",
"description": "Background HTTP server rendering markdown files with calm, book-like reading experience",
"main": "scripts/server.cjs",
"scripts": {
"start": "node scripts/server.cjs",
"test": "node scripts/tests/server.test.cjs"
},
"dependencies": {
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"marked": "^17.0.0"
}
}
/**
* 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));
}
function isWithinDirectory(filePath, baseDir) {
const relativePath = path.relative(baseDir, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
/**
* 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 => isWithinDirectory(resolved, 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>/preview</code> command 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
};
/**
* 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;
/**
* Initialize markdown dependencies
*/
function initDependencies() {
if (!marked) {
const { Marked } = require('marked');
hljs = require('highlight.js');
marked = new Marked({
gfm: true,
breaks: true
});
// Configure highlight.js renderer
marked.setOptions({
highlight: (code, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch {
return code;
}
}
return hljs.highlightAuto(code).value;
}
});
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
};
/**
* Plan navigation system - detects plan structure and generates navigation
* Enables sidebar navigation for multi-phase plans
*/
const fs = require('fs');
const path = require('path');
/**
* Detect if a file is part of a plan directory
* @param {string} filePath - Path to markdown file
* @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 };
}
// Find all phase files
const files = fs.readdirSync(dir);
const phases = files
.filter(f => f.startsWith('phase-') && f.endsWith('.md'))
.sort((a, b) => {
// Sort by phase number
const numA = parseInt(a.match(/phase-(\d+)/)?.[1] || '0', 10);
const numB = parseInt(b.match(/phase-(\d+)/)?.[1] || '0', 10);
return numA - numB;
});
return {
isPlan: true,
planDir: dir,
planFile,
phases: phases.map(f => path.join(dir, f))
};
}
/**
* Normalize status string to standard format
* @param {string} raw - Raw status text
* @returns {string} - Normalized status (completed, in-progress, pending)
*/
function normalizeStatus(raw) {
const s = (raw || '').toLowerCase().trim();
// Match various completed indicators
if (s.includes('complete') || s.includes('done') || s.includes('✓') || s.includes('✅')) {
return 'completed';
}
// Match in-progress indicators
if (s.includes('progress') || s.includes('active') || s.includes('wip') || s.includes('🔄')) {
return 'in-progress';
}
return 'pending';
}
/**
* Generate a slug from text for use as anchor ID
* @param {string} text - Text to slugify
* @returns {string} - URL-safe slug
*/
function slugify(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Parse plan.md to extract phase metadata from table
* Supports multiple table formats:
* 1. Standard: | Phase | Name | Status | [Link](path) |
* 2. Link-first: | [Phase X](path) | Description | Status | ... |
* 3. Heading-based: ### Phase X: Name with - Status: XXX
* @param {string} planFilePath - Path to plan.md
* @returns {Array<{phase: number, name: string, status: string, file: string, anchor: string}>}
*/
function parsePlanTable(planFilePath) {
const content = fs.readFileSync(planFilePath, 'utf8');
const dir = path.dirname(planFilePath);
const phases = [];
// Format 1: Standard table | Phase | Name | Status | [Link](path) |
const standardRegex = /\|\s*(\d+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*\[([^\]]+)\]\(([^)]+)\)/g;
let match;
while ((match = standardRegex.exec(content)) !== null) {
const [, phase, name, status, linkText, linkPath] = match;
phases.push({
phase: parseInt(phase, 10),
name: name.trim(),
status: normalizeStatus(status),
file: path.resolve(dir, linkPath),
linkText: linkText.trim()
});
}
// Format 2: Link-first table | [Phase X](path) | Description | Status | ... |
// Matches: | [Phase 1](phase-01-xxx.md) | Description | ✓ Complete | 4h |
if (phases.length === 0) {
const linkFirstRegex = /\|\s*\[(?:Phase\s*)?(\d+)\]\(([^)]+)\)\s*\|\s*([^|]+)\s*\|\s*([^|]+)/g;
while ((match = linkFirstRegex.exec(content)) !== null) {
const [, phase, linkPath, name, status] = match;
phases.push({
phase: parseInt(phase, 10),
name: name.trim(),
status: normalizeStatus(status),
file: path.resolve(dir, linkPath),
linkText: `Phase ${phase}`
});
}
}
// Format 2b: Number-first with link in col 2: | 1 | [Name](path) | Status | ... |
// Matches: | 1 | [Tab Structure](./phase-01-xxx.md) | Pending | High | 4h |
if (phases.length === 0) {
const numLinkRegex = /\|\s*(\d+)\s*\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|\s*([^|]+)/g;
while ((match = numLinkRegex.exec(content)) !== null) {
const [, phase, name, linkPath, status] = match;
phases.push({
phase: parseInt(phase, 10),
name: name.trim(),
status: normalizeStatus(status),
file: path.resolve(dir, linkPath),
linkText: name.trim()
});
}
}
// Format 2c: Simple table without links: | Phase | Description | Status |
// Matches: | 01 | Backend: Install deps | Completed ✅ |
if (phases.length === 0) {
const simpleTblRegex = /\|\s*0?(\d+)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|/g;
while ((match = simpleTblRegex.exec(content)) !== null) {
const [fullMatch, phase, name, status] = match;
// Skip header rows and separator rows
if (name.trim().toLowerCase() === 'description' || name.trim().toLowerCase() === 'name') continue;
if (name.includes('---') || name.includes('===')) continue;
const phaseNum = parseInt(phase, 10);
phases.push({
phase: phaseNum,
name: name.trim(),
status: normalizeStatus(status),
file: planFilePath,
linkText: name.trim(),
anchor: `phase-${String(phaseNum).padStart(2, '0')}-${slugify(name.trim())}`
});
}
}
// Format 3: Heading-based phases (### Phase X: Name with - Status: XXX)
if (phases.length === 0) {
const contentLines = content.split('\n');
let currentPhase = null;
for (let i = 0; i < contentLines.length; i++) {
const line = contentLines[i];
const headingMatch = /###\s*Phase\s*(\d+)[:\s]+(.+)/i.exec(line);
if (headingMatch) {
if (currentPhase) phases.push(currentPhase);
const phaseNum = parseInt(headingMatch[1], 10);
const phaseName = headingMatch[2].trim();
currentPhase = {
phase: phaseNum,
name: phaseName,
status: 'pending',
file: planFilePath,
linkText: `Phase ${phaseNum}`,
anchor: `phase-${String(phaseNum).padStart(2, '0')}-${slugify(phaseName)}`
};
}
// Look for status in subsequent lines
if (currentPhase) {
const statusMatch = /-\s*Status:\s*(.+)/i.exec(line);
if (statusMatch) {
currentPhase.status = normalizeStatus(statusMatch[1]);
}
}
}
if (currentPhase) phases.push(currentPhase);
}
// Format 4: Bullet-list phases with nested File: references (check early - specific pattern)
// Matches:
// - Phase 01: Name ✅ (date)
// - File: `phase-01-name.md`
// - Completed: date
// Check if content has this specific pattern before proceeding
if (phases.length === 0 && /^-\s*Phase\s*\d+[:\s]/m.test(content)) {
const lines = content.split('\n');
let currentPhase = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match phase line: "- Phase 01: Name ✅" or "- Phase 01: Name (date)"
const phaseMatch = /^-\s*Phase\s*0?(\d+)[:\s]+([^✅✓\n]+)/i.exec(line);
if (phaseMatch) {
// Save previous phase if exists
if (currentPhase) phases.push(currentPhase);
const phaseNum = parseInt(phaseMatch[1], 10);
const name = phaseMatch[2].trim().replace(/\s*\([^)]*\)\s*$/, ''); // Remove trailing (date)
const hasCheckmark = /[✅✓]/.test(line);
currentPhase = {
phase: phaseNum,
name: name,
status: hasCheckmark ? 'completed' : 'pending',
file: planFilePath, // Default to plan.md, will be updated if File: found
linkText: name,
anchor: `phase-${String(phaseNum).padStart(2, '0')}-${slugify(name)}`
};
continue;
}
// Look for nested File: reference within current phase
if (currentPhase) {
const fileMatch = /^\s+-\s*File:\s*`?([^`\n]+)`?/i.exec(line);
if (fileMatch) {
const fileName = fileMatch[1].trim();
currentPhase.file = path.resolve(dir, fileName);
// Clear anchor when separate file exists
currentPhase.anchor = null;
}
// Check for status indicators in nested lines
const statusMatch = /^\s+-\s*(Completed|Status):\s*(.+)/i.exec(line);
if (statusMatch) {
currentPhase.status = normalizeStatus(statusMatch[2]);
}
// End current phase when we hit another top-level non-phase item or section header
if (/^##/.test(line) || (/^-\s/.test(line) && !/^-\s*Phase/i.test(line) && !/^\s+-/.test(line))) {
phases.push(currentPhase);
currentPhase = null;
}
}
}
// Push last phase if exists
if (currentPhase) phases.push(currentPhase);
}
// Format 5: Numbered list phases with checkbox status
// Matches: 1) **Discovery** with status from - [x] Discovery: ...
if (phases.length === 0) {
// First pass: find numbered phases like "1) **Name**" or "1. **Name**"
const numberedPhaseRegex = /^(\d+)[)\.]\s*\*\*([^*]+)\*\*/gm;
const phaseMap = new Map();
while ((match = numberedPhaseRegex.exec(content)) !== null) {
const [, num, name] = match;
const phaseNum = parseInt(num, 10);
phaseMap.set(name.trim().toLowerCase(), {
phase: phaseNum,
name: name.trim(),
status: 'pending',
file: planFilePath,
linkText: name.trim(),
anchor: `phase-${String(phaseNum).padStart(2, '0')}-${slugify(name.trim())}`
});
}
// Second pass: find checkbox status like "- [x] Name:" or "- [ ] Name:"
const checkboxRegex = /^-\s*\[(x| )\]\s*([^:]+)/gmi;
while ((match = checkboxRegex.exec(content)) !== null) {
const [, checked, name] = match;
const key = name.trim().toLowerCase();
if (phaseMap.has(key)) {
phaseMap.get(key).status = checked.toLowerCase() === 'x' ? 'completed' : 'pending';
}
}
// Convert map to array sorted by phase number
if (phaseMap.size > 0) {
phases.push(...Array.from(phaseMap.values()).sort((a, b) => a.phase - b.phase));
}
}
// Format 6: Checkbox list with bold links
// Matches: - [ ] **[Phase 1: Name](./phase-01-xxx.md)** or - [x] **[Phase 1](path)**
if (phases.length === 0) {
const checkboxLinkRegex = /^-\s*\[(x| )\]\s*\*\*\[(?:Phase\s*)?(\d+)[:\s]*([^\]]*)\]\(([^)]+)\)\*\*/gmi;
while ((match = checkboxLinkRegex.exec(content)) !== null) {
const [, checked, phase, name, linkPath] = match;
phases.push({
phase: parseInt(phase, 10),
name: name.trim() || `Phase ${phase}`,
status: checked.toLowerCase() === 'x' ? 'completed' : 'pending',
file: path.resolve(dir, linkPath),
linkText: name.trim() || `Phase ${phase}`
});
}
}
// Enhancement: Extract file paths from "Phase Files" section if phases point to plan.md
// This handles plans with heading-based phases + separate file links section
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 [, linkName, linkPath] = linkMatch;
// Extract phase number from filename (phase-01-xxx.md -> 1)
const phaseNum = parseInt(linkName.match(/phase-0?(\d+)/i)?.[1] || '0', 10);
// Update corresponding phase's file path
const phase = phases.find(p => p.phase === phaseNum);
if (phase && phase.file === planFilePath) {
phase.file = path.resolve(dir, linkPath);
}
}
}
}
// Filter out phases that only point to the plan.md itself (inline sections)
// Only keep phases that have separate phase files
return phases.filter(p => p.file !== planFilePath);
}
/**
* Get navigation context for a file
* @param {string} filePath - Current file path
* @returns {{planInfo: Object, currentIndex: number, prev: Object, next: Object, allPhases: Array}}
*/
function getNavigationContext(filePath) {
const planInfo = detectPlan(filePath);
if (!planInfo.isPlan) {
return { planInfo, currentIndex: -1, prev: null, next: null, allPhases: [] };
}
// Parse plan table for metadata
const phaseMeta = parsePlanTable(planInfo.planFile);
// Build all phases list including plan.md
const allPhases = [
{
phase: 0,
name: 'Plan Overview',
status: 'overview',
file: planInfo.planFile
},
...phaseMeta
];
// Find current file index
const normalizedPath = path.normalize(filePath);
const currentIndex = allPhases.findIndex(p => path.normalize(p.file) === normalizedPath);
// Get prev/next
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
};
}
/**
* Generate navigation sidebar HTML
* @param {string} filePath - Current file path
* @returns {string} - HTML navigation sidebar
*/
function generateNavSidebar(filePath) {
const { planInfo, currentIndex, allPhases } = getNavigationContext(filePath);
if (!planInfo.isPlan) {
return '';
}
const planName = path.basename(planInfo.planDir);
const normalizedCurrentPath = path.normalize(filePath);
const items = allPhases.map((phase, index) => {
const isActive = index === currentIndex;
const statusClass = phase.status.replace(/\s+/g, '-');
const normalizedPhasePath = path.normalize(phase.file);
const isSameFile = normalizedPhasePath === normalizedCurrentPath;
// Check if phase file actually exists on disk
const fileExists = fs.existsSync(phase.file);
const unavailableClass = !fileExists ? 'unavailable' : '';
// If file doesn't exist, render as non-clickable span with tooltip
if (!fileExists) {
return `
<li class="phase-item ${unavailableClass}" data-status="${statusClass}" title="Phase planned but not yet implemented">
<span class="phase-link-disabled">
<span class="status-dot ${statusClass}"></span>
<span class="phase-name">${phase.name}</span>
<span class="unavailable-badge">Planned</span>
</span>
</li>
`;
}
// Build href: use anchor for same-file phases, full URL for different files
let href;
let isInlineSection = false;
if (isSameFile && phase.anchor) {
// Same file with anchor - use hash fragment only for smooth scrolling
href = `#${phase.anchor}`;
isInlineSection = true;
} else if (phase.anchor) {
// Different file with anchor
href = `/view?file=${encodeURIComponent(phase.file)}#${phase.anchor}`;
} else {
// No anchor (separate phase file or plan overview)
href = `/view?file=${encodeURIComponent(phase.file)}`;
}
// Add data attributes for client-side section tracking
const dataAnchor = phase.anchor ? `data-anchor="${phase.anchor}"` : '';
const inlineSectionClass = isInlineSection ? 'inline-section' : '';
// Type icon: hash/anchor for inline sections, file for separate docs
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="${statusClass}" ${dataAnchor}>
<a href="${href}">
${typeIcon}
<span class="status-dot ${statusClass}"></span>
<span class="phase-name">${phase.name}</span>
</a>
</li>
`;
}).join('');
return `
<nav class="plan-nav" id="plan-nav">
<div class="plan-title">
<span class="plan-icon">📖</span>
<span>${planName}</span>
</div>
<ul class="phase-list">
${items}
</ul>
</nav>
`;
}
/**
* Generate prev/next navigation footer
* @param {string} filePath - Current file path
* @returns {string} - HTML navigation footer
*/
function generateNavFooter(filePath) {
const { prev, next } = getNavigationContext(filePath);
if (!prev && !next) {
return '';
}
// Check if prev/next files exist
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">${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">${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">${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">${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
};
/**
* 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
};
/**
* Process manager - handles PID files and server lifecycle
* Used by markdown-novel-viewer server
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PID_DIR = os.tmpdir();
const PID_PREFIX = 'md-novel-viewer-';
function ensurePidDir() {
fs.mkdirSync(PID_DIR, { recursive: true });
}
/**
* 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) {
ensurePidDir();
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 = [];
if (!fs.existsSync(PID_DIR)) {
return 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
};
#!/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 localhost]
* 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)
* --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 { spawn } = 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 = validateHost(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;
}
function validateHost(host) {
if (!/^(localhost|127\.0\.0\.1)$/.test(host)) {
throw new Error(`Invalid host: ${host}`);
}
return host;
}
/**
* 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 command;
let args;
if (platform === 'darwin') {
command = 'open';
args = [url];
} else if (platform === 'win32') {
command = 'cmd';
args = ['/c', 'start', '', url];
} else {
command = 'xdg-open';
args = [url];
}
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
shell: false
});
child.unref();
} 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;
}
/**
* Build URL with query parameters (fixes path conflicts)
* @returns {{url: string}} - Local URL
*/
function buildUrl(host, port, type, filePath) {
const baseUrl = `http://${host}:${port}`;
let urlPath = '';
if (type === 'file') {
urlPath = `/view?file=${encodeURIComponent(filePath)}`;
} else if (type === 'directory') {
urlPath = `/browse?dir=${encodeURIComponent(filePath)}`;
}
const url = baseUrl + urlPath;
return { url };
}
/**
* 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 } = buildUrl(args.host, port, resolved.type, resolved.path);
const result = {
success: true,
url,
path: resolved.path,
port,
host: args.host,
mode: resolved.type
};
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 } = 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
};
console.log(JSON.stringify(result));
} else {
console.log(`\nMarkdown Novel Viewer`);
console.log(`${'─'.repeat(40)}`);
console.log(`URL: ${url}`);
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);
});
#!/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 os = require('os');
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}"`);
}
}
function fileRoute(basePath, imagePath) {
return `/file/${encodeURIComponent(path.resolve(basePath, imagePath))}`;
}
// 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');
assertIncludes(resolved, fileRoute('/base/path', './image.png'), 'Should include encoded 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');
assertIncludes(resolved, fileRoute('/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');
assertIncludes(resolved, fileRoute('/project', './images/logo.png'), 'Should resolve path with title');
});
test('resolveImages handles inline images with titles', () => {
const md = '';
const resolved = resolveImages(md, '/base');
assertIncludes(resolved, fileRoute('/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 = path.join(os.tmpdir(), '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 | Link |
|-------|------|--------|------|
| 1 | Test Phase | Pending | [phase-01-test.md](./phase-01-test.md) |
`);
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(path.join(os.tmpdir(), '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(path.join(os.tmpdir(), 'random.md'));
assertEqual(html, '', 'Should return empty string');
});
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!');
/**
* Tests for dashboard assets
* HTML template structure, CSS syntax, JS functions
*/
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const assetsDir = path.join(__dirname, '..', 'assets');
const templatePath = path.join(assetsDir, 'dashboard-template.html');
const cssPath = path.join(assetsDir, 'dashboard.css');
const jsPath = path.join(assetsDir, 'dashboard.js');
describe('dashboard-template.html', () => {
let htmlContent;
before(() => {
assert(fs.existsSync(templatePath), `Template file not found: ${templatePath}`);
htmlContent = fs.readFileSync(templatePath, 'utf8');
});
it('should be valid HTML5', () => {
assert(htmlContent.includes('<!DOCTYPE html'));
assert(htmlContent.includes('<html'));
assert(htmlContent.includes('</html>'));
});
it('should have proper head section', () => {
assert(htmlContent.includes('<head>'));
assert(htmlContent.includes('<meta charset="UTF-8">'));
assert(htmlContent.includes('<meta name="viewport"'));
assert(htmlContent.includes('</head>'));
});
it('should have title element', () => {
assert(htmlContent.includes('<title>'));
assert(htmlContent.includes('Plans Dashboard'));
});
it('should link required CSS files', () => {
assert(htmlContent.includes('novel-theme.css'));
assert(htmlContent.includes('dashboard.css'));
});
it('should have main content area', () => {
assert(htmlContent.includes('<main'));
assert(htmlContent.includes('role="main"'));
assert(htmlContent.includes('aria-label="Plans Dashboard"'));
});
it('should have dashboard header', () => {
assert(htmlContent.includes('class="dashboard-header"'));
assert(htmlContent.includes('<h1>Plans Dashboard</h1>'));
});
it('should have theme toggle button', () => {
assert(htmlContent.includes('id="theme-toggle"'));
assert(htmlContent.includes('aria-label="Toggle theme"'));
});
it('should have search input', () => {
assert(htmlContent.includes('id="plan-search"'));
assert(htmlContent.includes('type="search"'));
assert(htmlContent.includes('placeholder="Search plans..."'));
});
it('should have sort select', () => {
assert(htmlContent.includes('id="sort-select"'));
assert(htmlContent.includes('value="date-desc"'));
assert(htmlContent.includes('value="name-asc"'));
});
it('should have filter pills', () => {
assert(htmlContent.includes('class="filter-pills"'));
assert(htmlContent.includes('data-filter="all"'));
assert(htmlContent.includes('data-filter="completed"'));
assert(htmlContent.includes('data-filter="in-progress"'));
assert(htmlContent.includes('data-filter="pending"'));
});
it('should have plans grid section', () => {
assert(htmlContent.includes('class="plans-grid"'));
assert(htmlContent.includes('aria-label="Plans list"'));
});
it('should have template placeholders', () => {
assert(htmlContent.includes('{{plans-grid}}'));
assert(htmlContent.includes('{{plan-count}}'));
assert(htmlContent.includes('{{plans-json}}'));
assert(htmlContent.includes('{{empty-state}}'));
});
it('should have loading skeleton', () => {
assert(htmlContent.includes('class="loading-skeleton"'));
assert(htmlContent.includes('class="skeleton-card"'));
});
it('should have screen reader announcements', () => {
assert(htmlContent.includes('id="sr-announce"'));
assert(htmlContent.includes('aria-live="polite"'));
});
it('should embed plans JSON', () => {
assert(htmlContent.includes('window.__plans'));
});
it('should load dashboard.js', () => {
assert(htmlContent.includes('src="/assets/dashboard.js"'));
});
it('should have proper closing tags', () => {
const openMain = (htmlContent.match(/<main/g) || []).length;
const closeMain = (htmlContent.match(/<\/main>/g) || []).length;
assert.strictEqual(openMain, closeMain, 'Mismatched main tags');
const openBody = (htmlContent.match(/<body/g) || []).length;
const closeBody = (htmlContent.match(/<\/body>/g) || []).length;
assert.strictEqual(openBody, closeBody, 'Mismatched body tags');
});
it('should have data-theme attribute on html', () => {
assert(htmlContent.includes('data-theme='));
});
});
describe('dashboard.css', () => {
let cssContent;
before(() => {
assert(fs.existsSync(cssPath), `CSS file not found: ${cssPath}`);
cssContent = fs.readFileSync(cssPath, 'utf8');
});
it('should have valid CSS syntax', () => {
// Basic check: should have selectors and properties
assert(cssContent.includes('{'));
assert(cssContent.includes('}'));
});
it('should define dashboard-view class', () => {
assert(cssContent.includes('.dashboard-view'));
});
it('should define dashboard-header styles', () => {
assert(cssContent.includes('.dashboard-header'));
});
it('should define plan-card styles', () => {
assert(cssContent.includes('.plan-card'));
});
it('should define progress-ring styles', () => {
assert(cssContent.includes('.progress-ring'));
});
it('should define progress-bar styles', () => {
assert(cssContent.includes('.progress-bar'));
});
it('should define empty-state styles', () => {
assert(cssContent.includes('.empty-state'));
});
it('should have responsive media queries', () => {
assert(cssContent.includes('@media'));
});
it('should define animations', () => {
assert(cssContent.includes('@keyframes'));
});
it('should have accessibility classes', () => {
assert(cssContent.includes('.visually-hidden'));
});
it('should have focus styles', () => {
assert(cssContent.includes(':focus'));
assert(cssContent.includes(':focus-visible'));
});
it('should support reduced motion', () => {
assert(cssContent.includes('prefers-reduced-motion'));
});
it('should define color variables or hex values', () => {
// Check for color definitions
assert(cssContent.includes('var(--') || cssContent.includes('#') || cssContent.includes('rgb'));
});
it('should not have CSS syntax errors (basic check)', () => {
// Check for unclosed braces
const openBraces = (cssContent.match(/{/g) || []).length;
const closeBraces = (cssContent.match(/}/g) || []).length;
assert.strictEqual(openBraces, closeBraces, 'Unmatched CSS braces');
});
it('should define filter pills styling', () => {
assert(cssContent.includes('.filter-pill'));
});
it('should define search box styling', () => {
assert(cssContent.includes('.search-box'));
});
it('should define status count styling', () => {
assert(cssContent.includes('.status-count'));
});
});
describe('dashboard.js', () => {
let jsContent;
before(() => {
assert(fs.existsSync(jsPath), `JS file not found: ${jsPath}`);
jsContent = fs.readFileSync(jsPath, 'utf8');
});
it('should be valid JavaScript', () => {
// Check for syntax errors by looking for basic patterns
assert(jsContent.includes('function') || jsContent.includes('const') || jsContent.includes('let'));
});
it('should have IIFE pattern for encapsulation', () => {
assert(jsContent.includes('(function()'));
assert(jsContent.includes('})()'));
});
it('should initialize state object', () => {
assert(jsContent.includes('const state'));
assert(jsContent.includes('sort:'));
assert(jsContent.includes('filter:'));
assert(jsContent.includes('search:'));
});
it('should have init function', () => {
assert(jsContent.includes('function init()'));
});
it('should bind events', () => {
assert(jsContent.includes('function bindEvents()'));
});
it('should apply filters and sort', () => {
assert(jsContent.includes('function applyFiltersAndSort()'));
});
it('should render grid', () => {
assert(jsContent.includes('renderGrid'));
assert(jsContent.includes('.plans-grid'));
});
it('should parse URL parameters', () => {
assert(jsContent.includes('parseURL'));
assert(jsContent.includes('URLSearchParams'));
});
it('should update URL', () => {
assert(jsContent.includes('updateURL'));
assert(jsContent.includes('history.replaceState'));
});
it('should handle search input', () => {
assert(jsContent.includes('plan-search'));
assert(jsContent.includes('addEventListener'));
});
it('should handle sort select', () => {
assert(jsContent.includes('sort-select'));
assert(jsContent.includes('change'));
});
it('should handle filter pills', () => {
assert(jsContent.includes('.filter-pill'));
});
it('should handle card click navigation', () => {
assert(jsContent.includes('.plan-card'));
assert(jsContent.includes('.view-btn'));
});
it('should have keyboard navigation', () => {
assert(jsContent.includes('setupKeyboardNav'));
assert(jsContent.includes('ArrowRight') || jsContent.includes('ArrowDown'));
});
it('should have theme toggle setup', () => {
assert(jsContent.includes('setupThemeToggle'));
assert(jsContent.includes('theme-toggle'));
assert(jsContent.includes('localStorage'));
});
it('should announce to screen readers', () => {
assert(jsContent.includes('announce'));
assert(jsContent.includes('sr-announce'));
});
it('should use window.__plans data', () => {
assert(jsContent.includes('window.__plans'));
});
it('should initialize on DOM ready', () => {
assert(jsContent.includes('DOMContentLoaded'));
});
it('should have strict mode', () => {
assert(jsContent.includes("'use strict'"));
});
it('should check for required DOM elements', () => {
assert(jsContent.includes('document.querySelector'));
assert(jsContent.includes('.plans-grid'));
assert(jsContent.includes('.result-count'));
assert(jsContent.includes('.empty-state'));
});
it('should validate syntax with basic checks', () => {
// Check for unclosed strings
const singleQuotes = (jsContent.match(/'/g) || []).length;
const doubleQuotes = (jsContent.match(/"/g) || []).length;
// Both should be even (pairs)
assert.strictEqual(singleQuotes % 2, 0, 'Unmatched single quotes');
assert.strictEqual(doubleQuotes % 2, 0, 'Unmatched double quotes');
});
it('should have debounce for search input', () => {
assert(jsContent.includes('debounce'));
assert(jsContent.includes('setTimeout'));
});
it('should support sort options', () => {
assert(jsContent.includes('date-desc'));
assert(jsContent.includes('name-asc'));
assert(jsContent.includes('progress-desc'));
});
});
console.log('\n' + '='.repeat(60));
console.log('Dashboard Assets Tests');
console.log('='.repeat(60));
/**
* Tests for http-server.cjs
* Route testing, security validation, MIME types
*/
const assert = require('assert');
const {
createHttpServer,
getMimeType,
sendResponse,
sendError,
serveFile,
isPathSafe,
setAllowedDirs,
sanitizeErrorMessage,
MIME_TYPES
} = require('../scripts/lib/http-server.cjs');
const path = require('path');
describe('MIME_TYPES', () => {
it('should have common file types', () => {
assert.strictEqual(MIME_TYPES['.html'], 'text/html');
assert.strictEqual(MIME_TYPES['.css'], 'text/css');
assert.strictEqual(MIME_TYPES['.js'], 'application/javascript');
assert.strictEqual(MIME_TYPES['.json'], 'application/json');
});
it('should have image types', () => {
assert.strictEqual(MIME_TYPES['.png'], 'image/png');
assert.strictEqual(MIME_TYPES['.jpg'], 'image/jpeg');
assert.strictEqual(MIME_TYPES['.svg'], 'image/svg+xml');
});
});
describe('getMimeType', () => {
it('should return correct MIME type for HTML', () => {
assert.strictEqual(getMimeType('file.html'), 'text/html');
});
it('should return correct MIME type for CSS', () => {
assert.strictEqual(getMimeType('style.css'), 'text/css');
});
it('should return correct MIME type for JavaScript', () => {
assert.strictEqual(getMimeType('script.js'), 'application/javascript');
});
it('should handle uppercase extensions', () => {
assert.strictEqual(getMimeType('FILE.HTML'), 'text/html');
assert.strictEqual(getMimeType('style.CSS'), 'text/css');
});
it('should return octet-stream for unknown types', () => {
assert.strictEqual(getMimeType('file.xyz'), 'application/octet-stream');
});
it('should handle files without extensions', () => {
assert.strictEqual(getMimeType('README'), 'application/octet-stream');
});
});
describe('sanitizeErrorMessage', () => {
it('should remove absolute paths from error messages', () => {
const message = 'Error: /home/user/project/file.txt not found';
const sanitized = sanitizeErrorMessage(message);
assert(!sanitized.includes('/home/user'));
assert(sanitized.includes('[path]'));
});
it('should preserve non-path text', () => {
const message = 'Error: File not found';
const sanitized = sanitizeErrorMessage(message);
assert(sanitized.includes('Error'));
assert(sanitized.includes('File not found'));
});
it('should handle multiple paths', () => {
const message = 'Error comparing /path/one and /path/two';
const sanitized = sanitizeErrorMessage(message);
assert.strictEqual((sanitized.match(/\[path\]/g) || []).length, 2);
});
it('should not remove text after URL protocols', () => {
const message = 'Visit https://example.com for help';
const sanitized = sanitizeErrorMessage(message);
// Verify message is preserved after sanitization
assert(sanitized.length > 0, 'Message should not be empty');
assert(sanitized.includes('help'), 'Message text should be preserved');
});
});
describe('isPathSafe', () => {
it('should reject null byte injection', () => {
assert.strictEqual(isPathSafe('/var/www/file.txt\0.jpg'), false);
});
it('should allow normal paths with empty allowedDirs', () => {
// When allowedDirs is empty (during initialization), allow all
setAllowedDirs([]);
assert.strictEqual(isPathSafe('/var/www/file.txt'), true);
});
it('should reject paths outside allowed directories when set', () => {
setAllowedDirs(['/allowed/dir']);
assert.strictEqual(isPathSafe('/other/dir/file.txt'), false);
});
it('should allow paths inside allowed directories', () => {
const allowed = '/allowed/dir';
setAllowedDirs([allowed]);
const filePath = require('path').join(allowed, 'file.txt');
assert.strictEqual(isPathSafe(filePath), true);
});
it('should handle multiple allowed directories', () => {
const dir1 = '/dir1';
const dir2 = '/dir2';
setAllowedDirs([dir1, dir2]);
// Paths must be absolute and within allowed dirs
const path1 = require('path').join(dir1, 'file.txt');
const path2 = require('path').join(dir2, 'file.txt');
assert.strictEqual(isPathSafe(path1), true);
assert.strictEqual(isPathSafe(path2), true);
});
it('should allow empty allowedDirs during initialization', () => {
setAllowedDirs([]);
assert.strictEqual(isPathSafe('/any/path.txt'), true);
});
});
describe('setAllowedDirs', () => {
it('should set allowed directories', () => {
const dirs = ['/home/user', '/tmp'];
setAllowedDirs(dirs);
// Verify by testing path safety
assert.strictEqual(isPathSafe('/home/user/file.txt'), true);
});
it('should resolve relative paths to absolute', () => {
setAllowedDirs(['./relative']);
// Should be resolved to absolute path
assert(isPathSafe(path.resolve('./relative/file.txt')));
});
});
describe('createHttpServer', () => {
it('should create an HTTP server', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
assert(server);
assert(typeof server.listen === 'function');
server.close();
});
it('should require assetsDir', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
assert(server);
server.close();
});
it('should accept plansDir option', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: '/plans'
});
assert(server);
server.close();
});
it('should accept allowedDirs option', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname, '/tmp']
});
assert(server);
server.close();
});
});
describe('Route: /assets/*', () => {
it('should prevent directory traversal in assets path', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
// Route validation happens internally - can't test HTTP response without full setup
server.close();
});
it('should validate asset paths for ../', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
// Security check happens in route handler
server.close();
});
});
describe('Route: /dashboard', () => {
it('should accept plansDir parameter', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: __dirname
});
server.close();
});
it('should validate custom directory parameter', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
server.close();
});
});
describe('Route: /api/dashboard', () => {
it('should return JSON response', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
plansDir: __dirname
});
server.close();
});
it('should handle missing plansDir gracefully', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
server.close();
});
});
describe('Route: /file/*', () => {
it('should validate file path safety', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>',
allowedDirs: [__dirname]
});
server.close();
});
});
describe('Route: /api/files', () => {
it('should be disabled for security', () => {
const server = createHttpServer({
assetsDir: __dirname,
renderMarkdown: (fp) => '<html></html>'
});
server.close();
});
});
console.log('\n' + '='.repeat(60));
console.log('HTTP Server Tests');
console.log('='.repeat(60));