init
This commit is contained in:
319
.opencode/skills/markdown-novel-viewer/SKILL.md
Normal file
319
.opencode/skills/markdown-novel-viewer/SKILL.md
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
name: ck:markdown-novel-viewer
|
||||
description: View markdown files with calm, book-like reading experience via HTTP server. Use for long-form content, documentation preview, novel reading, report viewing, distraction-free reading.
|
||||
argument-hint: "[file-or-directory]"
|
||||
metadata:
|
||||
author: claudekit
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# markdown-novel-viewer
|
||||
|
||||
Background HTTP server rendering markdown files with calm, book-like reading experience.
|
||||
|
||||
**Note:** HTML generation mode (`/ck:preview --html ...`) produces self-contained HTML files that open directly in the browser — they do not use this server.
|
||||
|
||||
## ⚠️ Installation Required
|
||||
|
||||
**This skill requires npm dependencies.** Run one of the following:
|
||||
|
||||
```bash
|
||||
# Option 1: Install via ClaudeKit CLI (recommended)
|
||||
ck init # Runs install.sh which handles all skills
|
||||
|
||||
# Option 2: Manual installation
|
||||
cd .opencode/skills/markdown-novel-viewer
|
||||
npm install
|
||||
```
|
||||
|
||||
**Dependencies:** `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
|
||||
|
||||
```bash
|
||||
# View a markdown file
|
||||
node .opencode/skills/markdown-novel-viewer/scripts/server.cjs \
|
||||
--file ./plans/my-plan/plan.md \
|
||||
--open
|
||||
|
||||
# Browse a directory
|
||||
node .opencode/skills/markdown-novel-viewer/scripts/server.cjs \
|
||||
--dir ./plans \
|
||||
--host 0.0.0.0 \
|
||||
--open
|
||||
|
||||
# Background mode
|
||||
node .opencode/skills/markdown-novel-viewer/scripts/server.cjs \
|
||||
--file ./README.md \
|
||||
--background
|
||||
|
||||
# Stop all running servers
|
||||
node .opencode/skills/markdown-novel-viewer/scripts/server.cjs --stop
|
||||
```
|
||||
|
||||
## Skill Invocation
|
||||
|
||||
Use `/ck:preview` for quick access:
|
||||
|
||||
```bash
|
||||
/ck:preview plans/my-plan/plan.md # View markdown file
|
||||
/ck:preview plans/ # Browse directory
|
||||
/ck:preview --stop # Stop server
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 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
|
||||
|
||||
### Mermaid.js Diagrams
|
||||
- Auto-renders `mermaid` code blocks as diagrams
|
||||
- Theme-aware (light/dark mode support)
|
||||
- Full-width toggle: Click diagram to expand/collapse
|
||||
- Error display with source preview for debugging
|
||||
|
||||
### 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
|
||||
|
||||
### Focused Reader Mode
|
||||
- **Auto-hide header**: Header hides on scroll down, shows on scroll up
|
||||
- **Progress bar**: Always-visible horizontal progress bar tracks reading position
|
||||
- **Distraction-free**: Minimal UI that gets out of the way while reading
|
||||
- **Smooth transitions**: Gentle animations for header show/hide
|
||||
|
||||
### Plan Navigation
|
||||
- Auto-detects plan directory structure
|
||||
- Accordion sidebar with status badges (✓ complete, ⏳ in progress)
|
||||
- Previous/Next navigation buttons
|
||||
- Auto-hide header with progress bar on scroll
|
||||
- Mobile FAB (floating action button) for navigation
|
||||
- Bottom sheet sidebar for mobile devices
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
**First-time toast**: Shows "Press ? for keyboard shortcuts" on first visit (auto-dismisses after 5s)
|
||||
|
||||
**Available shortcuts:**
|
||||
- `?` - Show keyboard shortcuts cheatsheet (full-screen overlay)
|
||||
- `T` - Toggle theme (light/dark)
|
||||
- `S` - Toggle sidebar (desktop)
|
||||
- `←` / `→` - Navigate previous/next phase
|
||||
- `Esc` - Close sidebar (mobile) or cheatsheet modal
|
||||
|
||||
**Cheatsheet modal**: Press `?` to see all shortcuts in a full-screen overlay with backdrop blur. Close with `Esc`, `×` button, or backdrop click.
|
||||
|
||||
### Mobile Optimization
|
||||
- **FAB (Floating Action Button)**: Fixed bottom-right button for navigation on mobile
|
||||
- **Bottom sheet**: Slide-up sidebar with touch gestures
|
||||
- **Touch-friendly**: Larger tap targets, swipe gestures
|
||||
- **Responsive breakpoint**: Switches at 768px viewport width
|
||||
|
||||
## 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 (`0.0.0.0` for remote) | 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
|
||||
├── reader.js # Client-side interactivity
|
||||
├── novel-theme.css # Main theme file (imports modules)
|
||||
├── directory-browser.css # Directory browser styles
|
||||
└── styles/ # Modular CSS architecture
|
||||
├── novel-theme-base.css # Base colors, fonts, reset
|
||||
├── novel-theme-typography.css # Headings, paragraphs, lists
|
||||
├── novel-theme-code.css # Code blocks, syntax highlighting
|
||||
├── novel-theme-tables.css # Table styling
|
||||
├── novel-theme-links.css # Link states, hover effects
|
||||
├── novel-theme-layout.css # Grid, spacing, containers
|
||||
├── novel-theme-header.css # Auto-hide header, progress bar
|
||||
├── novel-theme-sidebar.css # Accordion sidebar, status badges
|
||||
└── novel-theme-overlays.css # Toast, cheatsheet modal
|
||||
```
|
||||
|
||||
## HTTP 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 via `npm install`)
|
||||
|
||||
## Customization
|
||||
|
||||
### Theme Colors (CSS Variables)
|
||||
|
||||
Light mode variables in `assets/novel-theme.css`:
|
||||
```css
|
||||
--bg-primary: #faf8f3; /* Warm cream */
|
||||
--accent: #8b4513; /* Saddle brown */
|
||||
```
|
||||
|
||||
Dark mode:
|
||||
```css
|
||||
--bg-primary: #1a1a1a; /* Near black */
|
||||
--accent: #d4a574; /* Warm gold */
|
||||
```
|
||||
|
||||
### Content Width
|
||||
```css
|
||||
--content-width: 720px;
|
||||
```
|
||||
|
||||
## Remote Access
|
||||
|
||||
To access from another device on your network:
|
||||
|
||||
```bash
|
||||
# Start with 0.0.0.0 to bind to all interfaces
|
||||
node .opencode/skills/markdown-novel-viewer/scripts/server.cjs --file ./README.md --host 0.0.0.0 --port 3456
|
||||
```
|
||||
|
||||
When using `--host 0.0.0.0`, the server auto-detects your local network IP and includes it in the output:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"url": "http://localhost:3456/view?file=...",
|
||||
"networkUrl": "http://192.168.2.75:3456/view?file=...",
|
||||
"port": 3456
|
||||
}
|
||||
```
|
||||
|
||||
Use `networkUrl` to access from other devices on the same network.
|
||||
|
||||
## 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 `/tmp/md-novel-viewer-*.pid` for stale PID files
|
||||
|
||||
**Remote access denied**: Use `--host 0.0.0.0` to bind to all interfaces
|
||||
|
||||
## Mermaid.js Diagrams
|
||||
|
||||
### Usage
|
||||
|
||||
Use fenced code blocks with `mermaid` language:
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
pie title Traffic Sources
|
||||
"Organic" : 45
|
||||
"Direct" : 30
|
||||
"Referral" : 25
|
||||
```
|
||||
````
|
||||
|
||||
### Supported Diagram Types
|
||||
|
||||
| Type | Syntax | Use Case |
|
||||
|------|--------|----------|
|
||||
| Flowchart | `flowchart LR/TB/TD` | Process flows, decision trees |
|
||||
| Sequence | `sequenceDiagram` | API interactions, message flows |
|
||||
| Pie | `pie title "..."` | Distribution data |
|
||||
| Gantt | `gantt` | Project timelines |
|
||||
| XY Chart | `xychart-beta` | Bar/line charts |
|
||||
| Mindmap | `mindmap` | Idea hierarchies |
|
||||
| Quadrant | `quadrantChart` | 2x2 matrices |
|
||||
|
||||
### Validating Mermaid Snippets
|
||||
|
||||
**Quick validation**: Use the [Mermaid Live Editor](https://mermaid.live) to test syntax.
|
||||
|
||||
**Common errors and fixes**:
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `Parse error` | Invalid syntax | Check diagram type declaration |
|
||||
| `Unknown diagram type` | Typo in declaration | Use exact type: `flowchart`, not `flow` |
|
||||
| `Expecting token` | Missing quotes/brackets | Ensure balanced delimiters |
|
||||
| `UnknownDiagramError` | Empty or malformed block | Add valid diagram content |
|
||||
|
||||
### Fixing Common Issues
|
||||
|
||||
**1. Flowchart arrows**
|
||||
```mermaid
|
||||
%% Wrong: A -> B
|
||||
%% Correct:
|
||||
flowchart LR
|
||||
A --> B
|
||||
```
|
||||
|
||||
**2. Pie chart values**
|
||||
```mermaid
|
||||
%% Wrong: "Label": 50%
|
||||
%% Correct:
|
||||
pie title Sales
|
||||
"Product A" : 50
|
||||
"Product B" : 30
|
||||
```
|
||||
|
||||
**3. XY Chart data format**
|
||||
```mermaid
|
||||
xychart-beta
|
||||
title "Monthly Sales"
|
||||
x-axis [Jan, Feb, Mar]
|
||||
y-axis "Revenue" 0 --> 100
|
||||
bar [30, 45, 60]
|
||||
```
|
||||
|
||||
**4. Sequence diagram participants**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Client
|
||||
participant B as Server
|
||||
A->>B: Request
|
||||
B-->>A: Response
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
When a diagram fails to render, the viewer shows:
|
||||
- Error message
|
||||
- Expandable source code preview
|
||||
- Line number where parsing failed (when available)
|
||||
|
||||
Fix the syntax and refresh the page to re-render.
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
BIN
.opencode/skills/markdown-novel-viewer/assets/favicon.png
Normal file
BIN
.opencode/skills/markdown-novel-viewer/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Novel Theme - Main Entry Point
|
||||
* Warm, book-like reading experience with dark/light modes
|
||||
*
|
||||
* Imports all module stylesheets in correct cascade order.
|
||||
*/
|
||||
|
||||
@import url('./styles/novel-theme-variables.css');
|
||||
@import url('./styles/novel-theme-base.css');
|
||||
@import url('./styles/novel-theme-header.css');
|
||||
@import url('./styles/novel-theme-sidebar.css');
|
||||
@import url('./styles/novel-theme-content.css');
|
||||
@import url('./styles/novel-theme-components.css');
|
||||
@import url('./styles/novel-theme-mermaid.css');
|
||||
@import url('./styles/novel-theme-overlays.css');
|
||||
@import url('./styles/novel-theme-responsive.css');
|
||||
850
.opencode/skills/markdown-novel-viewer/assets/reader.js
Normal file
850
.opencode/skills/markdown-novel-viewer/assets/reader.js
Normal file
@@ -0,0 +1,850 @@
|
||||
/**
|
||||
* 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');
|
||||
const header = document.querySelector('.reader-header');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
const progressFill = progressBar?.querySelector('.progress-bar-fill');
|
||||
const shortcutsToast = document.getElementById('shortcuts-toast');
|
||||
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
||||
|
||||
// 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';
|
||||
const TOAST_SHOWN_KEY = 'reader:shortcuts-toast-shown';
|
||||
|
||||
// Scroll state
|
||||
let lastScrollY = 0;
|
||||
let scrollTicking = false;
|
||||
|
||||
// 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, skipMermaid = false) {
|
||||
html.dataset.theme = theme;
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
|
||||
// Switch highlight.js theme
|
||||
if (hljsLight && hljsDark) {
|
||||
hljsLight.disabled = theme === 'dark';
|
||||
hljsDark.disabled = theme === 'light';
|
||||
}
|
||||
|
||||
// Re-render mermaid diagrams with new theme (skip on initial load)
|
||||
if (!skipMermaid && window.mermaidModule) {
|
||||
updateMermaidTheme();
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Show shortcuts toast (first visit)
|
||||
function showToast() {
|
||||
if (!shortcutsToast) return;
|
||||
|
||||
const hasShown = localStorage.getItem(TOAST_SHOWN_KEY);
|
||||
if (hasShown) return;
|
||||
|
||||
// Show toast after short delay
|
||||
setTimeout(() => {
|
||||
shortcutsToast.classList.add('show');
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
setTimeout(() => {
|
||||
dismissToast();
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Dismiss toast
|
||||
function dismissToast() {
|
||||
if (!shortcutsToast) return;
|
||||
shortcutsToast.classList.remove('show');
|
||||
localStorage.setItem(TOAST_SHOWN_KEY, 'true');
|
||||
}
|
||||
|
||||
// Show shortcuts cheatsheet
|
||||
function showCheatsheet() {
|
||||
if (!shortcutsOverlay) return;
|
||||
shortcutsOverlay.removeAttribute('hidden');
|
||||
shortcutsOverlay.setAttribute('aria-hidden', 'false');
|
||||
// Focus trap
|
||||
const closeBtn = shortcutsOverlay.querySelector('.modal-close');
|
||||
closeBtn?.focus();
|
||||
}
|
||||
|
||||
// Hide shortcuts cheatsheet
|
||||
function hideCheatsheet() {
|
||||
if (!shortcutsOverlay) return;
|
||||
shortcutsOverlay.setAttribute('hidden', '');
|
||||
shortcutsOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
// Initialize shortcuts
|
||||
function initShortcuts() {
|
||||
// Toast dismiss button
|
||||
const dismissBtn = shortcutsToast?.querySelector('.toast-dismiss');
|
||||
dismissBtn?.addEventListener('click', dismissToast);
|
||||
|
||||
// Cheatsheet close button
|
||||
const closeBtn = shortcutsOverlay?.querySelector('.modal-close');
|
||||
closeBtn?.addEventListener('click', hideCheatsheet);
|
||||
|
||||
// Backdrop click
|
||||
const backdrop = shortcutsOverlay?.querySelector('.shortcuts-backdrop');
|
||||
backdrop?.addEventListener('click', hideCheatsheet);
|
||||
|
||||
// Show toast on first visit
|
||||
showToast();
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function handleKeydown(e) {
|
||||
// Skip if in input/textarea
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close cheatsheet on Escape
|
||||
if (e.key === 'Escape' && shortcutsOverlay && !shortcutsOverlay.hasAttribute('hidden')) {
|
||||
e.preventDefault();
|
||||
hideCheatsheet();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close bottom sheet on Escape
|
||||
const bottomSheet = document.getElementById('bottom-sheet');
|
||||
if (e.key === 'Escape' && bottomSheet && bottomSheet.getAttribute('aria-hidden') === 'false') {
|
||||
e.preventDefault();
|
||||
if (window.closeBottomSheet) {
|
||||
window.closeBottomSheet();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cheatsheet on ?
|
||||
if (e.key === '?' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
showCheatsheet();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle utility
|
||||
function throttle(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update progress bar based on scroll position
|
||||
function updateProgressBar() {
|
||||
if (!progressFill) return;
|
||||
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
|
||||
progressFill.style.width = `${Math.min(progress, 100)}%`;
|
||||
}
|
||||
|
||||
// Handle scroll for progress bar and fixed header shadow
|
||||
function handleScroll() {
|
||||
if (!header) return;
|
||||
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
// Add fixed shadow when scrolled past header
|
||||
if (currentScrollY > 60) {
|
||||
header.classList.add('is-fixed');
|
||||
} else {
|
||||
header.classList.remove('is-fixed');
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
|
||||
// Update progress bar using requestAnimationFrame
|
||||
if (!scrollTicking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
updateProgressBar();
|
||||
scrollTicking = false;
|
||||
});
|
||||
scrollTicking = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Mermaid diagrams
|
||||
async function initMermaid() {
|
||||
// Wait for mermaid module to load (imported in template.html)
|
||||
let attempts = 0;
|
||||
while (!window.mermaidModule && attempts < 50) {
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!window.mermaidModule) {
|
||||
console.warn('Mermaid module not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
const mermaid = window.mermaidModule;
|
||||
const isDark = html.dataset.theme === 'dark';
|
||||
|
||||
// Initialize mermaid with theme
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
});
|
||||
|
||||
// Find unprocessed mermaid elements (both pre and div)
|
||||
const diagrams = document.querySelectorAll('.mermaid:not([data-processed="true"])');
|
||||
|
||||
if (diagrams.length === 0) {
|
||||
return; // Nothing to render
|
||||
}
|
||||
|
||||
// Store original source before mermaid replaces content (for theme switching)
|
||||
diagrams.forEach(el => {
|
||||
if (!el.dataset.mermaidSource) {
|
||||
el.dataset.mermaidSource = el.textContent;
|
||||
}
|
||||
});
|
||||
|
||||
// Use mermaid.run() - the preferred API for v10+
|
||||
try {
|
||||
await mermaid.run({
|
||||
nodes: diagrams,
|
||||
suppressErrors: false
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Mermaid run error:', err);
|
||||
// Show errors inline for diagrams that failed
|
||||
diagrams.forEach(el => {
|
||||
if (!el.querySelector('svg') && !el.hasAttribute('data-processed')) {
|
||||
const code = el.dataset.mermaidSource || el.textContent;
|
||||
el.innerHTML = `<div class="mermaid-error">
|
||||
<strong>Mermaid Error:</strong>
|
||||
<pre>${err.message || err}</pre>
|
||||
<details><summary>Source</summary><pre>${code}</pre></details>
|
||||
</div>`;
|
||||
el.classList.add('mermaid-error-container');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render mermaid on theme change
|
||||
async function updateMermaidTheme() {
|
||||
if (!window.mermaidModule) return;
|
||||
|
||||
const isDark = html.dataset.theme === 'dark';
|
||||
|
||||
// Re-initialize with new theme
|
||||
window.mermaidModule.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'Inter, sans-serif'
|
||||
});
|
||||
|
||||
// Restore original source and re-render
|
||||
const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]');
|
||||
diagrams.forEach(el => {
|
||||
const source = el.dataset.mermaidSource;
|
||||
if (source) {
|
||||
el.textContent = source;
|
||||
el.removeAttribute('data-processed');
|
||||
}
|
||||
});
|
||||
|
||||
await initMermaid();
|
||||
// Re-init expand buttons after re-render
|
||||
setTimeout(() => initMermaidExpand(), 100);
|
||||
}
|
||||
|
||||
// Compute width and left offset for an expanded wrapper to fill .main-content
|
||||
// without using viewport units (immune to sidebar state changes)
|
||||
function applyExpandLayout(wrapper, isExpanded) {
|
||||
if (!isExpanded) {
|
||||
wrapper.style.width = '';
|
||||
wrapper.style.left = '';
|
||||
return;
|
||||
}
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
if (!mainContent) return;
|
||||
const mainRect = mainContent.getBoundingClientRect();
|
||||
const mainPadding = parseFloat(getComputedStyle(mainContent).paddingLeft) || 0;
|
||||
const availableWidth = mainRect.width - mainPadding * 2;
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const offset = mainRect.left + mainPadding - wrapperRect.left;
|
||||
wrapper.style.width = availableWidth + 'px';
|
||||
wrapper.style.left = offset + 'px';
|
||||
}
|
||||
|
||||
// Recalculate expanded wrappers on resize or sidebar toggle
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('.mermaid-wrapper.expanded, .code-wrapper.expanded')
|
||||
.forEach(w => applyExpandLayout(w, true));
|
||||
});
|
||||
|
||||
// Initialize Mermaid expand toggle buttons
|
||||
function initMermaidExpand() {
|
||||
// Find all rendered mermaid diagrams not already wrapped
|
||||
const diagrams = document.querySelectorAll('.mermaid[data-processed="true"]');
|
||||
|
||||
diagrams.forEach(diagram => {
|
||||
// Skip if already wrapped
|
||||
if (diagram.parentElement?.classList.contains('mermaid-wrapper')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-wrapper';
|
||||
|
||||
// Create expand button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'mermaid-expand-btn';
|
||||
btn.setAttribute('aria-label', 'Expand diagram to full width');
|
||||
btn.innerHTML = `
|
||||
<span class="icon-expand">⤢</span>
|
||||
<span class="icon-collapse">⤡</span>
|
||||
`;
|
||||
|
||||
// Toggle handler — expand to fill .main-content, re-render at new width
|
||||
btn.addEventListener('click', async () => {
|
||||
const isExpanded = wrapper.classList.toggle('expanded');
|
||||
btn.setAttribute('aria-label', isExpanded
|
||||
? 'Collapse diagram'
|
||||
: 'Expand diagram to full width'
|
||||
);
|
||||
applyExpandLayout(wrapper, isExpanded);
|
||||
|
||||
// Re-render mermaid at the new container width for crisp output
|
||||
const source = diagram.dataset.mermaidSource;
|
||||
if (source && window.mermaidModule) {
|
||||
diagram.textContent = source;
|
||||
diagram.removeAttribute('data-processed');
|
||||
try {
|
||||
await window.mermaidModule.run({ nodes: [diagram], suppressErrors: false });
|
||||
} catch (e) {
|
||||
console.error('Mermaid re-render on expand failed:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Insert wrapper before diagram
|
||||
diagram.parentNode.insertBefore(wrapper, diagram);
|
||||
|
||||
// Move diagram into wrapper
|
||||
wrapper.appendChild(diagram);
|
||||
|
||||
// Add button to wrapper
|
||||
wrapper.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize code block expand toggle buttons (for wide ASCII art)
|
||||
function initCodeExpand() {
|
||||
// Find all pre elements that are not mermaid and not already wrapped
|
||||
const codeBlocks = document.querySelectorAll('pre:not(.mermaid)');
|
||||
|
||||
codeBlocks.forEach(pre => {
|
||||
// Skip if already wrapped or inside mermaid error
|
||||
if (pre.parentElement?.classList.contains('code-wrapper') ||
|
||||
pre.parentElement?.classList.contains('mermaid-error')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add expand button if content is wider than container
|
||||
// Check if scrollWidth > clientWidth (has horizontal overflow)
|
||||
if (pre.scrollWidth <= pre.clientWidth + 10) {
|
||||
return; // No overflow, no need for expand button
|
||||
}
|
||||
|
||||
// Create wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'code-wrapper';
|
||||
|
||||
// Create expand button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'code-expand-btn';
|
||||
btn.setAttribute('aria-label', 'Expand code block to full width');
|
||||
btn.innerHTML = `
|
||||
<span class="icon-expand">⤢</span>
|
||||
<span class="icon-collapse">⤡</span>
|
||||
`;
|
||||
|
||||
// Toggle handler — expand to fill .main-content
|
||||
btn.addEventListener('click', () => {
|
||||
const isExpanded = wrapper.classList.toggle('expanded');
|
||||
btn.setAttribute('aria-label', isExpanded
|
||||
? 'Collapse code block'
|
||||
: 'Expand code block to full width'
|
||||
);
|
||||
applyExpandLayout(wrapper, isExpanded);
|
||||
});
|
||||
|
||||
// Insert wrapper before pre
|
||||
pre.parentNode.insertBefore(wrapper, pre);
|
||||
|
||||
// Move pre into wrapper
|
||||
wrapper.appendChild(pre);
|
||||
|
||||
// Add button to wrapper
|
||||
wrapper.appendChild(btn);
|
||||
|
||||
// Auto-expand overflowing code blocks to fill available width
|
||||
// (user can collapse back via the button)
|
||||
wrapper.classList.add('expanded');
|
||||
btn.setAttribute('aria-label', 'Collapse code block');
|
||||
applyExpandLayout(wrapper, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
|
||||
// Initialize accordion
|
||||
function initAccordion() {
|
||||
const phaseHeaders = document.querySelectorAll('.phase-header');
|
||||
if (phaseHeaders.length === 0) return;
|
||||
|
||||
// Get plan identifier for localStorage key
|
||||
const planNav = document.getElementById('plan-nav');
|
||||
if (!planNav) return;
|
||||
const planName = planNav.querySelector('.plan-title span:last-child')?.textContent || 'unknown';
|
||||
const planId = planName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
|
||||
phaseHeaders.forEach(header => {
|
||||
const phaseGroup = header.closest('.phase-group');
|
||||
if (!phaseGroup) return;
|
||||
|
||||
const phaseId = phaseGroup.dataset.phaseId;
|
||||
const storageKey = `reader:accordion:${planId}:${phaseId}`;
|
||||
|
||||
// Restore collapsed state from localStorage
|
||||
try {
|
||||
const collapsed = localStorage.getItem(storageKey);
|
||||
if (collapsed === 'true') {
|
||||
phaseGroup.classList.add('collapsed');
|
||||
}
|
||||
} catch (e) {
|
||||
// Graceful fallback if localStorage unavailable
|
||||
}
|
||||
|
||||
// Toggle handler
|
||||
const toggleAccordion = () => {
|
||||
const isCollapsed = phaseGroup.classList.toggle('collapsed');
|
||||
|
||||
// Persist state
|
||||
try {
|
||||
localStorage.setItem(storageKey, isCollapsed);
|
||||
} catch (e) {
|
||||
// Graceful fallback
|
||||
}
|
||||
};
|
||||
|
||||
// Click handler
|
||||
header.addEventListener('click', toggleAccordion);
|
||||
|
||||
// Keyboard accessibility
|
||||
header.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleAccordion();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize mobile navigation (FAB + bottom sheet)
|
||||
function initMobileNav() {
|
||||
const fabMenu = document.getElementById('fab-menu');
|
||||
const fabNext = document.querySelector('.nav-next-mobile');
|
||||
const fabPrev = document.querySelector('.nav-prev-mobile');
|
||||
const bottomSheet = document.getElementById('bottom-sheet');
|
||||
const bottomSheetContent = document.getElementById('bottom-sheet-content');
|
||||
const backdrop = bottomSheet?.querySelector('.bottom-sheet-backdrop');
|
||||
const handle = bottomSheet?.querySelector('.bottom-sheet-handle');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
if (!fabMenu || !bottomSheet) return;
|
||||
|
||||
// Set FAB hrefs from footer navigation
|
||||
const navNext = document.querySelector('.nav-next');
|
||||
const navPrev = document.querySelector('.nav-prev');
|
||||
if (navNext && fabNext) {
|
||||
fabNext.href = navNext.href;
|
||||
}
|
||||
if (navPrev && fabPrev) {
|
||||
fabPrev.href = navPrev.href;
|
||||
}
|
||||
|
||||
// Clone sidebar content to bottom sheet
|
||||
if (sidebar && bottomSheetContent) {
|
||||
bottomSheetContent.innerHTML = sidebar.innerHTML;
|
||||
}
|
||||
|
||||
// Open bottom sheet
|
||||
function openBottomSheet() {
|
||||
bottomSheet.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
// Close bottom sheet
|
||||
function closeBottomSheet() {
|
||||
bottomSheet.setAttribute('aria-hidden', 'true');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// FAB menu click handler
|
||||
fabMenu.addEventListener('click', openBottomSheet);
|
||||
|
||||
// Backdrop click handler
|
||||
backdrop?.addEventListener('click', closeBottomSheet);
|
||||
|
||||
// Swipe down gesture on handle
|
||||
let touchStartY = 0;
|
||||
let touchEndY = 0;
|
||||
|
||||
handle?.addEventListener('touchstart', (e) => {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
handle?.addEventListener('touchmove', (e) => {
|
||||
touchEndY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
handle?.addEventListener('touchend', () => {
|
||||
const swipeDistance = touchEndY - touchStartY;
|
||||
// Swipe down threshold: 50px
|
||||
if (swipeDistance > 50) {
|
||||
closeBottomSheet();
|
||||
}
|
||||
touchStartY = 0;
|
||||
touchEndY = 0;
|
||||
});
|
||||
|
||||
// Close on resize to desktop
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (window.innerWidth > 768 && bottomSheet.getAttribute('aria-hidden') === 'false') {
|
||||
closeBottomSheet();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Store close function for keyboard handler
|
||||
window.closeBottomSheet = closeBottomSheet;
|
||||
}
|
||||
|
||||
// Sidebar resize functionality
|
||||
const SIDEBAR_WIDTH_KEY = 'novel-viewer-sidebar-width';
|
||||
const resizeHandle = document.getElementById('sidebar-resize');
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
|
||||
function initSidebarResize() {
|
||||
if (!resizeHandle || !sidebar) return;
|
||||
|
||||
// Restore saved width
|
||||
const savedWidth = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||
if (savedWidth) {
|
||||
const width = parseInt(savedWidth, 10);
|
||||
if (width >= 200 && width <= 480) {
|
||||
sidebar.style.width = width + 'px';
|
||||
if (mainContent) mainContent.style.marginLeft = width + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
|
||||
resizeHandle.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startWidth = sidebar.offsetWidth;
|
||||
resizeHandle.classList.add('dragging');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
const delta = e.clientX - startX;
|
||||
const newWidth = Math.min(480, Math.max(200, startWidth + delta));
|
||||
sidebar.style.width = newWidth + 'px';
|
||||
if (mainContent) mainContent.style.marginLeft = newWidth + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
resizeHandle.classList.remove('dragging');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebar.offsetWidth.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
initTheme();
|
||||
initFontSize();
|
||||
initSidebar();
|
||||
initSidebarResize();
|
||||
initAccordion();
|
||||
initShortcuts();
|
||||
initMobileNav();
|
||||
initMermaid().then(() => {
|
||||
// Initialize expand buttons after mermaid renders
|
||||
setTimeout(() => initMermaidExpand(), 100);
|
||||
});
|
||||
// Initialize code block expand buttons after fonts load
|
||||
// (font metrics affect scrollWidth used for overflow detection)
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(() => initCodeExpand());
|
||||
} else {
|
||||
// Fallback for browsers without Font Loading API
|
||||
setTimeout(() => initCodeExpand(), 300);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Initialize scroll state and handlers
|
||||
lastScrollY = window.scrollY;
|
||||
updateProgressBar();
|
||||
window.addEventListener('scroll', throttle(handleScroll, 100), { passive: true });
|
||||
|
||||
// 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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Novel Theme - Base Styles
|
||||
* Reset, html, body fundamentals
|
||||
*/
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for entire page */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) var(--bg-secondary);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Novel Theme - Component Styles
|
||||
* Code blocks, blockquotes, tables, images, horizontal rules
|
||||
*/
|
||||
|
||||
/* 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);
|
||||
/* Use fit-content so wide ASCII art expands beyond .content container */
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - var(--sidebar-width) - 6rem);
|
||||
/* Center the expanded code block relative to .content */
|
||||
position: relative;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
/* Ensure ASCII art and wide code is scrollable, not clipped */
|
||||
white-space: pre;
|
||||
/* Always show scrollbar when content overflows */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar for code blocks */
|
||||
pre::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-track {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
/* Preserve whitespace for ASCII art */
|
||||
white-space: pre;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Code block expand toggle (similar to mermaid) */
|
||||
.code-wrapper {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
transition: width 0.2s ease, left 0.2s ease;
|
||||
}
|
||||
|
||||
.code-wrapper pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-expand-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.code-wrapper:hover .code-expand-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.code-expand-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Always show button when expanded */
|
||||
.code-wrapper.expanded .code-expand-btn {
|
||||
opacity: 1;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.code-wrapper.expanded .code-expand-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Expanded state for code blocks — width and offset set dynamically by JS (initCodeExpand)
|
||||
to fill .main-content without affecting sibling content or using viewport units */
|
||||
.code-wrapper.expanded {
|
||||
max-width: none;
|
||||
position: relative;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.code-wrapper.expanded pre {
|
||||
max-width: none;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Icon visibility toggle for code blocks */
|
||||
.code-expand-btn .icon-expand { display: inline; }
|
||||
.code-expand-btn .icon-collapse { display: none; }
|
||||
|
||||
.code-wrapper.expanded .code-expand-btn .icon-expand { display: none; }
|
||||
.code-wrapper.expanded .code-expand-btn .icon-collapse { display: inline; }
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Novel Theme - Content Styles
|
||||
* Main content area, typography, navigation footer
|
||||
*/
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
transition: margin-left 0.15s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for main content */
|
||||
.main-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.main-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.main-content::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.main-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
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;
|
||||
/* Allow code blocks to visually overflow beyond content width */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Novel Theme - Header Styles
|
||||
* Header, controls, navigation, theme toggle
|
||||
*/
|
||||
|
||||
/* Header */
|
||||
.reader-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
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: transform 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.reader-header.is-hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.reader-header.is-fixed {
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar-container {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
z-index: 99;
|
||||
transition: top 0.3s ease;
|
||||
}
|
||||
|
||||
.reader-header.is-hidden ~ .progress-bar-container {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||||
transition: width 0.1s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Novel Theme - Mermaid Diagram Styles
|
||||
* Mermaid diagrams, error containers
|
||||
*/
|
||||
|
||||
/* Mermaid Diagrams */
|
||||
pre.mermaid,
|
||||
.mermaid {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
overflow-x: auto;
|
||||
/* Reset pre defaults for mermaid */
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
pre.mermaid[data-processed="true"],
|
||||
.mermaid-rendered {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure rendered SVGs scale within their container */
|
||||
.mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mermaid-error-container {
|
||||
background: #fff5f5;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-error-container {
|
||||
background: #3a2020;
|
||||
border-color: #6a3030;
|
||||
}
|
||||
|
||||
.mermaid-error {
|
||||
text-align: left;
|
||||
color: #842029;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-error {
|
||||
color: #f8d7da;
|
||||
}
|
||||
|
||||
.mermaid-error strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mermaid-error pre {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mermaid-error pre {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mermaid-error details {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mermaid-error summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mermaid expand toggle */
|
||||
.mermaid-wrapper {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.mermaid-expand-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 10;
|
||||
padding: 0.375rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mermaid-wrapper:hover .mermaid-expand-btn,
|
||||
.mermaid-wrapper:focus-within .mermaid-expand-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Always show button when expanded so user can collapse */
|
||||
.mermaid-wrapper.expanded .mermaid-expand-btn {
|
||||
opacity: 1;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mermaid-expand-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.mermaid-wrapper.expanded .mermaid-expand-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Expanded state — width and offset set dynamically by JS (initMermaidExpand)
|
||||
to fill .main-content without affecting sibling content or using viewport units */
|
||||
.mermaid-wrapper.expanded {
|
||||
max-width: none;
|
||||
position: relative;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mermaid-wrapper.expanded .mermaid {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Override Mermaid's inline max-width so SVG fills expanded container */
|
||||
.mermaid-wrapper.expanded .mermaid svg {
|
||||
max-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Icon visibility toggle */
|
||||
.mermaid-expand-btn .icon-expand { display: inline; }
|
||||
.mermaid-expand-btn .icon-collapse { display: none; }
|
||||
|
||||
.mermaid-wrapper.expanded .mermaid-expand-btn .icon-expand { display: none; }
|
||||
.mermaid-wrapper.expanded .mermaid-expand-btn .icon-collapse { display: inline; }
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Novel Theme - Overlays Module
|
||||
* Toast notifications and keyboard shortcuts cheatsheet
|
||||
*/
|
||||
|
||||
/* ===========================
|
||||
KEYBOARD SHORTCUTS TOAST
|
||||
=========================== */
|
||||
|
||||
.shortcuts-toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
z-index: 1000;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shortcuts-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shortcuts-toast kbd {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
KEYBOARD SHORTCUTS MODAL
|
||||
=========================== */
|
||||
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcuts-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shortcuts-modal {
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.shortcuts-modal header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shortcuts-modal h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shortcuts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.shortcuts-table tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shortcuts-table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcuts-table td {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.shortcuts-table td:first-child {
|
||||
width: 100px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shortcuts-table td:last-child {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shortcuts-table kbd {
|
||||
display: inline-block;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
RESPONSIVE
|
||||
=========================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shortcuts-toast {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.shortcuts-modal {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.shortcuts-modal header {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.shortcuts-table td:first-child {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Novel Theme - Responsive Styles
|
||||
* Media queries for responsive design, print styles
|
||||
*/
|
||||
|
||||
/* Responsive - Tablet */
|
||||
@media (max-width: 900px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
box-shadow: 2px 0 8px var(--shadow);
|
||||
}
|
||||
|
||||
.sidebar.visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Hide header nav text on small screens */
|
||||
.header-nav-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-nav-btn {
|
||||
padding: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile FAB (Floating Action Button) Group */
|
||||
.fab-group {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
display: none; /* Hidden on desktop */
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fab-group {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.fab {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px var(--shadow);
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.fab svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* FAB variants */
|
||||
.fab-menu {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.fab-prev,
|
||||
.fab-next {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fab-prev:hover,
|
||||
.fab-next:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* Bottom Sheet */
|
||||
.bottom-sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottom-sheet[aria-hidden="false"] {
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bottom-sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.bottom-sheet[aria-hidden="false"] .bottom-sheet-backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bottom-sheet-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 85vh;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
box-shadow: 0 -4px 16px var(--shadow);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bottom-sheet[aria-hidden="false"] .bottom-sheet-container {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-sheet-backdrop,
|
||||
.bottom-sheet-container,
|
||||
.fab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-sheet-handle {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bottom-sheet-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.handle-bar {
|
||||
width: 3rem;
|
||||
height: 0.25rem;
|
||||
background: var(--text-muted);
|
||||
border-radius: 0.125rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bottom-sheet-content {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 1rem 1.5rem 1rem;
|
||||
flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Clone sidebar styles for bottom sheet */
|
||||
.bottom-sheet-content .toc-section {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.bottom-sheet-content .toc-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.bottom-sheet-content .toc {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bottom-sheet-content .toc a {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.bottom-sheet-content .toc a:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Hide desktop sidebar on mobile when bottom sheet is active */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.reader-header,
|
||||
.sidebar,
|
||||
.nav-footer,
|
||||
.font-controls,
|
||||
#theme-toggle,
|
||||
#sidebar-toggle,
|
||||
.fab-group,
|
||||
.bottom-sheet {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
pre {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Novel Theme - Sidebar Styles
|
||||
* Layout, sidebar, plan navigation, TOC
|
||||
*/
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
display: flex;
|
||||
margin-top: var(--header-height);
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Resize handle */
|
||||
.sidebar-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-resize-handle:hover,
|
||||
.sidebar-resize-handle.dragging {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Phase accordion */
|
||||
.phase-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.phase-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.phase-chevron {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.phase-group.collapsed .phase-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Progress badges */
|
||||
.phase-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-done {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-done {
|
||||
background: #1e3a2f;
|
||||
color: #75d49b;
|
||||
}
|
||||
|
||||
.badge-progress {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .badge-progress {
|
||||
background: #1a3a5c;
|
||||
color: #6db3f2;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Phase items container — visible by default, collapsed state hides */
|
||||
.phase-items {
|
||||
list-style: none;
|
||||
padding-left: 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.phase-group.collapsed .phase-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Novel Theme - CSS Custom Properties
|
||||
* Light and dark theme variables
|
||||
*/
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
/* Content width responsive to font size */
|
||||
html[data-font-size="S"] { --content-width: 640px; }
|
||||
html[data-font-size="M"] { --content-width: 720px; }
|
||||
html[data-font-size="L"] { --content-width: 800px; }
|
||||
|
||||
[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;
|
||||
}
|
||||
149
.opencode/skills/markdown-novel-viewer/assets/template.html
Normal file
149
.opencode/skills/markdown-novel-viewer/assets/template.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<!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>
|
||||
<!-- Mermaid.js for diagram rendering -->
|
||||
<script type="module">
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
window.mermaidModule = mermaid;
|
||||
</script>
|
||||
</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="progress-bar-container" id="progress-bar">
|
||||
<div class="progress-bar-fill"></div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-resize-handle" id="sidebar-resize"></div>
|
||||
{{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>
|
||||
|
||||
<!-- Keyboard shortcuts toast -->
|
||||
<div class="shortcuts-toast" id="shortcuts-toast" role="status" aria-live="polite">
|
||||
<span>Press <kbd>?</kbd> for keyboard shortcuts</span>
|
||||
<button class="toast-dismiss" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts cheatsheet -->
|
||||
<div class="shortcuts-overlay" id="shortcuts-overlay" role="dialog" aria-modal="true" aria-labelledby="shortcuts-title" hidden>
|
||||
<div class="shortcuts-backdrop"></div>
|
||||
<div class="shortcuts-modal">
|
||||
<header>
|
||||
<h2 id="shortcuts-title">Keyboard Shortcuts</h2>
|
||||
<button class="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<table class="shortcuts-table">
|
||||
<tbody>
|
||||
<tr><td><kbd>T</kbd></td><td>Toggle theme (light/dark)</td></tr>
|
||||
<tr><td><kbd>S</kbd></td><td>Toggle sidebar</td></tr>
|
||||
<tr><td><kbd>←</kbd></td><td>Previous page</td></tr>
|
||||
<tr><td><kbd>→</kbd></td><td>Next page</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>Close sidebar / modal</td></tr>
|
||||
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile FAB group -->
|
||||
<div class="fab-group" id="fab-group" aria-label="Quick navigation">
|
||||
<button class="fab fab-menu" id="fab-menu" aria-label="Open navigation menu">
|
||||
<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>
|
||||
<a class="fab fab-next nav-next-mobile" aria-label="Next page">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 18l6-6-6-6"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="fab fab-prev nav-prev-mobile" aria-label="Previous page">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
<div class="bottom-sheet" id="bottom-sheet" aria-hidden="true">
|
||||
<div class="bottom-sheet-backdrop"></div>
|
||||
<div class="bottom-sheet-container">
|
||||
<div class="bottom-sheet-handle" aria-label="Drag to close">
|
||||
<span class="handle-bar"></span>
|
||||
</div>
|
||||
<div class="bottom-sheet-content" id="bottom-sheet-content">
|
||||
<!-- Sidebar content cloned here via JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__frontmatter = {{frontmatter}};
|
||||
</script>
|
||||
<script src="/assets/reader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
.opencode/skills/markdown-novel-viewer/bun.lock
Normal file
38
.opencode/skills/markdown-novel-viewer/bun.lock
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "markdown-novel-viewer",
|
||||
"dependencies": {
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-bom-string": ["strip-bom-string@1.0.0", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
}
|
||||
}
|
||||
15
.opencode/skills/markdown-novel-viewer/package.json
Normal file
15
.opencode/skills/markdown-novel-viewer/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Core HTTP server for markdown-novel-viewer
|
||||
* Handles routing for markdown viewer and directory browser
|
||||
*
|
||||
* Routes:
|
||||
* - /view?file=<path> - Markdown file viewer
|
||||
* - /browse?dir=<path> - Directory browser
|
||||
* - /assets/* - Static assets
|
||||
* - /file/* - Local files (images, etc.)
|
||||
*
|
||||
* Security: Paths are validated to prevent directory traversal attacks
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
// Allowed base directories for file access (set at runtime)
|
||||
let allowedBaseDirs = [];
|
||||
|
||||
/**
|
||||
* Set allowed directories for file serving
|
||||
* @param {string[]} dirs - Array of allowed directory paths
|
||||
*/
|
||||
function setAllowedDirs(dirs) {
|
||||
allowedBaseDirs = dirs.map(d => path.resolve(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path is within allowed directories (prevents path traversal)
|
||||
* @param {string} filePath - Path to validate
|
||||
* @param {string[]} allowedDirs - Allowed base directories
|
||||
* @returns {boolean} - True if path is safe
|
||||
*/
|
||||
function isPathSafe(filePath, allowedDirs = allowedBaseDirs) {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (resolved.includes('..') || filePath.includes('\0')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no allowed dirs set, allow only project paths
|
||||
if (allowedDirs.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Must be within one of the allowed directories
|
||||
return allowedDirs.some(dir => resolved.startsWith(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error message to prevent path disclosure
|
||||
*/
|
||||
function sanitizeErrorMessage(message) {
|
||||
return message.replace(/\/[^\s'"<>]+/g, '[path]');
|
||||
}
|
||||
|
||||
// MIME type mapping
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.md': 'text/markdown',
|
||||
'.txt': 'text/plain',
|
||||
'.pdf': 'application/pdf'
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MIME type for file extension
|
||||
*/
|
||||
function getMimeType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response with content
|
||||
*/
|
||||
function sendResponse(res, statusCode, contentType, content) {
|
||||
res.writeHead(statusCode, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response (sanitized)
|
||||
*/
|
||||
function sendError(res, statusCode, message) {
|
||||
const safeMessage = sanitizeErrorMessage(message);
|
||||
sendResponse(res, statusCode, 'text/html', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error ${statusCode}</title></head>
|
||||
<body style="font-family: system-ui; padding: 2rem;">
|
||||
<h1>Error ${statusCode}</h1>
|
||||
<p>${safeMessage}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve static file with path validation
|
||||
*/
|
||||
function serveFile(res, filePath, skipValidation = false) {
|
||||
if (!skipValidation && !isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
sendResponse(res, 200, mimeType, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file icon based on extension
|
||||
*/
|
||||
function getFileIcon(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const iconMap = {
|
||||
'.md': '📄',
|
||||
'.txt': '📝',
|
||||
'.json': '📋',
|
||||
'.js': '📜',
|
||||
'.cjs': '📜',
|
||||
'.mjs': '📜',
|
||||
'.ts': '📘',
|
||||
'.css': '🎨',
|
||||
'.html': '🌐',
|
||||
'.png': '🖼️',
|
||||
'.jpg': '🖼️',
|
||||
'.jpeg': '🖼️',
|
||||
'.gif': '🖼️',
|
||||
'.svg': '🖼️',
|
||||
'.pdf': '📕',
|
||||
'.yaml': '⚙️',
|
||||
'.yml': '⚙️',
|
||||
'.toml': '⚙️',
|
||||
'.env': '🔐',
|
||||
'.sh': '💻',
|
||||
'.bash': '💻'
|
||||
};
|
||||
return iconMap[ext] || '📄';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render directory browser HTML
|
||||
*/
|
||||
function renderDirectoryBrowser(dirPath, assetsDir) {
|
||||
const items = fs.readdirSync(dirPath);
|
||||
const displayPath = dirPath.length > 50 ? '...' + dirPath.slice(-47) : dirPath;
|
||||
|
||||
// Separate directories and files, sort alphabetically
|
||||
const dirs = [];
|
||||
const files = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Skip hidden files and deprecated folders
|
||||
if (item.startsWith('.') || item === 'deprecated') continue;
|
||||
|
||||
const itemPath = path.join(dirPath, item);
|
||||
try {
|
||||
const stats = fs.statSync(itemPath);
|
||||
if (stats.isDirectory()) {
|
||||
dirs.push(item);
|
||||
} else {
|
||||
files.push(item);
|
||||
}
|
||||
} catch {
|
||||
// Skip items we can't stat
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
files.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
|
||||
// Build file list HTML
|
||||
let listHtml = '';
|
||||
|
||||
// Parent directory link (if not root)
|
||||
const parentDir = path.dirname(dirPath);
|
||||
if (parentDir !== dirPath) {
|
||||
listHtml += `<li class="dir-item parent">
|
||||
<a href="/browse?dir=${encodeURIComponent(parentDir)}">
|
||||
<span class="icon">📁</span>
|
||||
<span class="name">..</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// Directories
|
||||
for (const dir of dirs) {
|
||||
const fullPath = path.join(dirPath, dir);
|
||||
listHtml += `<li class="dir-item folder">
|
||||
<a href="/browse?dir=${encodeURIComponent(fullPath)}">
|
||||
<span class="icon">📁</span>
|
||||
<span class="name">${dir}/</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// Files
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dirPath, file);
|
||||
const icon = getFileIcon(file);
|
||||
const isMarkdown = file.endsWith('.md');
|
||||
|
||||
if (isMarkdown) {
|
||||
listHtml += `<li class="dir-item file markdown">
|
||||
<a href="/view?file=${encodeURIComponent(fullPath)}">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${file}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
} else {
|
||||
listHtml += `<li class="dir-item file">
|
||||
<a href="/file${fullPath}" target="_blank">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${file}</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty directory message
|
||||
if (dirs.length === 0 && files.length === 0) {
|
||||
listHtml = '<li class="empty">This directory is empty</li>';
|
||||
}
|
||||
|
||||
// Read CSS
|
||||
let css = '';
|
||||
const cssPath = path.join(assetsDir, 'directory-browser.css');
|
||||
if (fs.existsSync(cssPath)) {
|
||||
css = fs.readFileSync(cssPath, 'utf8');
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📁 ${path.basename(dirPath)}</title>
|
||||
<style>
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>📁 ${path.basename(dirPath)}</h1>
|
||||
<p class="path">${displayPath}</p>
|
||||
</header>
|
||||
<ul class="file-list">
|
||||
${listHtml}
|
||||
</ul>
|
||||
<footer>
|
||||
<p>${dirs.length} folder${dirs.length !== 1 ? 's' : ''}, ${files.length} file${files.length !== 1 ? 's' : ''}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTTP server with routing
|
||||
* @param {Object} options - Server options
|
||||
* @param {string} options.assetsDir - Static assets directory
|
||||
* @param {Function} options.renderMarkdown - Markdown render function (filePath) => html
|
||||
* @param {string[]} options.allowedDirs - Allowed directories for file access
|
||||
* @returns {http.Server} - HTTP server instance
|
||||
*/
|
||||
function createHttpServer(options) {
|
||||
const { assetsDir, renderMarkdown, allowedDirs = [] } = options;
|
||||
|
||||
// Set allowed directories for path validation
|
||||
if (allowedDirs.length > 0) {
|
||||
setAllowedDirs(allowedDirs);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
const pathname = decodeURIComponent(parsedUrl.pathname);
|
||||
|
||||
// Route: /assets/* - serve static files from assets directory
|
||||
if (pathname.startsWith('/assets/')) {
|
||||
const relativePath = pathname.replace('/assets/', '');
|
||||
if (relativePath.includes('..')) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
const assetPath = path.join(assetsDir, relativePath);
|
||||
serveFile(res, assetPath, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /file/* - serve local files (images, etc.)
|
||||
if (pathname.startsWith('/file/')) {
|
||||
// Extract path after '/file/' prefix (slice(6) removes '/file/')
|
||||
// Path is already URL-decoded by decodeURIComponent above
|
||||
const filePath = pathname.slice(6);
|
||||
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
serveFile(res, filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /view?file=<path> - render markdown (query param)
|
||||
if (pathname === '/view') {
|
||||
const filePath = parsedUrl.query?.file;
|
||||
|
||||
if (!filePath) {
|
||||
sendError(res, 400, 'Missing ?file= parameter. Use /view?file=/path/to/file.md');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathSafe(filePath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = renderMarkdown(filePath);
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Render error:', err.message);
|
||||
sendError(res, 500, 'Error rendering markdown');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: /browse?dir=<path> - directory browser (query param)
|
||||
if (pathname === '/browse') {
|
||||
const dirPath = parsedUrl.query?.dir;
|
||||
|
||||
if (!dirPath) {
|
||||
sendError(res, 400, 'Missing ?dir= parameter. Use /browse?dir=/path/to/directory');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPathSafe(dirPath)) {
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
sendError(res, 404, 'Directory not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const html = renderDirectoryBrowser(dirPath, assetsDir);
|
||||
sendResponse(res, 200, 'text/html', html);
|
||||
} catch (err) {
|
||||
console.error('[http-server] Browse error:', err.message);
|
||||
sendError(res, 500, 'Error listing directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Route: / - show welcome/usage page
|
||||
if (pathname === '/') {
|
||||
sendResponse(res, 200, 'text/html', `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Markdown Novel Viewer</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 1rem; }
|
||||
h1 { color: #8b4513; }
|
||||
code { background: #f5f5f5; padding: 0.2rem 0.4rem; border-radius: 3px; }
|
||||
.routes { background: #faf8f3; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📖 Markdown Novel Viewer</h1>
|
||||
<p>A calm, book-like viewer for markdown files.</p>
|
||||
<div class="routes">
|
||||
<h3>Routes</h3>
|
||||
<ul>
|
||||
<li><code>/view?file=/path/to/file.md</code> - View markdown</li>
|
||||
<li><code>/browse?dir=/path/to/dir</code> - Browse directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Use the <code>/ck:preview</code> skill invocation to start viewing files.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: 404
|
||||
sendError(res, 404, 'Not found');
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createHttpServer,
|
||||
getMimeType,
|
||||
sendResponse,
|
||||
sendError,
|
||||
serveFile,
|
||||
isPathSafe,
|
||||
setAllowedDirs,
|
||||
sanitizeErrorMessage,
|
||||
MIME_TYPES,
|
||||
renderDirectoryBrowser,
|
||||
getFileIcon
|
||||
};
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Markdown rendering engine with syntax highlighting and image resolution
|
||||
* Converts markdown to styled HTML for novel-reader UI
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Lazy load dependencies
|
||||
let marked = null;
|
||||
let hljs = null;
|
||||
let matter = null;
|
||||
|
||||
/**
|
||||
* Escape HTML entities to prevent XSS in mermaid content
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} - Escaped string
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize markdown dependencies
|
||||
*/
|
||||
function initDependencies() {
|
||||
if (!marked) {
|
||||
const { Marked } = require('marked');
|
||||
hljs = require('highlight.js');
|
||||
|
||||
marked = new Marked({
|
||||
gfm: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
// Custom extension for code blocks (handles mermaid specially)
|
||||
// marked v17+ requires extensions array for custom token handling
|
||||
const mermaidExtension = {
|
||||
name: 'mermaidCodeBlock',
|
||||
level: 'block',
|
||||
renderer(token) {
|
||||
// This is called for code tokens
|
||||
if (token.type === 'code') {
|
||||
const code = token.text || '';
|
||||
const language = token.lang || '';
|
||||
|
||||
// Handle mermaid code blocks - render as div for client-side processing
|
||||
if (language === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
|
||||
}
|
||||
|
||||
// Regular code blocks with syntax highlighting
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = hljs.highlight(code, { language }).value;
|
||||
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect language or plain text
|
||||
const highlighted = hljs.highlightAuto(code).value;
|
||||
return `<pre><code class="hljs">${highlighted}</code></pre>`;
|
||||
}
|
||||
return false; // Use default renderer for other tokens
|
||||
}
|
||||
};
|
||||
|
||||
// Use the renderer override approach for marked v17+
|
||||
marked.use({
|
||||
renderer: {
|
||||
code(token) {
|
||||
const code = typeof token === 'string' ? token : (token.text || '');
|
||||
const language = typeof token === 'string' ? '' : (token.lang || '');
|
||||
|
||||
// Handle mermaid code blocks - render as div for client-side processing
|
||||
if (language === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(code)}</pre>`;
|
||||
}
|
||||
|
||||
// Regular code blocks with syntax highlighting
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
const highlighted = hljs.highlight(code, { language }).value;
|
||||
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect language or plain text
|
||||
const highlighted = hljs.highlightAuto(code).value;
|
||||
return `<pre><code class="hljs">${highlighted}</code></pre>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
matter = require('gray-matter');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single image source path to /file/ route
|
||||
* @param {string} src - Image source path
|
||||
* @param {string} basePath - Base directory path
|
||||
* @returns {string} - Resolved path or original if absolute URL
|
||||
*/
|
||||
function resolveImageSrc(src, basePath) {
|
||||
// Skip absolute URLs
|
||||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/file')) {
|
||||
return src;
|
||||
}
|
||||
// Resolve relative path to absolute /file/ route
|
||||
// Use URL encoding to handle special chars and Windows paths (D:\...)
|
||||
const absolutePath = path.resolve(basePath, src);
|
||||
return `/file/${encodeURIComponent(absolutePath)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative image paths to /file/ routes
|
||||
* Supports both inline and reference-style markdown images
|
||||
* @param {string} markdown - Markdown content
|
||||
* @param {string} basePath - Base directory path
|
||||
* @returns {string} - Markdown with resolved image paths
|
||||
*/
|
||||
function resolveImages(markdown, basePath) {
|
||||
let result = markdown;
|
||||
|
||||
// 1. Handle inline images:  or 
|
||||
const inlineImgRegex = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||
result = result.replace(inlineImgRegex, (match, alt, src) => {
|
||||
const resolvedSrc = resolveImageSrc(src, basePath);
|
||||
return ``;
|
||||
});
|
||||
|
||||
// 2. Handle reference-style image definitions: [label]: src or [label]: src "title"
|
||||
// These appear at the end of the document like: [Step 1 Initial]: ./screenshots/step1.png
|
||||
const refDefRegex = /^\[([^\]]+)\]:\s*(\S+)(?:\s+"[^"]*")?$/gm;
|
||||
result = result.replace(refDefRegex, (match, label, src) => {
|
||||
const resolvedSrc = resolveImageSrc(src, basePath);
|
||||
return `[${label}]: ${resolvedSrc}`;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate table of contents from headings
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {Array<{level: number, id: string, text: string}>} - TOC items
|
||||
*/
|
||||
function generateTOC(html) {
|
||||
const headings = [];
|
||||
// Match h1-h3 with id attribute
|
||||
const regex = /<h([1-3])[^>]*id="([^"]+)"[^>]*>([^<]+)<\/h\1>/gi;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
headings.push({
|
||||
level: parseInt(match[1], 10),
|
||||
id: match[2],
|
||||
text: match[3].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from text for use as anchor ID (matches plan-navigator.cjs)
|
||||
* @param {string} text - Text to slugify
|
||||
* @returns {string} - URL-safe slug
|
||||
*/
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add IDs to headings for anchor links
|
||||
* Also adds phase-specific IDs for inline phases in plan.md
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {string} - HTML with heading IDs
|
||||
*/
|
||||
function addHeadingIds(html) {
|
||||
const usedIds = new Set();
|
||||
|
||||
return html.replace(/<h([1-6])>([^<]+)<\/h\1>/gi, (match, level, text) => {
|
||||
// Check if this is a phase heading (e.g., "Phase 01: Name" or contains phase table row content)
|
||||
const phaseMatch = text.match(/^Phase\s*(\d+)[:\s]+(.+)/i);
|
||||
|
||||
let id;
|
||||
if (phaseMatch) {
|
||||
// Generate phase-specific anchor ID that matches plan-navigator.cjs format
|
||||
const phaseNum = parseInt(phaseMatch[1], 10);
|
||||
const phaseName = phaseMatch[2].trim();
|
||||
id = `phase-${String(phaseNum).padStart(2, '0')}-${slugify(phaseName)}`;
|
||||
} else {
|
||||
// Standard heading ID generation
|
||||
id = slugify(text);
|
||||
}
|
||||
|
||||
// Handle duplicate IDs
|
||||
let uniqueId = id;
|
||||
let counter = 1;
|
||||
while (usedIds.has(uniqueId)) {
|
||||
uniqueId = `${id}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
usedIds.add(uniqueId);
|
||||
|
||||
return `<h${level} id="${uniqueId}">${text}</h${level}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add anchor IDs to phase table rows
|
||||
* Matches table rows with phase numbers: | 01 | Description | Status |
|
||||
* @param {string} html - Rendered HTML
|
||||
* @returns {string} - HTML with phase anchor IDs in table rows
|
||||
*/
|
||||
function addPhaseTableAnchors(html) {
|
||||
const usedIds = new Set();
|
||||
|
||||
// Match table rows with phase pattern: <tr><td>01</td><td>Description</td>...
|
||||
// This handles the "Phase Summary" table format
|
||||
return html.replace(/<tr>\s*<td>(\d{2})<\/td>\s*<td>([^<]+)<\/td>/gi, (match, phaseNum, description) => {
|
||||
const num = parseInt(phaseNum, 10);
|
||||
const slug = slugify(description.trim());
|
||||
const id = `phase-${String(num).padStart(2, '0')}-${slug}`;
|
||||
|
||||
// Handle duplicates
|
||||
let uniqueId = id;
|
||||
let counter = 1;
|
||||
while (usedIds.has(uniqueId)) {
|
||||
uniqueId = `${id}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
usedIds.add(uniqueId);
|
||||
|
||||
// Add anchor span at the start of the row
|
||||
return `<tr id="${uniqueId}"><td>${phaseNum}</td><td>${description}</td>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frontmatter from markdown
|
||||
* @param {string} content - Raw markdown content
|
||||
* @returns {{data: Object, content: string}} - Parsed frontmatter and content
|
||||
*/
|
||||
function parseFrontmatter(content) {
|
||||
initDependencies();
|
||||
return matter(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown file to HTML
|
||||
* @param {string} filePath - Path to markdown file
|
||||
* @param {Object} options - Render options
|
||||
* @returns {{html: string, toc: Array, frontmatter: Object, title: string}}
|
||||
*/
|
||||
function renderMarkdownFile(filePath, options = {}) {
|
||||
initDependencies();
|
||||
|
||||
const rawContent = fs.readFileSync(filePath, 'utf8');
|
||||
const basePath = path.dirname(filePath);
|
||||
|
||||
// Parse frontmatter
|
||||
const { data: frontmatter, content } = parseFrontmatter(rawContent);
|
||||
|
||||
// Resolve image paths
|
||||
const resolvedContent = resolveImages(content, basePath);
|
||||
|
||||
// Render markdown to HTML
|
||||
let html = marked.parse(resolvedContent);
|
||||
|
||||
// Add IDs to headings
|
||||
html = addHeadingIds(html);
|
||||
|
||||
// Add anchor IDs to phase table rows (for inline phases in plan.md)
|
||||
html = addPhaseTableAnchors(html);
|
||||
|
||||
// Generate TOC
|
||||
const toc = generateTOC(html);
|
||||
|
||||
// Extract title from frontmatter or first h1
|
||||
let title = frontmatter.title;
|
||||
if (!title) {
|
||||
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||
title = h1Match ? h1Match[1] : path.basename(filePath, '.md');
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
toc,
|
||||
frontmatter,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render TOC as HTML sidebar
|
||||
* @param {Array} toc - TOC items
|
||||
* @returns {string} - HTML string
|
||||
*/
|
||||
function renderTOCHtml(toc) {
|
||||
if (!toc.length) return '';
|
||||
|
||||
const items = toc.map(({ level, id, text }) => {
|
||||
const indent = (level - 1) * 12;
|
||||
return `<li style="padding-left: ${indent}px"><a href="#${id}">${text}</a></li>`;
|
||||
}).join('\n');
|
||||
|
||||
return `<ul class="toc-list">${items}</ul>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderMarkdownFile,
|
||||
resolveImages,
|
||||
resolveImageSrc,
|
||||
generateTOC,
|
||||
addHeadingIds,
|
||||
addPhaseTableAnchors,
|
||||
parseFrontmatter,
|
||||
renderTOCHtml,
|
||||
initDependencies
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Plan navigation system - detects plan structure and generates navigation
|
||||
* Delegates parsing to shared plan-table-parser module
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parsePlanPhases, normalizeStatus, filenameToTitle } = require('../../../_shared/lib/plan-table-parser.cjs');
|
||||
|
||||
/** Escape HTML special characters to prevent XSS */
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Generate a slug from text for anchor IDs */
|
||||
function slugify(text) {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a file is part of a plan directory
|
||||
* @param {string} filePath
|
||||
* @returns {{isPlan: boolean, planDir: string, planFile: string, phases: Array}}
|
||||
*/
|
||||
function detectPlan(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
const planFile = path.join(dir, 'plan.md');
|
||||
if (!fs.existsSync(planFile)) return { isPlan: false };
|
||||
|
||||
const files = fs.readdirSync(dir);
|
||||
const phases = files
|
||||
.filter(f => f.startsWith('phase-') && f.endsWith('.md'))
|
||||
.sort((a, b) => {
|
||||
const matchA = a.match(/phase-(\d+)([a-z]?)/);
|
||||
const matchB = b.match(/phase-(\d+)([a-z]?)/);
|
||||
const numA = parseInt(matchA?.[1] || '0', 10);
|
||||
const numB = parseInt(matchB?.[1] || '0', 10);
|
||||
if (numA !== numB) return numA - numB;
|
||||
return (matchA?.[2] || '').localeCompare(matchB?.[2] || '');
|
||||
});
|
||||
|
||||
return { isPlan: true, planDir: dir, planFile, phases: phases.map(f => path.join(dir, f)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse plan.md delegating to shared parser with anchor generation
|
||||
* @param {string} planFilePath
|
||||
* @returns {Array}
|
||||
*/
|
||||
function parsePlanTable(planFilePath) {
|
||||
const content = fs.readFileSync(planFilePath, 'utf8');
|
||||
const dir = path.dirname(planFilePath);
|
||||
const phases = parsePlanPhases(content, dir, { generateAnchors: true, slugify });
|
||||
|
||||
// Enhancement: resolve files from "Phase Files" section for heading-based phases
|
||||
if (phases.length > 0) {
|
||||
const phaseFilesSection = content.match(/##\s*Phase\s*Files[\s\S]*?(?=##|$)/i);
|
||||
if (phaseFilesSection) {
|
||||
const linkRegex = /\d+\.\s*\[([^\]]+)\]\(([^)]+\.md)\)/g;
|
||||
let linkMatch;
|
||||
while ((linkMatch = linkRegex.exec(phaseFilesSection[0])) !== null) {
|
||||
const [, , linkPath] = linkMatch;
|
||||
const phaseNum = parseInt(linkMatch[1].match(/phase-0?(\d+)/i)?.[1] || '0', 10);
|
||||
const phase = phases.find(p => p.phase === phaseNum);
|
||||
if (phase && (!phase.file || phase.file === planFilePath)) {
|
||||
phase.file = path.resolve(dir, linkPath);
|
||||
phase.anchor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out inline-only phases (no separate file)
|
||||
return phases.filter(p => p.file && p.file !== planFilePath);
|
||||
}
|
||||
|
||||
/** Get navigation context for a file */
|
||||
function getNavigationContext(filePath) {
|
||||
const planInfo = detectPlan(filePath);
|
||||
if (!planInfo.isPlan) return { planInfo, currentIndex: -1, prev: null, next: null, allPhases: [] };
|
||||
|
||||
const phaseMeta = parsePlanTable(planInfo.planFile);
|
||||
const allPhases = [{ phase: 0, phaseId: '0', name: 'Plan Overview', status: 'overview', file: planInfo.planFile }, ...phaseMeta];
|
||||
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const currentIndex = allPhases.findIndex(p => path.normalize(p.file) === normalizedPath);
|
||||
const prev = currentIndex > 0 ? allPhases[currentIndex - 1] : null;
|
||||
const next = currentIndex < allPhases.length - 1 && currentIndex >= 0 ? allPhases[currentIndex + 1] : null;
|
||||
|
||||
return { planInfo, currentIndex, prev, next, allPhases };
|
||||
}
|
||||
|
||||
/** Get status badge HTML for a phase group */
|
||||
function getGroupBadge(phases) {
|
||||
const completed = phases.filter(p => p.status === 'completed').length;
|
||||
const inProgress = phases.filter(p => p.status === 'in-progress').length;
|
||||
if (completed === phases.length) return '<span class="phase-badge badge-done">✓</span>';
|
||||
if (inProgress > 0) return '<span class="phase-badge badge-progress">●</span>';
|
||||
return '<span class="phase-badge badge-pending">○</span>';
|
||||
}
|
||||
|
||||
/** Render a single phase item as HTML */
|
||||
function renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath) {
|
||||
const isActive = index === currentIndex;
|
||||
const statusClass = phase.status.replace(/\s+/g, '-');
|
||||
const normalizedPhasePath = path.normalize(phase.file);
|
||||
const isSameFile = normalizedPhasePath === normalizedCurrentPath;
|
||||
const fileExists = fs.existsSync(phase.file);
|
||||
|
||||
// M7: escape statusClass and phase.anchor to prevent XSS in HTML attributes
|
||||
const safeStatusClass = escapeHtml(statusClass);
|
||||
const safeAnchor = phase.anchor ? escapeHtml(phase.anchor) : null;
|
||||
|
||||
if (!fileExists) {
|
||||
return `<li class="phase-item unavailable" data-status="${safeStatusClass}" title="Phase planned but not yet implemented">
|
||||
<span class="phase-link-disabled">
|
||||
<span class="status-dot ${safeStatusClass}"></span>
|
||||
<span class="phase-name">${escapeHtml(phase.name)}</span>
|
||||
<span class="unavailable-badge">Planned</span>
|
||||
</span></li>`;
|
||||
}
|
||||
|
||||
let href, isInlineSection = false;
|
||||
if (isSameFile && safeAnchor) { href = `#${safeAnchor}`; isInlineSection = true; }
|
||||
else if (safeAnchor) { href = `/view?file=${encodeURIComponent(phase.file)}#${safeAnchor}`; }
|
||||
else { href = `/view?file=${encodeURIComponent(phase.file)}`; }
|
||||
|
||||
const dataAnchor = safeAnchor ? `data-anchor="${safeAnchor}"` : '';
|
||||
const inlineSectionClass = isInlineSection ? 'inline-section' : '';
|
||||
const typeIcon = isInlineSection
|
||||
? `<svg class="phase-type-icon" viewBox="0 0 16 16" fill="currentColor"><path d="M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-.5 9.45a.75.75 0 01-1.06-1.06l-1.25 1.25a2 2 0 01-2.83-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25z"/></svg>`
|
||||
: `<svg class="phase-type-icon" viewBox="0 0 16 16" fill="currentColor"><path d="M3.75 1.5a.25.25 0 00-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V4.664a.25.25 0 00-.073-.177l-2.914-2.914a.25.25 0 00-.177-.073H3.75zM2 1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0112.25 16h-8.5A1.75 1.75 0 012 14.25V1.75z"/></svg>`;
|
||||
|
||||
return `<li class="phase-item ${isActive ? 'active' : ''} ${inlineSectionClass}" data-status="${safeStatusClass}" ${dataAnchor}>
|
||||
<a href="${href}">${typeIcon}<span class="status-dot ${safeStatusClass}"></span><span class="phase-name">${escapeHtml(phase.name)}</span></a></li>`;
|
||||
}
|
||||
|
||||
/** Generate navigation sidebar HTML */
|
||||
function generateNavSidebar(filePath) {
|
||||
const { planInfo, currentIndex, allPhases } = getNavigationContext(filePath);
|
||||
if (!planInfo.isPlan) return '';
|
||||
|
||||
const planName = path.basename(planInfo.planDir);
|
||||
const normalizedCurrentPath = path.normalize(filePath);
|
||||
|
||||
// Flat list when <= 15 phases (no accordion grouping needed)
|
||||
if (allPhases.length <= 15) {
|
||||
const items = allPhases.map((phase, index) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join('');
|
||||
return `<nav class="plan-nav" id="plan-nav">
|
||||
<div class="plan-title"><span class="plan-icon">📖</span><span>${escapeHtml(planName)}</span></div>
|
||||
<ul class="phase-list">${items}</ul></nav>`;
|
||||
}
|
||||
|
||||
// Accordion groups for large plans (> 15 phases)
|
||||
const groups = [];
|
||||
let currentGroup = [], groupStart = 0;
|
||||
allPhases.forEach((phase, index) => {
|
||||
if (currentGroup.length === 0) groupStart = phase.phase;
|
||||
currentGroup.push({ phase, index });
|
||||
if (currentGroup.length === 10 || index === allPhases.length - 1 || (phase.phase % 10 === 0 && phase.phase !== groupStart)) {
|
||||
groups.push({ start: groupStart, end: phase.phase, phases: [...currentGroup] });
|
||||
currentGroup = [];
|
||||
}
|
||||
});
|
||||
|
||||
const groupsHtml = groups.map(group => {
|
||||
const groupId = `phase-group-${group.start}-${group.end}`;
|
||||
const groupLabel = group.start === 0 ? 'Overview' : group.start === group.end ? `Phase ${group.start}` : `Phases ${group.start}-${group.end}`;
|
||||
const badge = getGroupBadge(group.phases.map(p => p.phase));
|
||||
const items = group.phases.map(({ phase, index }) => renderPhaseItem(phase, index, currentIndex, normalizedCurrentPath)).join('');
|
||||
return `<div class="phase-group" data-phase-id="${groupId}">
|
||||
<button class="phase-header" tabindex="0" aria-expanded="true" aria-controls="${groupId}-items">
|
||||
<span class="phase-chevron">▼</span><span class="phase-name">${escapeHtml(groupLabel)}</span>${badge}
|
||||
</button>
|
||||
<ul class="phase-items" id="${groupId}-items">${items}</ul></div>`;
|
||||
}).join('');
|
||||
|
||||
return `<nav class="plan-nav" id="plan-nav">
|
||||
<div class="plan-title"><span class="plan-icon">📖</span><span>${escapeHtml(planName)}</span></div>
|
||||
${groupsHtml}</nav>`;
|
||||
}
|
||||
|
||||
/** Generate prev/next navigation footer HTML */
|
||||
function generateNavFooter(filePath) {
|
||||
const { prev, next } = getNavigationContext(filePath);
|
||||
if (!prev && !next) return '';
|
||||
|
||||
const prevExists = prev && fs.existsSync(prev.file);
|
||||
const nextExists = next && fs.existsSync(next.file);
|
||||
|
||||
const prevHtml = prev ? (prevExists
|
||||
? `<a href="/view?file=${encodeURIComponent(prev.file)}" class="nav-prev"><span class="nav-arrow">←</span><span class="nav-label">${escapeHtml(prev.name)}</span></a>`
|
||||
: `<span class="nav-prev nav-unavailable" title="Phase planned but not yet implemented"><span class="nav-arrow">←</span><span class="nav-label">${escapeHtml(prev.name)}</span><span class="nav-badge">Planned</span></span>`)
|
||||
: '<span></span>';
|
||||
|
||||
const nextHtml = next ? (nextExists
|
||||
? `<a href="/view?file=${encodeURIComponent(next.file)}" class="nav-next"><span class="nav-label">${escapeHtml(next.name)}</span><span class="nav-arrow">→</span></a>`
|
||||
: `<span class="nav-next nav-unavailable" title="Phase planned but not yet implemented"><span class="nav-label">${escapeHtml(next.name)}</span><span class="nav-badge">Planned</span><span class="nav-arrow">→</span></span>`)
|
||||
: '<span></span>';
|
||||
|
||||
return `<footer class="nav-footer">${prevHtml}${nextHtml}</footer>`;
|
||||
}
|
||||
|
||||
module.exports = { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar, generateNavFooter };
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Port finder utility - finds available port in range
|
||||
* Used by markdown-novel-viewer server
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
|
||||
const DEFAULT_PORT = 3456;
|
||||
const PORT_RANGE_END = 3500;
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
* @param {number} port - Port to check
|
||||
* @returns {Promise<boolean>} - True if available
|
||||
*/
|
||||
function isPortAvailable(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find first available port in range
|
||||
* @param {number} startPort - Starting port (default: 3456)
|
||||
* @returns {Promise<number>} - Available port
|
||||
* @throws {Error} - If no port available in range
|
||||
*/
|
||||
async function findAvailablePort(startPort = DEFAULT_PORT) {
|
||||
for (let port = startPort; port <= PORT_RANGE_END; port++) {
|
||||
if (await isPortAvailable(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port in range ${startPort}-${PORT_RANGE_END}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
DEFAULT_PORT,
|
||||
PORT_RANGE_END
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Process manager - handles PID files and server lifecycle
|
||||
* Used by markdown-novel-viewer server
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PID_DIR = '/tmp';
|
||||
const PID_PREFIX = 'md-novel-viewer-';
|
||||
|
||||
/**
|
||||
* Get PID file path for a port
|
||||
* @param {number} port - Server port
|
||||
* @returns {string} - PID file path
|
||||
*/
|
||||
function getPidFilePath(port) {
|
||||
return path.join(PID_DIR, `${PID_PREFIX}${port}.pid`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write PID file for running server
|
||||
* @param {number} port - Server port
|
||||
* @param {number} pid - Process ID
|
||||
*/
|
||||
function writePidFile(port, pid) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
fs.writeFileSync(pidPath, String(pid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PID from file
|
||||
* @param {number} port - Server port
|
||||
* @returns {number|null} - PID or null if not found
|
||||
*/
|
||||
function readPidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
const pid = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
return parseInt(pid, 10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove PID file
|
||||
* @param {number} port - Server port
|
||||
*/
|
||||
function removePidFile(port) {
|
||||
const pidPath = getPidFilePath(port);
|
||||
if (fs.existsSync(pidPath)) {
|
||||
fs.unlinkSync(pidPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all running server instances
|
||||
* @returns {Array<{port: number, pid: number}>} - Running instances
|
||||
*/
|
||||
function findRunningInstances() {
|
||||
const instances = [];
|
||||
const files = fs.readdirSync(PID_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(PID_PREFIX) && file.endsWith('.pid')) {
|
||||
const port = parseInt(file.replace(PID_PREFIX, '').replace('.pid', ''), 10);
|
||||
const pid = readPidFile(port);
|
||||
if (pid) {
|
||||
// Check if process is actually running
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
instances.push({ port, pid });
|
||||
} catch {
|
||||
// Process not running, clean up stale PID file
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop server by port
|
||||
* @param {number} port - Server port
|
||||
* @returns {boolean} - True if stopped successfully
|
||||
*/
|
||||
function stopServer(port) {
|
||||
const pid = readPidFile(port);
|
||||
if (!pid) return false;
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
removePidFile(port);
|
||||
return true;
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running servers
|
||||
* @returns {number} - Number of servers stopped
|
||||
*/
|
||||
function stopAllServers() {
|
||||
const instances = findRunningInstances();
|
||||
let stopped = 0;
|
||||
|
||||
for (const { port, pid } of instances) {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
removePidFile(port);
|
||||
stopped++;
|
||||
} catch {
|
||||
removePidFile(port);
|
||||
}
|
||||
}
|
||||
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup graceful shutdown handlers
|
||||
* @param {number} port - Server port
|
||||
* @param {Function} cleanup - Additional cleanup function
|
||||
*/
|
||||
function setupShutdownHandlers(port, cleanup) {
|
||||
const handler = (signal) => {
|
||||
if (cleanup) cleanup();
|
||||
removePidFile(port);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', handler);
|
||||
process.on('SIGINT', handler);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPidFilePath,
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
findRunningInstances,
|
||||
stopServer,
|
||||
stopAllServers,
|
||||
setupShutdownHandlers,
|
||||
PID_PREFIX
|
||||
};
|
||||
411
.opencode/skills/markdown-novel-viewer/scripts/server.cjs
Executable file
411
.opencode/skills/markdown-novel-viewer/scripts/server.cjs
Executable file
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Markdown Novel Viewer Server
|
||||
* Background HTTP server rendering markdown files with calm, book-like UI
|
||||
*
|
||||
* Universal viewer - pass ANY path and view it:
|
||||
* - Markdown files → novel-reader UI
|
||||
* - Directories → file listing browser
|
||||
*
|
||||
* Usage:
|
||||
* node server.cjs --file ./plan.md [--port 3456] [--no-open] [--stop] [--host 0.0.0.0]
|
||||
* node server.cjs --dir ./plans [--port 3456] # Browse directory
|
||||
*
|
||||
* Options:
|
||||
* --file <path> Path to markdown file
|
||||
* --dir <path> Path to directory (browse mode)
|
||||
* --port <number> Server port (default: 3456, auto-increment if busy)
|
||||
* --host <addr> Host to bind (default: localhost, use 0.0.0.0 for all interfaces)
|
||||
* --no-open Disable auto-open browser (opens by default)
|
||||
* --stop Stop all running servers
|
||||
* --background Run in background (detached) - legacy mode
|
||||
* --foreground Run in foreground (for CC background tasks)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawn, execSync } = require('child_process');
|
||||
|
||||
const { findAvailablePort, DEFAULT_PORT } = require('./lib/port-finder.cjs');
|
||||
const { writePidFile, stopAllServers, setupShutdownHandlers, findRunningInstances } = require('./lib/process-mgr.cjs');
|
||||
const { createHttpServer } = require('./lib/http-server.cjs');
|
||||
const { renderMarkdownFile, renderTOCHtml } = require('./lib/markdown-renderer.cjs');
|
||||
const { generateNavSidebar, generateNavFooter, detectPlan, getNavigationContext } = require('./lib/plan-navigator.cjs');
|
||||
|
||||
/**
|
||||
* Parse command line arguments
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
file: null,
|
||||
dir: null,
|
||||
port: DEFAULT_PORT,
|
||||
host: 'localhost',
|
||||
open: true, // Auto-open browser by default
|
||||
stop: false,
|
||||
background: false,
|
||||
foreground: false,
|
||||
isChild: false
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--file' && argv[i + 1]) {
|
||||
args.file = argv[++i];
|
||||
} else if (arg === '--dir' && argv[i + 1]) {
|
||||
args.dir = argv[++i];
|
||||
} else if (arg === '--port' && argv[i + 1]) {
|
||||
args.port = parseInt(argv[++i], 10);
|
||||
} else if (arg === '--host' && argv[i + 1]) {
|
||||
args.host = argv[++i];
|
||||
} else if (arg === '--open') {
|
||||
args.open = true;
|
||||
} else if (arg === '--no-open') {
|
||||
args.open = false;
|
||||
} else if (arg === '--stop') {
|
||||
args.stop = true;
|
||||
} else if (arg === '--background') {
|
||||
args.background = true;
|
||||
} else if (arg === '--foreground') {
|
||||
args.foreground = true;
|
||||
} else if (arg === '--child') {
|
||||
args.isChild = true;
|
||||
} else if (!arg.startsWith('--') && !args.file && !args.dir) {
|
||||
// Positional argument - could be file or directory
|
||||
args.file = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve input path - simple logic, no smart detection
|
||||
* @param {string} input - Input path
|
||||
* @param {string} cwd - Current working directory
|
||||
* @returns {{type: 'file'|'directory'|null, path: string|null}}
|
||||
*/
|
||||
function resolveInput(input, cwd) {
|
||||
if (!input) return { type: null, path: null };
|
||||
|
||||
// Resolve relative to CWD
|
||||
const resolved = path.isAbsolute(input) ? input : path.resolve(cwd, input);
|
||||
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return { type: null, path: null };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(resolved);
|
||||
|
||||
// File mode
|
||||
if (stats.isFile()) {
|
||||
return { type: 'file', path: resolved };
|
||||
}
|
||||
|
||||
// Directory mode - browse, no auto-detection of plan.md
|
||||
if (stats.isDirectory()) {
|
||||
return { type: 'directory', path: resolved };
|
||||
}
|
||||
|
||||
return { type: null, path: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser with URL
|
||||
*/
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
let cmd;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
cmd = `open "${url}"`;
|
||||
} else if (platform === 'win32') {
|
||||
// On Windows, start command treats first quoted arg as window title
|
||||
// Use empty title "" before the URL to prevent this
|
||||
cmd = `start "" "${url}"`;
|
||||
} else {
|
||||
cmd = `xdg-open "${url}"`;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore browser open errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full HTML page from markdown
|
||||
*/
|
||||
function generateFullPage(filePath, assetsDir) {
|
||||
const { html, toc, frontmatter, title } = renderMarkdownFile(filePath);
|
||||
const tocHtml = renderTOCHtml(toc);
|
||||
const navSidebar = generateNavSidebar(filePath);
|
||||
const navFooter = generateNavFooter(filePath);
|
||||
const planInfo = detectPlan(filePath);
|
||||
const navContext = getNavigationContext(filePath);
|
||||
|
||||
// Read template
|
||||
const templatePath = path.join(assetsDir, 'template.html');
|
||||
let template = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Generate back button (links to parent directory browser)
|
||||
const parentDir = path.dirname(filePath);
|
||||
const backButton = `
|
||||
<a href="/browse?dir=${encodeURIComponent(parentDir)}" class="icon-btn back-btn" title="Back to folder">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</a>`;
|
||||
|
||||
// Generate header nav (prev/next) for plan files
|
||||
let headerNav = '';
|
||||
if (navContext.prev || navContext.next) {
|
||||
const prevBtn = navContext.prev && fs.existsSync(navContext.prev.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.prev.file)}" class="header-nav-btn prev" title="${navContext.prev.name}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<span>Prev</span>
|
||||
</a>`
|
||||
: '';
|
||||
const nextBtn = navContext.next && fs.existsSync(navContext.next.file)
|
||||
? `<a href="/view?file=${encodeURIComponent(navContext.next.file)}" class="header-nav-btn next" title="${navContext.next.name}">
|
||||
<span>Next</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</a>`
|
||||
: '';
|
||||
headerNav = `<div class="header-nav">${prevBtn}${nextBtn}</div>`;
|
||||
}
|
||||
|
||||
// Replace placeholders
|
||||
template = template
|
||||
.replace(/\{\{title\}\}/g, title)
|
||||
.replace('{{toc}}', tocHtml)
|
||||
.replace('{{nav-sidebar}}', navSidebar)
|
||||
.replace('{{nav-footer}}', navFooter)
|
||||
.replace('{{content}}', html)
|
||||
.replace('{{has-plan}}', planInfo.isPlan ? 'has-plan' : '')
|
||||
.replace('{{frontmatter}}', JSON.stringify(frontmatter || {}))
|
||||
.replace('{{back-button}}', backButton)
|
||||
.replace('{{header-nav}}', headerNav);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local network IP address for remote access
|
||||
* @returns {string|null} - Local IP or null if not found
|
||||
*/
|
||||
function getLocalIP() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
for (const iface of interfaces[name]) {
|
||||
// Skip internal (loopback) and non-IPv4 addresses
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL with query parameters (fixes path conflicts)
|
||||
* @returns {{url: string, networkUrl: string|null}} - Local and network URLs
|
||||
*/
|
||||
function buildUrl(host, port, type, filePath) {
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
const baseUrl = `http://${displayHost}:${port}`;
|
||||
|
||||
let urlPath = '';
|
||||
if (type === 'file') {
|
||||
urlPath = `/view?file=${encodeURIComponent(filePath)}`;
|
||||
} else if (type === 'directory') {
|
||||
urlPath = `/browse?dir=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
const url = baseUrl + urlPath;
|
||||
|
||||
// If binding to all interfaces, provide network URL for remote access
|
||||
let networkUrl = null;
|
||||
if (host === '0.0.0.0') {
|
||||
const localIP = getLocalIP();
|
||||
if (localIP) {
|
||||
networkUrl = `http://${localIP}:${port}${urlPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { url, networkUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const cwd = process.cwd();
|
||||
const assetsDir = path.join(__dirname, '..', 'assets');
|
||||
|
||||
// Handle --stop
|
||||
if (args.stop) {
|
||||
const instances = findRunningInstances();
|
||||
if (instances.length === 0) {
|
||||
console.log('No server running to stop');
|
||||
process.exit(0);
|
||||
}
|
||||
const stopped = stopAllServers();
|
||||
console.log(`Stopped ${stopped} server(s)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Determine input
|
||||
const input = args.dir || args.file;
|
||||
|
||||
// Validate input
|
||||
if (!input) {
|
||||
console.error('Error: --file or --dir argument required');
|
||||
console.error('Usage:');
|
||||
console.error(' node server.cjs --file <path.md> [--port 3456] [--open]');
|
||||
console.error(' node server.cjs --dir <path> [--port 3456] [--open] # Browse directory');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve input path - simple logic
|
||||
let resolved = resolveInput(input, cwd);
|
||||
|
||||
// If --dir was explicitly used, force directory mode
|
||||
if (args.dir && resolved.type === null) {
|
||||
const dirPath = path.isAbsolute(args.dir) ? args.dir : path.resolve(cwd, args.dir);
|
||||
if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
|
||||
resolved = { type: 'directory', path: dirPath };
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.type === null) {
|
||||
console.error(`Error: Invalid path: ${input}`);
|
||||
console.error('Path must be a file or directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Background mode - spawn child and exit (legacy mode for manual runs)
|
||||
// Skip if --foreground is set (for Claude Code background tasks)
|
||||
if (args.background && !args.foreground && !args.isChild) {
|
||||
const childArgs = ['--port', String(args.port), '--host', args.host, '--child'];
|
||||
if (resolved.type === 'file') {
|
||||
childArgs.unshift('--file', resolved.path);
|
||||
} else {
|
||||
childArgs.unshift('--dir', resolved.path);
|
||||
}
|
||||
if (args.open) childArgs.push('--open');
|
||||
|
||||
const child = spawn(process.execPath, [__filename, ...childArgs], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: cwd
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Wait briefly for child to start
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// Find the port the child is using
|
||||
const instances = findRunningInstances();
|
||||
const instance = instances.find(i => i.port >= args.port);
|
||||
const port = instance ? instance.port : args.port;
|
||||
|
||||
const { url, networkUrl } = buildUrl(args.host, port, resolved.type, resolved.path);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url,
|
||||
path: resolved.path,
|
||||
port,
|
||||
host: args.host,
|
||||
mode: resolved.type
|
||||
};
|
||||
if (networkUrl) result.networkUrl = networkUrl;
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Find available port
|
||||
const port = await findAvailablePort(args.port);
|
||||
if (port !== args.port) {
|
||||
console.error(`Port ${args.port} in use, using ${port}`);
|
||||
}
|
||||
|
||||
// Determine allowed directories for security
|
||||
const allowedDirs = [assetsDir, cwd];
|
||||
if (resolved.path) {
|
||||
const targetDir = resolved.type === 'file' ? path.dirname(resolved.path) : resolved.path;
|
||||
if (!allowedDirs.includes(targetDir)) {
|
||||
allowedDirs.push(targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Create server
|
||||
const server = createHttpServer({
|
||||
assetsDir,
|
||||
renderMarkdown: (fp) => generateFullPage(fp, assetsDir),
|
||||
allowedDirs
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(port, args.host, () => {
|
||||
const { url, networkUrl } = buildUrl(args.host, port, resolved.type, resolved.path);
|
||||
|
||||
// Write PID file
|
||||
writePidFile(port, process.pid);
|
||||
|
||||
// Setup shutdown handlers
|
||||
setupShutdownHandlers(port, () => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
// Output for CLI/command integration
|
||||
// In foreground mode (CC background task), always output JSON
|
||||
if (args.foreground || args.isChild || process.env.CLAUDE_COMMAND) {
|
||||
const result = {
|
||||
success: true,
|
||||
url,
|
||||
path: resolved.path,
|
||||
port,
|
||||
host: args.host,
|
||||
mode: resolved.type
|
||||
};
|
||||
if (networkUrl) result.networkUrl = networkUrl;
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(`\nMarkdown Novel Viewer`);
|
||||
console.log(`${'─'.repeat(40)}`);
|
||||
console.log(`URL: ${url}`);
|
||||
if (networkUrl) {
|
||||
console.log(`Network: ${networkUrl}`);
|
||||
}
|
||||
console.log(`Path: ${resolved.path}`);
|
||||
console.log(`Port: ${port}`);
|
||||
console.log(`Host: ${args.host}`);
|
||||
console.log(`Mode: ${resolved.type === 'file' ? 'File Viewer' : 'Directory Browser'}`);
|
||||
console.log(`\nPress Ctrl+C to stop\n`);
|
||||
}
|
||||
|
||||
// Open browser
|
||||
if (args.open) {
|
||||
openBrowser(url);
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`Server error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Run
|
||||
main().catch(err => {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
327
.opencode/skills/markdown-novel-viewer/scripts/tests/server.test.cjs
Executable file
327
.opencode/skills/markdown-novel-viewer/scripts/tests/server.test.cjs
Executable file
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Tests for markdown-novel-viewer
|
||||
* Run: node scripts/tests/server.test.cjs
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
const { isPortAvailable, findAvailablePort, DEFAULT_PORT } = require('../lib/port-finder.cjs');
|
||||
const { writePidFile, readPidFile, removePidFile, findRunningInstances } = require('../lib/process-mgr.cjs');
|
||||
const { getMimeType, MIME_TYPES, isPathSafe, sanitizeErrorMessage } = require('../lib/http-server.cjs');
|
||||
const { resolveImages, addHeadingIds, generateTOC, renderTOCHtml } = require('../lib/markdown-renderer.cjs');
|
||||
const { detectPlan, parsePlanTable, getNavigationContext, generateNavSidebar } = require('../lib/plan-navigator.cjs');
|
||||
|
||||
// Test utilities
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, message) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}: expected "${expected}", got "${actual}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertTrue(value, message) {
|
||||
if (!value) {
|
||||
throw new Error(`${message}: expected truthy value`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFalse(value, message) {
|
||||
if (value) {
|
||||
throw new Error(`${message}: expected falsy value`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(str, substr, message) {
|
||||
if (!str.includes(substr)) {
|
||||
throw new Error(`${message}: expected to include "${substr}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test suites
|
||||
console.log('\n--- Port Finder Tests ---');
|
||||
|
||||
test('DEFAULT_PORT is 3456', () => {
|
||||
assertEqual(DEFAULT_PORT, 3456, 'Default port');
|
||||
});
|
||||
|
||||
test('isPortAvailable returns boolean', () => {
|
||||
// Sync test - function exists
|
||||
assertTrue(typeof isPortAvailable === 'function', 'Should be function');
|
||||
});
|
||||
|
||||
test('findAvailablePort returns number', () => {
|
||||
// Sync test - actual async behavior tested in integration
|
||||
assertTrue(typeof findAvailablePort === 'function', 'Should be function');
|
||||
});
|
||||
|
||||
console.log('\n--- Process Manager Tests ---');
|
||||
|
||||
test('writePidFile and readPidFile work correctly', () => {
|
||||
const testPort = 9876;
|
||||
const testPid = 12345;
|
||||
|
||||
writePidFile(testPort, testPid);
|
||||
const readPid = readPidFile(testPort);
|
||||
assertEqual(readPid, testPid, 'PID should match');
|
||||
|
||||
removePidFile(testPort);
|
||||
const afterRemove = readPidFile(testPort);
|
||||
assertEqual(afterRemove, null, 'Should be null after remove');
|
||||
});
|
||||
|
||||
test('findRunningInstances returns array', () => {
|
||||
const instances = findRunningInstances();
|
||||
assertTrue(Array.isArray(instances), 'Should return array');
|
||||
});
|
||||
|
||||
console.log('\n--- HTTP Server Tests ---');
|
||||
|
||||
test('getMimeType returns correct types', () => {
|
||||
assertEqual(getMimeType('test.html'), 'text/html', 'HTML type');
|
||||
assertEqual(getMimeType('test.css'), 'text/css', 'CSS type');
|
||||
assertEqual(getMimeType('test.js'), 'application/javascript', 'JS type');
|
||||
assertEqual(getMimeType('test.png'), 'image/png', 'PNG type');
|
||||
assertEqual(getMimeType('test.jpg'), 'image/jpeg', 'JPG type');
|
||||
assertEqual(getMimeType('test.unknown'), 'application/octet-stream', 'Unknown type');
|
||||
});
|
||||
|
||||
test('MIME_TYPES has common extensions', () => {
|
||||
assertTrue(MIME_TYPES['.html'], 'Has .html');
|
||||
assertTrue(MIME_TYPES['.css'], 'Has .css');
|
||||
assertTrue(MIME_TYPES['.js'], 'Has .js');
|
||||
assertTrue(MIME_TYPES['.png'], 'Has .png');
|
||||
assertTrue(MIME_TYPES['.md'], 'Has .md');
|
||||
});
|
||||
|
||||
console.log('\n--- Security Tests ---');
|
||||
|
||||
test('isPathSafe blocks path traversal', () => {
|
||||
assertFalse(isPathSafe('/etc/../etc/passwd', ['/home']), 'Should block .. traversal');
|
||||
assertFalse(isPathSafe('/path\0/file', ['/path']), 'Should block null bytes');
|
||||
});
|
||||
|
||||
test('isPathSafe allows valid paths', () => {
|
||||
assertTrue(isPathSafe('/tmp/test.md', ['/tmp']), 'Should allow path in allowed dir');
|
||||
});
|
||||
|
||||
test('sanitizeErrorMessage removes paths', () => {
|
||||
const sanitized = sanitizeErrorMessage('Error: /etc/passwd not found');
|
||||
assertFalse(sanitized.includes('/etc/passwd'), 'Should not contain path');
|
||||
assertIncludes(sanitized, '[path]', 'Should replace with placeholder');
|
||||
});
|
||||
|
||||
console.log('\n--- Markdown Renderer Tests ---');
|
||||
|
||||
test('resolveImages converts relative paths', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertIncludes(resolved, '/file/', 'Should include /file/ route');
|
||||
// Path is URL-encoded; decode to verify base path is present
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/path', 'Should include base path');
|
||||
});
|
||||
|
||||
test('resolveImages preserves absolute URLs', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertEqual(resolved, md, 'Should preserve absolute URL');
|
||||
});
|
||||
|
||||
test('resolveImages handles reference-style definitions', () => {
|
||||
const md = '![Step 1 Initial]\n\n[Step 1 Initial]: ./screenshots/step1.png';
|
||||
const resolved = resolveImages(md, '/base/path');
|
||||
assertIncludes(resolved, '/file/', 'Should include /file/ route in ref definition');
|
||||
// Path is URL-encoded; decode to verify resolved path
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/path/screenshots/step1.png', 'Should resolve relative path');
|
||||
});
|
||||
|
||||
test('resolveImages handles reference-style with titles', () => {
|
||||
const md = '[logo]: ./images/logo.png "Company Logo"';
|
||||
const resolved = resolveImages(md, '/project');
|
||||
// Path is URL-encoded; decode to verify
|
||||
assertIncludes(decodeURIComponent(resolved), '/project/images/logo.png', 'Should resolve path with title');
|
||||
});
|
||||
|
||||
test('resolveImages handles inline images with titles', () => {
|
||||
const md = '';
|
||||
const resolved = resolveImages(md, '/base');
|
||||
// Path is URL-encoded; decode to verify
|
||||
assertIncludes(decodeURIComponent(resolved), '/base/image.png', 'Should resolve inline with title');
|
||||
});
|
||||
|
||||
test('addHeadingIds adds id attributes', () => {
|
||||
const html = '<h1>Test Heading</h1><h2>Another</h2>';
|
||||
const withIds = addHeadingIds(html);
|
||||
assertIncludes(withIds, 'id="test-heading"', 'Should add id to h1');
|
||||
assertIncludes(withIds, 'id="another"', 'Should add id to h2');
|
||||
});
|
||||
|
||||
test('addHeadingIds handles duplicates', () => {
|
||||
const html = '<h1>Test</h1><h2>Test</h2>';
|
||||
const withIds = addHeadingIds(html);
|
||||
assertIncludes(withIds, 'id="test"', 'Should have first id');
|
||||
assertIncludes(withIds, 'id="test-1"', 'Should have unique second id');
|
||||
});
|
||||
|
||||
test('generateTOC extracts headings', () => {
|
||||
const html = '<h1 id="one">One</h1><h2 id="two">Two</h2><h3 id="three">Three</h3>';
|
||||
const toc = generateTOC(html);
|
||||
assertEqual(toc.length, 3, 'Should find 3 headings');
|
||||
assertEqual(toc[0].level, 1, 'First should be h1');
|
||||
assertEqual(toc[0].id, 'one', 'First id should be "one"');
|
||||
});
|
||||
|
||||
test('renderTOCHtml generates list', () => {
|
||||
const toc = [{ level: 1, id: 'test', text: 'Test' }];
|
||||
const html = renderTOCHtml(toc);
|
||||
assertIncludes(html, '<ul', 'Should have ul');
|
||||
assertIncludes(html, 'href="#test"', 'Should have anchor');
|
||||
assertIncludes(html, 'Test', 'Should have text');
|
||||
});
|
||||
|
||||
test('renderTOCHtml handles empty array', () => {
|
||||
const html = renderTOCHtml([]);
|
||||
assertEqual(html, '', 'Should return empty string');
|
||||
});
|
||||
|
||||
console.log('\n--- Plan Navigator Tests ---');
|
||||
|
||||
// Create temp plan structure for testing
|
||||
const testPlanDir = '/tmp/test-novel-viewer-plan';
|
||||
const testPlanFile = path.join(testPlanDir, 'plan.md');
|
||||
const testPhaseFile = path.join(testPlanDir, 'phase-01-test.md');
|
||||
|
||||
function setupTestPlan() {
|
||||
if (!fs.existsSync(testPlanDir)) {
|
||||
fs.mkdirSync(testPlanDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(testPlanFile, `# Test Plan
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Test Phase](./phase-01-test.md) | Pending |
|
||||
`);
|
||||
|
||||
fs.writeFileSync(testPhaseFile, `# Phase 1: Test Phase
|
||||
|
||||
Content here.
|
||||
`);
|
||||
}
|
||||
|
||||
function cleanupTestPlan() {
|
||||
if (fs.existsSync(testPlanDir)) {
|
||||
fs.rmSync(testPlanDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
setupTestPlan();
|
||||
|
||||
test('detectPlan identifies plan directory', () => {
|
||||
const result = detectPlan(testPlanFile);
|
||||
assertTrue(result.isPlan, 'Should detect as plan');
|
||||
assertEqual(result.planDir, testPlanDir, 'Should have correct dir');
|
||||
assertTrue(result.phases.length >= 1, 'Should find phases');
|
||||
});
|
||||
|
||||
test('detectPlan returns false for non-plan', () => {
|
||||
const result = detectPlan('/tmp/random-file.md');
|
||||
assertFalse(result.isPlan, 'Should not be plan');
|
||||
});
|
||||
|
||||
test('parsePlanTable extracts phases', () => {
|
||||
const phases = parsePlanTable(testPlanFile);
|
||||
assertTrue(phases.length >= 1, 'Should find phases');
|
||||
assertEqual(phases[0].phase, 1, 'First phase number');
|
||||
assertEqual(phases[0].name, 'Test Phase', 'Phase name');
|
||||
assertEqual(phases[0].status, 'pending', 'Status should be lowercase');
|
||||
});
|
||||
|
||||
test('getNavigationContext returns correct structure', () => {
|
||||
const ctx = getNavigationContext(testPlanFile);
|
||||
assertTrue(ctx.planInfo.isPlan, 'Should be plan');
|
||||
assertTrue(ctx.allPhases.length >= 1, 'Should have phases');
|
||||
assertEqual(ctx.currentIndex, 0, 'Plan.md should be index 0');
|
||||
});
|
||||
|
||||
test('generateNavSidebar returns HTML', () => {
|
||||
const html = generateNavSidebar(testPlanFile);
|
||||
assertIncludes(html, '<nav', 'Should have nav element');
|
||||
assertIncludes(html, 'phase-list', 'Should have phase list');
|
||||
});
|
||||
|
||||
test('generateNavSidebar returns empty for non-plan', () => {
|
||||
const html = generateNavSidebar('/tmp/random.md');
|
||||
assertEqual(html, '', 'Should return empty string');
|
||||
});
|
||||
|
||||
test('detectPlan sorts alphanumeric phase files (1a before 1b before 2)', () => {
|
||||
const alphaDir = '/tmp/test-alpha-plan';
|
||||
fs.mkdirSync(alphaDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(alphaDir, 'plan.md'), '# Plan\n');
|
||||
['phase-02-core.md', 'phase-01b-config.md', 'phase-01a-setup.md'].forEach(f =>
|
||||
fs.writeFileSync(path.join(alphaDir, f), `# ${f}\n`));
|
||||
|
||||
const result = detectPlan(path.join(alphaDir, 'plan.md'));
|
||||
assertTrue(result.isPlan, 'Should be plan');
|
||||
assertEqual(result.phases.length, 3, 'Should find 3 phases');
|
||||
assertTrue(result.phases[0].endsWith('phase-01a-setup.md'), '1a first');
|
||||
assertTrue(result.phases[1].endsWith('phase-01b-config.md'), '1b second');
|
||||
assertTrue(result.phases[2].endsWith('phase-02-core.md'), '2 third');
|
||||
|
||||
fs.rmSync(alphaDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('generateNavSidebar uses flat list when <= 15 phases', () => {
|
||||
const smallDir = '/tmp/test-small-plan';
|
||||
fs.mkdirSync(smallDir, { recursive: true });
|
||||
// Create 3 phase files + plan.md (4 total — well under 15)
|
||||
const planContent = `# Plan
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [Alpha](./phase-01-alpha.md) | Pending |
|
||||
| 2 | [Beta](./phase-02-beta.md) | Pending |
|
||||
| 3 | [Gamma](./phase-03-gamma.md) | Pending |
|
||||
`;
|
||||
fs.writeFileSync(path.join(smallDir, 'plan.md'), planContent);
|
||||
['phase-01-alpha.md', 'phase-02-beta.md', 'phase-03-gamma.md'].forEach(f =>
|
||||
fs.writeFileSync(path.join(smallDir, f), `# ${f}\n`));
|
||||
|
||||
const html = generateNavSidebar(path.join(smallDir, 'plan.md'));
|
||||
assertIncludes(html, 'phase-list', 'Should use flat phase-list class');
|
||||
assertFalse(html.includes('phase-group'), 'Should NOT use accordion groups');
|
||||
|
||||
fs.rmSync(smallDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
cleanupTestPlan();
|
||||
|
||||
// Summary
|
||||
console.log('\n--- Test Results ---');
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Total: ${passed + failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\nAll tests passed!');
|
||||
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* 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));
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Tests for dashboard-renderer.cjs
|
||||
* XSS protection, progress ring, plan card generation, and dashboard rendering
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
renderDashboard,
|
||||
generatePlanCard,
|
||||
generateProgressRing,
|
||||
generateProgressBar,
|
||||
generateStatusCounts,
|
||||
generateEmptyState,
|
||||
generatePlansGrid,
|
||||
escapeHtml,
|
||||
formatDate
|
||||
} = require('../scripts/lib/dashboard-renderer.cjs');
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('should escape HTML special characters', () => {
|
||||
assert.strictEqual(escapeHtml('<script>alert("xss")</script>'), '<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
it('should handle ampersands', () => {
|
||||
assert.strictEqual(escapeHtml('Tom & Jerry'), 'Tom & Jerry');
|
||||
});
|
||||
|
||||
it('should handle single quotes', () => {
|
||||
assert.strictEqual(escapeHtml("it's"), 'it's');
|
||||
});
|
||||
|
||||
it('should handle double quotes', () => {
|
||||
assert.strictEqual(escapeHtml('He said "hello"'), 'He said "hello"');
|
||||
});
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
assert.strictEqual(escapeHtml(null), '');
|
||||
assert.strictEqual(escapeHtml(undefined), '');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
assert.strictEqual(escapeHtml(''), '');
|
||||
});
|
||||
|
||||
it('should escape multiple occurrences', () => {
|
||||
const result = escapeHtml('<div class="test">Hello & "goodbye"</div>');
|
||||
assert.strictEqual(result, '<div class="test">Hello & "goodbye"</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should format ISO date string', () => {
|
||||
const result = formatDate('2025-12-11T10:30:00Z');
|
||||
assert(result.includes('Dec'));
|
||||
assert(result.includes('11'));
|
||||
assert(result.includes('2025'));
|
||||
});
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
assert.strictEqual(formatDate(null), '');
|
||||
assert.strictEqual(formatDate(undefined), '');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
assert.strictEqual(formatDate(''), '');
|
||||
});
|
||||
|
||||
it('should format different dates correctly', () => {
|
||||
const result1 = formatDate('2025-01-01T00:00:00Z');
|
||||
const result2 = formatDate('2025-12-31T23:59:59Z');
|
||||
// Check for month indicator - could be "Jan" or "1" depending on locale
|
||||
assert(result1.length > 0, 'Date 1 should format');
|
||||
assert(result2.length > 0, 'Date 2 should format');
|
||||
assert(result1 !== result2, 'Different dates should format differently');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateProgressRing', () => {
|
||||
it('should generate SVG with correct progress percentage', () => {
|
||||
const svg = generateProgressRing(50);
|
||||
assert(svg.includes('50%'));
|
||||
assert(svg.includes('stroke-dasharray'));
|
||||
});
|
||||
|
||||
it('should generate valid SVG structure', () => {
|
||||
const svg = generateProgressRing(75);
|
||||
assert(svg.includes('<svg class="progress-ring"'));
|
||||
assert(svg.includes('<circle'));
|
||||
assert(svg.includes('<text'));
|
||||
});
|
||||
|
||||
it('should handle 0% progress', () => {
|
||||
const svg = generateProgressRing(0);
|
||||
assert(svg.includes('0%'));
|
||||
assert(svg.includes('0, 100'));
|
||||
});
|
||||
|
||||
it('should handle 100% progress', () => {
|
||||
const svg = generateProgressRing(100);
|
||||
assert(svg.includes('100%'));
|
||||
assert(svg.includes('100, 100'));
|
||||
});
|
||||
|
||||
it('should have aria-hidden for accessibility', () => {
|
||||
const svg = generateProgressRing(50);
|
||||
assert(svg.includes('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateProgressBar', () => {
|
||||
it('should generate progress bar with correct percentages', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('50.0%')); // completed
|
||||
assert(bar.includes('30.0%')); // in-progress
|
||||
assert(bar.includes('20.0%')); // pending
|
||||
});
|
||||
|
||||
it('should have accessibility attributes', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('role="progressbar"'));
|
||||
assert(bar.includes('aria-valuenow="5"'));
|
||||
assert(bar.includes('aria-valuemin="0"'));
|
||||
assert(bar.includes('aria-valuemax="10"'));
|
||||
});
|
||||
|
||||
it('should handle zero total (fallback)', () => {
|
||||
const bar = generateProgressBar({ total: 0, completed: 0, inProgress: 0, pending: 0 });
|
||||
assert(bar.includes('class="progress-bar"'));
|
||||
});
|
||||
|
||||
it('should create three segments with correct classes', () => {
|
||||
const bar = generateProgressBar({ total: 10, completed: 5, inProgress: 3, pending: 2 });
|
||||
assert(bar.includes('class="bar-segment completed"'));
|
||||
assert(bar.includes('class="bar-segment in-progress"'));
|
||||
assert(bar.includes('class="bar-segment pending"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateStatusCounts', () => {
|
||||
it('should generate status count HTML', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('3'));
|
||||
assert(html.includes('2'));
|
||||
assert(html.includes('1'));
|
||||
});
|
||||
|
||||
it('should have accessibility features', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('visually-hidden'));
|
||||
assert(html.includes('data-tooltip'));
|
||||
});
|
||||
|
||||
it('should have correct status classes', () => {
|
||||
const html = generateStatusCounts({ completed: 3, inProgress: 2, pending: 1 });
|
||||
assert(html.includes('status-count completed'));
|
||||
assert(html.includes('status-count in-progress'));
|
||||
assert(html.includes('status-count pending'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePlanCard', () => {
|
||||
it('should generate card HTML with plan data', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test Plan',
|
||||
status: 'in-progress',
|
||||
progress: 50,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test-plan',
|
||||
phases: { completed: 2, inProgress: 1, pending: 1, total: 4 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('Test Plan'));
|
||||
assert(card.includes('plan-001'));
|
||||
assert(card.includes('/plans/test-plan'));
|
||||
});
|
||||
|
||||
it('should escape HTML in plan name', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: '<script>alert("xss")</script>',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(!card.includes('<script>'));
|
||||
assert(card.includes('<script>'));
|
||||
});
|
||||
|
||||
it('should escape HTML in plan path', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '"><script>alert(1)</script><"',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(!card.includes('<script>'));
|
||||
assert(card.includes('"'));
|
||||
});
|
||||
|
||||
it('should set correct data-status attribute', () => {
|
||||
const planInProgress = generatePlanCard({
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'in-progress',
|
||||
progress: 50,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/test',
|
||||
phases: { completed: 0, inProgress: 1, pending: 0, total: 1 }
|
||||
});
|
||||
assert(planInProgress.includes('data-status="in-progress"'));
|
||||
});
|
||||
|
||||
it('should have accessible structure', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test Plan',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('<article'));
|
||||
assert(card.includes('<header'));
|
||||
assert(card.includes('<footer'));
|
||||
assert(card.includes('tabindex="0"'));
|
||||
});
|
||||
|
||||
it('should include time element with datetime attribute', () => {
|
||||
const plan = {
|
||||
id: 'plan-001',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
};
|
||||
const card = generatePlanCard(plan);
|
||||
assert(card.includes('<time class="plan-date" datetime='));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePlansGrid', () => {
|
||||
it('should generate cards for all plans', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Plan 1',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/1',
|
||||
phases: { completed: 1, inProgress: 0, pending: 0, total: 1 }
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Plan 2',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T09:00:00Z',
|
||||
path: '/plans/2',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const grid = generatePlansGrid(plans);
|
||||
assert(grid.includes('Plan 1'));
|
||||
assert(grid.includes('Plan 2'));
|
||||
});
|
||||
|
||||
it('should return empty string for empty plans', () => {
|
||||
assert.strictEqual(generatePlansGrid([]), '');
|
||||
assert.strictEqual(generatePlansGrid(null), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEmptyState', () => {
|
||||
it('should generate empty state HTML', () => {
|
||||
const html = generateEmptyState();
|
||||
assert(html.includes('No plans found'));
|
||||
assert(html.includes('class="empty-state"'));
|
||||
assert(html.includes('hidden'));
|
||||
});
|
||||
|
||||
it('should have accessibility features', () => {
|
||||
const html = generateEmptyState();
|
||||
assert(html.includes('aria-hidden="true"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDashboard', () => {
|
||||
it('should render dashboard with plans', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test Plan',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('Test Plan'));
|
||||
assert(html.includes('<!DOCTYPE html'));
|
||||
});
|
||||
|
||||
it('should embed plans JSON for client-side filtering', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('window.__plans'));
|
||||
assert(html.includes('"id":"p1"'));
|
||||
});
|
||||
|
||||
it('should set plan count', () => {
|
||||
const plans = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
name: `Plan ${i}`,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: `/plans/${i}`,
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}));
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('Showing <strong>5</strong>'));
|
||||
});
|
||||
|
||||
it('should use inline template as fallback', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
// Non-existent assetsDir forces fallback
|
||||
const html = renderDashboard(plans, { assetsDir: '/nonexistent/path' });
|
||||
assert(html.includes('<!DOCTYPE html'));
|
||||
assert(html.includes('Plans Dashboard'));
|
||||
});
|
||||
|
||||
it('should set has-plans class when plans exist', () => {
|
||||
const plans = [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Test',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '/plans/test',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
const html = renderDashboard(plans, { assetsDir: '/tmp' });
|
||||
assert(html.includes('plans-loaded'));
|
||||
});
|
||||
|
||||
it('should not set has-plans class when no plans', () => {
|
||||
const html = renderDashboard([], { assetsDir: '/tmp' });
|
||||
assert(!html.includes('plans-loaded'));
|
||||
});
|
||||
});
|
||||
|
||||
// Run tests
|
||||
const tests = [
|
||||
'escapeHtml',
|
||||
'formatDate',
|
||||
'generateProgressRing',
|
||||
'generateProgressBar',
|
||||
'generateStatusCounts',
|
||||
'generatePlanCard',
|
||||
'generatePlansGrid',
|
||||
'generateEmptyState',
|
||||
'renderDashboard'
|
||||
];
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('Dashboard Renderer Tests');
|
||||
console.log('='.repeat(60));
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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));
|
||||
51
.opencode/skills/markdown-novel-viewer/tests/run-tests.cjs
Executable file
51
.opencode/skills/markdown-novel-viewer/tests/run-tests.cjs
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test runner for dashboard tests
|
||||
* Executes all test suites and generates report
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load test framework first
|
||||
require('./test-framework.cjs');
|
||||
|
||||
const testsDir = __dirname;
|
||||
const testFiles = [
|
||||
'dashboard-renderer.test.cjs',
|
||||
'http-server.test.cjs',
|
||||
'dashboard-assets.test.cjs'
|
||||
];
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Dashboard Implementation Test Suite');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
// Load all test files
|
||||
let loadErrors = [];
|
||||
for (const testFile of testFiles) {
|
||||
const testPath = path.join(testsDir, testFile);
|
||||
|
||||
if (!fs.existsSync(testPath)) {
|
||||
loadErrors.push(`Test file not found: ${testFile}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
require(testPath);
|
||||
} catch (error) {
|
||||
loadErrors.push(`${testFile}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadErrors.length > 0) {
|
||||
console.error('\nErrors loading test files:');
|
||||
loadErrors.forEach(err => {
|
||||
console.error(` - ${err}`);
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
global.runAllTests();
|
||||
154
.opencode/skills/markdown-novel-viewer/tests/test-framework.cjs
Normal file
154
.opencode/skills/markdown-novel-viewer/tests/test-framework.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Simple test framework for Node.js (mocha-like)
|
||||
*/
|
||||
|
||||
global.testStats = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
suites: [],
|
||||
currentSuite: null
|
||||
};
|
||||
|
||||
class TestSuite {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.tests = [];
|
||||
this.beforeFn = null;
|
||||
this.afterFn = null;
|
||||
}
|
||||
|
||||
addTest(name, fn) {
|
||||
this.tests.push({ name, fn });
|
||||
}
|
||||
|
||||
setBefore(fn) {
|
||||
this.beforeFn = fn;
|
||||
}
|
||||
|
||||
setAfter(fn) {
|
||||
this.afterFn = fn;
|
||||
}
|
||||
|
||||
async run() {
|
||||
const results = {
|
||||
name: this.name,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
console.log(`\n ${this.name}`);
|
||||
|
||||
for (const test of this.tests) {
|
||||
try {
|
||||
if (this.beforeFn) {
|
||||
await this.beforeFn();
|
||||
}
|
||||
|
||||
await test.fn();
|
||||
|
||||
if (this.afterFn) {
|
||||
await this.afterFn();
|
||||
}
|
||||
|
||||
results.passed++;
|
||||
process.stdout.write('.');
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
test: test.name,
|
||||
error: error.message
|
||||
});
|
||||
process.stdout.write('F');
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
global.testSuites = {};
|
||||
|
||||
global.describe = function(name, fn) {
|
||||
const suite = new TestSuite(name);
|
||||
global.testStats.currentSuite = suite;
|
||||
global.testSuites[name] = suite;
|
||||
fn();
|
||||
};
|
||||
|
||||
global.it = function(name, fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('it() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.addTest(name, fn);
|
||||
};
|
||||
|
||||
global.before = function(fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('before() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.setBefore(fn);
|
||||
};
|
||||
|
||||
global.after = function(fn) {
|
||||
if (!global.testStats.currentSuite) {
|
||||
throw new Error('after() called outside describe()');
|
||||
}
|
||||
global.testStats.currentSuite.setAfter(fn);
|
||||
};
|
||||
|
||||
global.runAllTests = async function() {
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Running Test Suites');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
const suites = Object.values(global.testSuites);
|
||||
const results = [];
|
||||
|
||||
for (const suite of suites) {
|
||||
const result = await suite.run();
|
||||
results.push(result);
|
||||
global.testStats.passed += result.passed;
|
||||
global.testStats.failed += result.failed;
|
||||
global.testStats.total += result.passed + result.failed;
|
||||
}
|
||||
|
||||
printTestResults(results);
|
||||
|
||||
return global.testStats;
|
||||
};
|
||||
|
||||
function printTestResults(results) {
|
||||
console.log('\n\n' + '='.repeat(70));
|
||||
console.log('Test Results');
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
const status = result.failed > 0 ? '✗' : '✓';
|
||||
console.log(`${status} ${result.name}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
result.errors.forEach(err => {
|
||||
console.log(` ✗ ${err.test}`);
|
||||
console.log(` ${err.error}`);
|
||||
});
|
||||
}
|
||||
|
||||
totalPassed += result.passed;
|
||||
totalFailed += result.failed;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log(`Total: ${totalPassed + totalFailed} | Passed: ${totalPassed} | Failed: ${totalFailed}`);
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
if (totalFailed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TestSuite, runAllTests };
|
||||
90
.opencode/skills/markdown-novel-viewer/tests/verify-xss.cjs
Executable file
90
.opencode/skills/markdown-novel-viewer/tests/verify-xss.cjs
Executable file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* XSS Protection Verification
|
||||
*/
|
||||
|
||||
const renderer = require('../scripts/lib/dashboard-renderer.cjs');
|
||||
|
||||
console.log('\nXSS Protection Verification');
|
||||
console.log('='.repeat(70));
|
||||
|
||||
// Test 1: Image onerror payload
|
||||
const xssPayload1 = '<img src=x onerror="alert(1)">';
|
||||
const result1 = renderer.escapeHtml(xssPayload1);
|
||||
console.log('\nTest 1: Image onerror');
|
||||
console.log(`Input: ${xssPayload1}`);
|
||||
console.log(`Output: ${result1}`);
|
||||
const pass1 = !result1.includes('<img') && result1.includes('<');
|
||||
console.log(`Result: ${pass1 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 2: Script tag injection
|
||||
const xssPayload2 = '<script>alert("xss")</script>';
|
||||
const result2 = renderer.escapeHtml(xssPayload2);
|
||||
console.log('\nTest 2: Script tag');
|
||||
console.log(`Input: ${xssPayload2}`);
|
||||
console.log(`Output: ${result2}`);
|
||||
const pass2 = !result2.includes('<script') && result2.includes('<script');
|
||||
console.log(`Result: ${pass2 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 3: Full dashboard render with malicious plan
|
||||
const plans = [
|
||||
{
|
||||
id: 'xss-test',
|
||||
name: '<script>alert(1)</script>',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
lastModified: '2025-12-11T10:00:00Z',
|
||||
path: '"><script>alert(1)</script><"',
|
||||
phases: { completed: 0, inProgress: 0, pending: 1, total: 1 }
|
||||
}
|
||||
];
|
||||
|
||||
const html = renderer.renderDashboard(plans, {
|
||||
assetsDir: 'nonexistent' // Use fallback template
|
||||
});
|
||||
|
||||
console.log('\nTest 3: Full dashboard render');
|
||||
console.log(`Input: Malicious plan name and path`);
|
||||
// Check that the HTML contains escaped version in plan card
|
||||
// JSON will also contain escaped content but that's safe
|
||||
const cardSectionStart = html.indexOf('<article');
|
||||
const cardSectionEnd = html.indexOf('</article>');
|
||||
const cardContent = cardSectionStart !== -1 ? html.substring(cardSectionStart, cardSectionEnd) : '';
|
||||
const hasEscapedInCard = cardContent.includes('<script>');
|
||||
const pass3 = hasEscapedInCard;
|
||||
console.log(`Result: ${pass3 ? 'PASS' : 'FAIL'}`);
|
||||
|
||||
// Test 4: HTML structure
|
||||
console.log('\nTest 4: HTML structure validity');
|
||||
const hasDoctype = html.includes('<!DOCTYPE html');
|
||||
const hasHtmlClose = html.includes('</html>');
|
||||
const hasMain = html.includes('<main');
|
||||
const hasPlans = html.includes('{{plans-grid}}') || html.includes('class="plans-grid"');
|
||||
console.log(`DOCTYPE: ${hasDoctype ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Closing HTML: ${hasHtmlClose ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Main element: ${hasMain ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`Plans section: ${hasPlans ? 'PASS' : 'FAIL'}`);
|
||||
const pass4 = hasDoctype && hasHtmlClose && hasMain;
|
||||
|
||||
// Test 5: JSON embedded safely
|
||||
console.log('\nTest 5: JSON embedding');
|
||||
const jsonIncluded = html.includes('window.__plans');
|
||||
const jsonValid = html.includes('"id"') && html.includes('"name"');
|
||||
console.log(`JSON variable: ${jsonIncluded ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`JSON valid: ${jsonValid ? 'PASS' : 'FAIL'}`);
|
||||
const pass5 = jsonIncluded && jsonValid;
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('Summary');
|
||||
console.log('='.repeat(70));
|
||||
const allPass = pass1 && pass2 && pass3 && pass4 && pass5;
|
||||
console.log(`XSS Escaping: ${pass1 && pass2 ? 'PASS (2/2)' : 'FAIL'}`);
|
||||
console.log(`Dashboard Render: ${pass3 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`HTML Structure: ${pass4 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`JSON Embedding: ${pass5 ? 'PASS' : 'FAIL'}`);
|
||||
console.log(`\nOverall: ${allPass ? 'PASS' : 'FAIL'}`);
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
process.exit(allPass ? 0 : 1);
|
||||
Reference in New Issue
Block a user