Add gitignore and initial project files

This commit is contained in:
2026-05-29 12:46:34 +08:00
commit f9e323f70a
20 changed files with 3216 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const { program } = require('commander');
const path = require('path');
const http = require('http');
const net = require('net');
const { createServer } = require('./server');
const { createWatcher } = require('./watcher');
/**
* Check if a port is available
*/
function isPortAvailable(port, host) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, host);
});
}
/**
* Find an available port starting from the given port
*/
async function findAvailablePort(startPort, host) {
let port = startPort;
while (!(await isPortAvailable(port, host))) {
port++;
if (port > startPort + 100) {
throw new Error('No available port found in range ' + startPort + '-' + (startPort + 100));
}
}
return port;
}
async function main() {
program
.name('webook')
.description('A CLI tool to preview markdown notes in a web browser')
.argument('[dir]', 'Root directory of your markdown notes', '.')
.option('-p, --port <number>', 'Port to listen on (auto-detect if not specified)', '0')
.option('-h, --host <address>', 'Host address to bind', '127.0.0.1')
.option('-t, --title <title>', 'Site title (defaults to directory name)')
.option('--theme <path>', 'Custom CSS theme file path')
.option('--open', 'Automatically open in browser', false)
.version(require('../package.json').version)
.parse(process.argv);
const args = program.args;
const opts = program.opts();
// Resolve directory
const dir = path.resolve(args[0] || '.');
// Validate directory
const fs = require('fs');
if (!fs.existsSync(dir)) {
console.error(`Error: Directory not found: ${dir}`);
process.exit(1);
}
if (!fs.statSync(dir).isDirectory()) {
console.error(`Error: Not a directory: ${dir}`);
process.exit(1);
}
// Determine title
const title = opts.title || path.basename(dir);
// Determine port
let port = parseInt(opts.port, 10);
if (!port || isNaN(port)) {
port = 8000;
}
port = await findAvailablePort(port, opts.host);
// Theme
const theme = opts.theme || null;
// Create Express app
const app = createServer({ dir, title, theme, port, host: opts.host });
// Create HTTP server
const server = http.createServer(app);
// Create file watcher
const watcher = createWatcher(server, dir);
// Start server
server.listen(port, opts.host, () => {
const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${port}`;
console.log('');
console.log(' 📖 webook is running!');
console.log(` 📂 Notes directory: ${dir}`);
console.log(` 🌐 Open: ${url}`);
console.log(` 🔄 Live reload: enabled`);
console.log('');
console.log(' Press Ctrl+C to stop');
console.log('');
// Auto open browser
if (opts.open) {
const { exec } = require('child_process');
const platform = process.platform;
const cmd = platform === 'darwin' ? 'open' :
platform === 'win32' ? 'start' : 'xdg-open';
exec(`${cmd} ${url}`);
}
});
// Graceful shutdown
function shutdown() {
console.log('\n Shutting down...');
watcher.close();
server.close();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
// Auto-execute when run directly (not required by another module)
if (!module.parent) {
main().catch((err) => {
console.error('Fatal error:', err.message);
process.exit(1);
});
}
module.exports = { main };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const MarkdownIt = require('markdown-it');
const hljs = require('highlight.js');
const path = require('path');
const fs = require('fs');
/**
* Create a markdown renderer instance
*/
function createRenderer(notesDir) {
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
breaks: false,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (__) {}
}
try {
return '<pre class="hljs"><code>' +
hljs.highlightAuto(str).value +
'</code></pre>';
} catch (__) {}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});
// Custom image renderer: rewrite local paths to /_notes/ URLs
const defaultImageRender = md.renderer.rules.image || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const src = token.attrGet('src');
if (src && !/^https?:\/\//i.test(src) && !src.startsWith('/') && !src.startsWith('data:')) {
const fileDir = env.filePath ? path.dirname(env.filePath) : '';
const absSrc = path.resolve(notesDir, fileDir, src);
if (fs.existsSync(absSrc)) {
const relSrc = path.relative(notesDir, absSrc).split(path.sep).join('/');
token.attrSet('src', '/_notes/' + relSrc);
}
}
return defaultImageRender(tokens, idx, options, env, self);
};
return {
/**
* Render markdown content to HTML
*/
render(content, env = {}) {
return md.render(content, env);
},
/**
* Get markdown-it instance (for direct use)
*/
getInstance() {
return md;
}
};
}
module.exports = { createRenderer };
+112
View File
@@ -0,0 +1,112 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* Extract title from markdown content (first h1 heading)
*/
function extractTitle(content, filename) {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : filename.replace(/\.(md|markdown)$/i, '');
}
/**
* Count words: Chinese characters + English words
*/
function countWords(content) {
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
return chineseChars + englishWords;
}
function normalizeSortKey(name) {
const volumeMatch = name.match(/^(?:第)?卷\s*([一二三四五六七八九十百千0-9]+)$/) || name.match(/^卷\s*([一二三四五六七八九十百千0-9]+)$/);
const chapterMatch = name.match(/^第\s*([一二三四五六七八九十百千0-9]+)\s*[章节篇卷]$/);
const numericMatch = name.match(/(\d+)/);
const toNumber = (value) => {
if (/^\d+$/.test(value)) return parseInt(value, 10);
const map = { : 1, : 2, : 2, : 3, : 4, : 5, : 6, : 7, : 8, : 9, : 10, : 100, : 1000 };
if (value.length === 1) return map[value] || Number.MAX_SAFE_INTEGER;
if (value === '十') return 10;
const digits = value.split('');
if (digits.length === 2 && digits[0] === '十') return 10 + (map[digits[1]] || 0);
if (digits.length === 2 && digits[1] === '十') return (map[digits[0]] || 0) * 10;
return value.split('').reduce((sum, ch) => sum + (map[ch] || 0), 0) || Number.MAX_SAFE_INTEGER;
};
if (volumeMatch) return { group: 0, value: toNumber(volumeMatch[1]), name };
if (chapterMatch) return { group: 1, value: toNumber(chapterMatch[1]), name };
if (numericMatch) return { group: 2, value: parseInt(numericMatch[1], 10), name };
return { group: 3, value: name.toLocaleLowerCase('zh-CN'), name };
}
function compareNames(a, b) {
const ak = normalizeSortKey(a.name);
const bk = normalizeSortKey(b.name);
if (ak.group !== bk.group) return ak.group - bk.group;
if (ak.value !== bk.value) return ak.value < bk.value ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
}
/**
* Recursively scan directory for markdown files
*/
function scanDirectory(rootPath, relativePath = '') {
const fullPath = path.join(rootPath, relativePath);
let entries;
try {
entries = fs.readdirSync(fullPath, { withFileTypes: true });
} catch (err) {
return [];
}
const result = [];
for (const entry of entries) {
// Skip hidden files/dirs and node_modules
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
const rel = relativePath ? path.posix.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
const children = scanDirectory(rootPath, rel);
if (children.length > 0) {
result.push({
name: entry.name,
path: rel,
type: 'dir',
children
});
}
} else if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) {
try {
const content = fs.readFileSync(path.join(fullPath, entry.name), 'utf-8');
const stats = fs.statSync(path.join(fullPath, entry.name));
result.push({
name: entry.name,
path: rel,
type: 'file',
meta: {
title: extractTitle(content, entry.name),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
wordCount: countWords(content)
}
});
} catch (err) {
// Skip files that can't be read
}
}
}
// Sort: directories first (alphabetical), then files (alphabetical)
result.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return compareNames(a, b);
});
return result;
}
module.exports = { scanDirectory };
+235
View File
@@ -0,0 +1,235 @@
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const { scanDirectory } = require('./scanner');
const { createRenderer } = require('./renderer');
/**
* Generate the HTML page template
*/
function renderPage({ title, nav, theme, defaultFile, initialContent }) {
const navJson = JSON.stringify(nav).replace(/</g, '\\u003c');
const isTocTheme = theme === 'toc';
const themeLink = theme && !isTocTheme
? `<link rel="stylesheet" href="/_notes/${theme.replace(/^\/+/, '')}">`
: '';
const initialContentJson = initialContent
? JSON.stringify(initialContent).replace(/</g, '\\u003c')
: 'null';
return `<!DOCTYPE html>
<html lang="zh-CN" data-theme="light" data-doc-theme="${isTocTheme ? 'toc' : 'default'}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<link rel="stylesheet" href="/_webook/style.css">
<link rel="stylesheet" href="/_webook/highlight-light.css" class="theme-highlight">
${themeLink}
</head>
<body>
<div class="app" id="app">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a class="sidebar-brand" href="/" aria-label="返回首页">
<span class="sidebar-brand-mark">W</span>
<span class="sidebar-brand-text">
<strong>${title}</strong>
<small>文档浏览器</small>
</span>
</a>
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索文件..." autocomplete="off">
</div>
</div>
<nav class="sidebar-nav" id="sidebarNav"></nav>
</aside>
<main class="content">
<div class="content-toolbar">
<div class="toolbar-breadcrumb">
<span class="toolbar-kicker">Docs</span>
<span class="toolbar-divider">/</span>
<span class="toolbar-title">${title}</span>
</div>
<div class="toolbar-actions">
<button class="btn-icon" id="btnToggleSidebar" title="切换侧边栏">☰</button>
<button class="btn-icon" id="btnToggleTheme" title="切换主题">🌙</button>
</div>
</div>
<div class="content-body" id="contentBody">
<div class="empty-state" id="emptyState">
<div class="empty-card">
<div class="empty-icon">📘</div>
<h2>选择左侧文件开始阅读</h2>
<p>支持 Markdown、代码块、图片和基础排版。</p>
</div>
</div>
<article class="markdown-body" id="markdownContent" style="display:none;">
<header class="article-header" id="articleHeader">
<h1 class="article-title" id="articleTitle"></h1>
<div class="article-meta" id="articleMeta"></div>
</header>
<div class="article-layout">
<div class="article-body" id="articleBody"></div>
<aside class="toc-panel" id="tocPanel" aria-label="目录导航">
<div class="toc-card">
<div class="toc-title">目录</div>
<nav class="toc-nav" id="tocNav"></nav>
</div>
</aside>
</div>
</article>
</div>
</main>
</div>
<div class="loading-bar" id="loadingBar"></div>
<div class="mobile-nav-btn" id="mobileNavBtn">☰</div>
<script>
window.__WEBOOK__ = {
nav: ${navJson},
defaultFile: ${JSON.stringify(defaultFile || null)},
initialContent: ${initialContentJson}
};
</script>
<script src="/_webook/app.js"></script>
<script src="/_webook/live-reload.js"></script>
</body>
</html>`;
}
/**
* Create and start the webook server
*/
function createServer(opts) {
const {
dir,
title,
theme,
port,
host
} = opts;
const app = express();
const renderer = createRenderer(dir);
// Serve webook public assets (CSS, JS)
app.use('/_webook', express.static(path.join(__dirname, '..', 'public')));
// Serve notes directory as static files (for images, attachments, custom theme CSS)
app.use('/_notes', express.static(dir));
// API: Get navigation tree
app.get('/api/nav', (req, res) => {
try {
const nav = scanDirectory(dir);
res.json({ success: true, data: nav });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// API: Get rendered file content
app.get('/api/file', (req, res) => {
try {
const filePath = req.query.path;
if (!filePath) {
return res.status(400).json({ success: false, error: 'Missing path parameter' });
}
// Security: prevent directory traversal
const safePath = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
const fullPath = path.join(dir, safePath);
if (!fullPath.startsWith(path.resolve(dir))) {
return res.status(403).json({ success: false, error: 'Access denied' });
}
if (!fs.existsSync(fullPath)) {
return res.status(404).json({ success: false, error: 'File not found' });
}
if (!/\.(md|markdown)$/i.test(fullPath)) {
return res.status(400).json({ success: false, error: 'Not a markdown file' });
}
const content = fs.readFileSync(fullPath, 'utf-8');
const stats = fs.statSync(fullPath);
// Render markdown to HTML
const env = { filePath: safePath };
const html = renderer.render(content, env);
// Extract metadata
const match = content.match(/^#\s+(.+)$/m);
const title = match ? match[1].trim() : path.basename(filePath, path.extname(filePath));
// Count words
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
res.json({
success: true,
data: {
title,
html,
meta: {
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
wordCount: chineseChars + englishWords,
fileName: path.basename(filePath)
}
}
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
// Main page
app.get('/', (req, res) => {
try {
const nav = scanDirectory(dir);
// Try to find a README.md or index.md as default
const defaultFiles = ['README.md', 'readme.md', 'index.md', 'INDEX.md'];
let initialContent = null;
let defaultFile = null;
for (const df of defaultFiles) {
const dfPath = path.join(dir, df);
if (fs.existsSync(dfPath)) {
defaultFile = df;
const content = fs.readFileSync(dfPath, 'utf-8');
const env = { filePath: df };
const html = renderer.render(content, env);
const stats = fs.statSync(dfPath);
const match = content.match(/^#\s+(.+)$/m);
const dfTitle = match ? match[1].trim() : df.replace(/\.md$/i, '');
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
initialContent = {
title: dfTitle,
html,
meta: {
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
wordCount: chineseChars + englishWords,
fileName: df
}
};
break;
}
}
res.send(renderPage({ title, nav, theme, defaultFile, initialContent }));
} catch (err) {
res.status(500).send(`<h1>Error</h1><pre>${err.message}</pre>`);
}
});
return app;
}
module.exports = { createServer };
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const chokidar = require('chokidar');
const { WebSocketServer } = require('ws');
/**
* Create file watcher and WebSocket server for live reload
*/
function createWatcher(httpServer, dir) {
const wss = new WebSocketServer({ server: httpServer, path: '/_ws' });
// Track connected clients
const clients = new Set();
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => {
clients.delete(ws);
});
ws.on('error', () => {
clients.delete(ws);
});
});
// Broadcast reload message to all clients
function broadcast(event) {
const msg = JSON.stringify(event);
for (const ws of clients) {
try {
if (ws.readyState === 1) { // WebSocket.OPEN
ws.send(msg);
}
} catch (e) {
// ignore
}
}
}
// Watch for file changes
const watcher = chokidar.watch(dir, {
ignored: [
/(^|[\/\\])\../, // dotfiles
/node_modules/,
/\.git/
],
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 300,
pollInterval: 100
}
});
let debounceTimer;
watcher.on('all', (event, filePath) => {
// Only trigger reload for markdown files and assets
if (/\.(md|markdown|css|js|png|jpg|jpeg|gif|svg|webp|ico)$/i.test(filePath)) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
broadcast({ type: 'reload', file: filePath, event });
}, 200);
}
});
watcher.on('error', (err) => {
console.error('Watcher error:', err.message);
});
return {
watcher,
wss,
close() {
watcher.close();
wss.close();
}
};
}
module.exports = { createWatcher };