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