|
|
1 mese fa | |
|---|---|---|
| public | 1 mese fa | |
| .gitignore | 1 mese fa | |
| CLAUDE.md | 1 mese fa | |
| README.md | 1 mese fa | |
| package.json | 1 mese fa | |
| server.js | 1 mese fa |
CJYDocs 是一个基于 Node.js + Express 的轻量级 Markdown 文档管理和渲染系统,采用前后端分离架构,通过解析 index.md 配置文件实现文档的层级化管理。系统提供优雅的阅读界面、全文搜索、TOC 导航等功能。
index.md 文件定义文档结构┌─────────────────────────────────────────────────────────────┐
│ 前端层 (Client) │
├─────────────────────────────────────────────────────────────┤
│ index.html │ reader.html │ style.css │
│ (首页) │ (阅读器) │ (统一样式) │
│ │ │ │
│ index.js │ reader.js │
│ - 加载分类列表 │ - 文档渲染 │
│ - 渲染分类卡片 │ - TOC 生成 │
│ │ - 搜索功能 │
│ │ - DOM 缓存 │
└─────────────────────────────────────────────────────────────┘
↕ REST API
┌─────────────────────────────────────────────────────────────┐
│ 后端层 (Server) │
├─────────────────────────────────────────────────────────────┤
│ server.js (Express) │
│ │
│ API 路由 │ 核心功能 │ 安全机制 │
│ - /api/structure │ - parseIndexMd │ - sanitizePath │
│ - /api/category/:id │ - 文档读取 │ - validatePath │
│ - /api/doc/:cat/:doc │ - 搜索引擎 │ - 输入验证 │
│ - /api/search/:cat │ │ │
└─────────────────────────────────────────────────────────────┘
↕ File I/O
┌─────────────────────────────────────────────────────────────┐
│ 数据层 (Storage) │
├─────────────────────────────────────────────────────────────┤
│ docs/ │
│ ├── index.md ← 文档结构配置文件 │
│ ├── 分类1/ │
│ │ ├── 文档A.md │
│ │ └── 文档B.md │
│ └── 分类2/ │
│ └── 文档C.md │
└─────────────────────────────────────────────────────────────┘
后端
前端
index.md 是整个文档系统的核心配置文件,采用简洁的文本格式定义文档的层级结构:
[分类名称]
1: 文档名.md
2: 文档名.md
2.1: 子文档名.md
2.2: 子文档名.md
3: 文档名.md
[另一个分类]
1: 文档名.md
格式说明:
[分类名] - 方括号包裹的分类标识,对应 docs/ 下的目录名编号: 文档名.md - 文档项,编号支持多级(如 1.1.1)核心解析函数 (server.js:201-236):
function parseIndexMd(content) {
const lines = content.split('\n');
const structure = [];
let currentCategory = null;
for (const line of lines) {
const trimmedLine = line.trim();
// 1. 匹配分类:[分类名]
const categoryMatch = trimmedLine.match(/^\[(.+?)\]$/);
if (categoryMatch) {
currentCategory = {
name: categoryMatch[1], // 分类名称
docs: [] // 该分类下的文档列表
};
structure.push(currentCategory);
continue;
}
// 2. 匹配文档项:编号: 文档名.md
const docMatch = trimmedLine.match(/^([\d.]+):\s*(.+?)\.md$/);
if (docMatch && currentCategory) {
const [, number, docName] = docMatch;
// 计算层级:通过点号数量确定(1=0级, 1.1=1级, 1.1.1=2级)
const level = (number.match(/\./g) || []).length;
currentCategory.docs.push({
number, // 原始编号(如 "1.2.3")
name: docName, // 文档名(不含 .md)
level, // 层级深度
fullName: `${docName}.md` // 完整文件名
});
}
}
return structure;
}
数据结构输出:
[
{
name: "分类1",
docs: [
{ number: "1", name: "文档A", level: 0, fullName: "文档A.md" },
{ number: "1.1", name: "文档A1", level: 1, fullName: "文档A1.md" },
{ number: "2", name: "文档B", level: 0, fullName: "文档B.md" }
]
},
{
name: "分类2",
docs: [
{ number: "1", name: "文档C", level: 0, fullName: "文档C.md" }
]
}
]
前端通过 level 值应用不同的缩进样式:
/* style.css */
.nav-item.level-0 { padding-left: 20px; }
.nav-item.level-1 { padding-left: 40px; }
.nav-item.level-2 { padding-left: 60px; }
页面加载
↓
DOM Ready 事件
↓
调用 loadDocumentStructure()
↓
GET /api/structure
↓
解析 JSON 响应
↓
renderCategories(structure)
↓
为每个分类创建卡片
↓
绑定"开始阅读"按钮点击事件
↓
跳转到 reader.html?category=xxx
// index.js:2-24
async function loadDocumentStructure() {
const loading = document.getElementById('loading');
const error = document.getElementById('error');
try {
const response = await fetch('/api/structure');
// 错误处理:检查 HTTP 状态
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `加载失败 (${response.status})`);
}
const structure = await response.json();
// 数据验证:确保返回的是有效数组
if (!Array.isArray(structure) || structure.length === 0) {
throw new Error('文档结构为空或格式错误');
}
loading.style.display = 'none';
renderCategories(structure);
} catch (err) {
console.error('Error loading structure:', err);
loading.style.display = 'none';
error.style.display = 'block';
error.textContent = `加载失败:${err.message || '请稍后重试'}`;
}
}
关键设计点:
┌─────────────────────────────────────────────────────────┐
│ 顶部搜索栏 │
│ [搜索框] [搜索按钮] [×] │
└─────────────────────────────────────────────────────────┘
┌──────────┬──────────────────────────┬──────────────────┐
│ │ │ │
│ 左侧导航 │ 主内容区 │ 右侧 TOC │
│ │ │ │
│ 分类标题 │ [Loading Spinner] │ 标题1 │
│ 🏠 🔍 │ │ 标题1.1 │
│ │ Markdown 渲染内容 │ 标题1.2 │
│ • 文档1 │ - 标题 │ 标题2 │
│ • 文档2 │ - 段落 │ 标题2.1 │
│ • 2.1 │ - 代码块 │ │
│ • 2.2 │ - 列表 │ [返回顶部] │
│ │ - ... │ │
│ │ │ │
│ [≡] │ │ [≡] │
└──────────┴──────────────────────────┴──────────────────┘
为减少重复的 DOM 查询,使用全局缓存对象:
// reader.js:5-13
const DOM = {
loading: null, // 加载动画
content: null, // 主内容区
docNav: null, // 左侧导航
toc: null, // 右侧目录
leftSidebar: null, // 左侧边栏
rightSidebar: null // 右侧边栏
};
// 使用时懒加载
if (!DOM.loading) DOM.loading = document.getElementById('loading');
性能对比:
问题:传统方式为每个导航项绑定独立的事件监听器,内存占用大
优化方案:使用事件委托,在父元素上监听点击事件
// reader.js:77-119
function renderDocNav(docs) {
const nav = DOM.docNav || document.getElementById('doc-nav');
if (!DOM.docNav) DOM.docNav = nav;
nav.innerHTML = '';
// 只创建 DOM,不绑定事件
docs.forEach(doc => {
const item = document.createElement('div');
item.className = `nav-item level-${doc.level}`;
item.dataset.docName = doc.name;
const link = document.createElement('a');
link.href = '#';
link.className = 'nav-link';
link.textContent = doc.name;
item.appendChild(link);
nav.appendChild(item);
});
// 事件委托:在父元素上监听
nav.removeEventListener('click', handleNavClick);
nav.addEventListener('click', handleNavClick);
}
// 统一的点击处理函数
function handleNavClick(e) {
if (e.target.classList.contains('nav-link')) {
e.preventDefault();
const item = e.target.parentElement;
const docName = item.dataset.docName;
if (docName) {
loadDocument(docName);
updateURL(currentCategory, docName);
// 更新活动状态
const navItems = DOM.docNav.querySelectorAll('.nav-item');
navItems.forEach(el =>
el.classList.toggle('active', el === item)
);
}
}
}
优化效果:
// reader.js:122-174
async function loadDocument(docName, scrollToText = null) {
// 使用 DOM 缓存
if (!DOM.loading) DOM.loading = document.getElementById('loading');
if (!DOM.content) DOM.content = document.getElementById('markdown-content');
DOM.loading.style.display = 'flex';
DOM.content.innerHTML = '';
try {
// 1. 获取文档内容
const response = await fetch(
`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`
);
// 2. 错误处理
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `加载失败 (${response.status})`);
}
const data = await response.json();
// 3. 数据验证
if (!data.content || typeof data.content !== 'string') {
throw new Error('文档内容格式错误');
}
currentDoc = docName;
// 4. Markdown 渲染
const html = marked.parse(data.content);
DOM.content.innerHTML = html;
// 5. 生成 TOC
generateTOC();
// 6. 更新导航状态
if (DOM.docNav) {
const navItems = DOM.docNav.querySelectorAll('.nav-item');
navItems.forEach(el =>
el.classList.toggle('active', el.dataset.docName === docName)
);
}
DOM.loading.style.display = 'none';
// 7. 搜索结果定位(如果有)
if (scrollToText) {
setTimeout(() => {
scrollToSearchMatch(scrollToText);
}, 100);
}
} catch (err) {
console.error('Error loading document:', err);
DOM.loading.style.display = 'none';
showError(`加载文档失败:${err.message || '请稍后重试'}`);
}
}
实现原理:遍历渲染后的 HTML,提取所有标题元素
// reader.js:176-221
function generateTOC() {
if (!DOM.toc) DOM.toc = document.getElementById('toc');
// 1. 查询所有标题元素
const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
DOM.toc.innerHTML = '';
if (headings.length === 0) {
DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
return;
}
// 2. 创建标题映射(用于快速查找)
const headingMap = new Map();
headings.forEach((heading, index) => {
// 给标题添加唯一 ID
if (!heading.id) {
heading.id = `heading-${index}`;
}
const level = parseInt(heading.tagName.substring(1));
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.className = `toc-link toc-level-${level}`;
link.textContent = heading.textContent;
headingMap.set(heading.id, heading);
DOM.toc.appendChild(link);
});
// 3. 使用事件委托
DOM.toc.removeEventListener('click', handleTocClick);
DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
// 4. 设置滚动监听
setupScrollSpy(headings);
}
使用 IntersectionObserver API 实现高性能的滚动监听:
// reader.js:223-247
function setupScrollSpy(headings) {
let activeLink = null;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
if (link && link !== activeLink) {
// 只在需要时更新,减少 DOM 操作
if (activeLink) activeLink.classList.remove('active');
link.classList.add('active');
activeLink = link;
}
}
});
}, {
rootMargin: '-100px 0px -80% 0px', // 视口顶部 100px 处触发
threshold: 0
});
headings.forEach(heading => observer.observe(heading));
}
优化要点:
activeLink 缓存当前激活链接,避免重复查询IntersectionObserver 比 scroll 事件性能更好用户输入搜索词
↓
防抖 (500ms)
↓
GET /api/search/:category?q=xxx¤tDoc=yyy
↓
服务端搜索所有 .md 文件
↓
返回结果:{ currentDoc: [...], otherDocs: [...] }
↓
前端渲染分组结果
↓
点击结果 → 跳转到文档 + 精确定位
// server.js:112-198
app.get('/api/search/:category', async (req, res) => {
try {
const category = sanitizePath(req.params.category);
const currentDoc = sanitizePath(req.query.currentDoc);
const { q } = req.query;
// 安全验证
if (!category) {
return res.status(400).json({ error: '无效的分类名称' });
}
if (!q || q.trim().length === 0) {
return res.json({ currentDoc: [], otherDocs: [] });
}
const query = q.toLowerCase();
const docsDir = path.join(__dirname, 'docs');
const categoryPath = path.join(docsDir, category);
// 路径验证
if (!validatePath(categoryPath, docsDir)) {
return res.status(403).json({ error: '拒绝访问:无效的路径' });
}
// 读取分类下的所有文档
const files = await fs.readdir(categoryPath);
const mdFiles = files.filter(file => file.endsWith('.md'));
const currentDocResults = [];
const otherDocsResults = [];
// 搜索每个文档
for (const file of mdFiles) {
const docName = file.replace('.md', '');
const filePath = path.join(categoryPath, file);
const content = await fs.readFile(filePath, 'utf-8');
// 搜索匹配的行
const lines = content.split('\n');
const matches = [];
lines.forEach((line, index) => {
if (line.toLowerCase().includes(query)) {
// 生成上下文片段 (前后各 50 个字符)
const queryIndex = line.toLowerCase().indexOf(query);
const startIndex = Math.max(0, queryIndex - 50);
const endIndex = Math.min(line.length, queryIndex + query.length + 50);
let snippet = line.substring(startIndex, endIndex);
if (startIndex > 0) snippet = '...' + snippet;
if (endIndex < line.length) snippet = snippet + '...';
matches.push({
line: index + 1,
snippet: snippet,
fullLine: line
});
}
});
if (matches.length > 0) {
const result = {
docName,
matchCount: matches.length,
matches: matches.slice(0, 10) // 限制每个文档最多返回 10 条
};
// 区分当前文档和其他文档
if (docName === currentDoc) {
currentDocResults.push(result);
} else {
otherDocsResults.push(result);
}
}
}
res.json({
query: q,
currentDoc: currentDocResults,
otherDocs: otherDocsResults
});
} catch (error) {
res.status(500).json({ error: '搜索失败', details: error.message });
}
});
搜索特性:
防抖优化:
// reader.js:393-397
let searchTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
// 500ms 防抖,减少 API 请求
searchTimeout = setTimeout(performSearch, 500);
});
搜索历史持久化:
// reader.js:263-275
const SEARCH_HISTORY_KEY = 'mdbook_search_history';
// 保存搜索历史(按分类)
const saveSearchHistory = (query) => {
if (!query || query.trim().length < 2) return;
try {
let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
history[currentCategory] = query; // 每个分类独立保存
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
} catch (err) {
console.error('Failed to save search history:', err);
}
};
精确定位:
使用 TreeWalker API 遍历文本节点,找到匹配的内容并滚动到视图中心:
// reader.js:541-595
function scrollToSearchMatch(fullLine) {
const content = DOM.content;
const searchText = fullLine.trim();
if (!searchText) return;
// 使用 TreeWalker 遍历所有文本节点
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_TEXT,
null
);
let found = false;
let node;
while (node = walker.nextNode()) {
if (node.textContent.includes(searchText)) {
const parentElement = node.parentElement;
// 滚动到元素中心
parentElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
// 临时高亮(2秒后消失)
parentElement.classList.add('temp-highlight');
setTimeout(() => {
parentElement.classList.remove('temp-highlight');
}, 2000);
found = true;
break;
}
}
// 如果精确匹配失败,尝试部分匹配
if (!found) {
const elements = content.querySelectorAll(
'p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code'
);
for (const element of elements) {
const text = element.textContent.trim();
// 匹配前 50 个字符
if (text.length > 10 &&
searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
element.classList.add('temp-highlight');
setTimeout(() => {
element.classList.remove('temp-highlight');
}, 2000);
break;
}
}
}
}
威胁场景:恶意用户通过 ../ 等方式访问系统文件
攻击示例:
GET /api/doc/../../etc/passwd
GET /api/doc/test/../../config.json
防护措施:
// server.js:9-29
// 1. 路径清理函数
function sanitizePath(input) {
if (!input || typeof input !== 'string') {
return '';
}
return input
.replace(/\.\./g, '') // 移除 ../
.replace(/^[\/\\]+/, '') // 移除开头的 / \
.replace(/[\/\\]/g, '') // 移除所有 / \
.replace(/[^a-zA-Z0-9\-_.]/g, ''); // 只保留安全字符
}
// 2. 路径范围验证
function validatePath(fullPath, baseDir) {
const resolvedPath = path.resolve(fullPath);
const resolvedBaseDir = path.resolve(baseDir);
// 确保解析后的路径在基础目录内
return resolvedPath.startsWith(resolvedBaseDir);
}
// 3. 应用到所有 API
app.get('/api/doc/:category/:docName', async (req, res) => {
const category = sanitizePath(req.params.category);
const docName = sanitizePath(req.params.docName);
if (!category || !docName) {
return res.status(400).json({ error: '无效的分类或文档名称' });
}
const docsDir = path.join(__dirname, 'docs');
const docPath = path.join(docsDir, category, `${docName}.md`);
// 验证路径
if (!validatePath(docPath, docsDir)) {
return res.status(403).json({ error: '拒绝访问:无效的路径' });
}
// 安全地读取文件
const content = await fs.readFile(docPath, 'utf-8');
res.json({ content, category, docName });
});
所有用户输入都经过验证和清理:
// 验证搜索查询
if (!q || q.trim().length === 0) {
return res.json({ currentDoc: [], otherDocs: [] });
}
// 验证数据结构
if (!data.content || typeof data.content !== 'string') {
throw new Error('文档内容格式错误');
}
// 验证数组
if (!Array.isArray(structure) || structure.length === 0) {
throw new Error('文档结构为空或格式错误');
}
cjydocs/
├── docs/ # 文档存储目录
│ ├── index.md # 文档结构配置文件
│ ├── 分类1/
│ │ ├── 文档A.md
│ │ └── 文档B.md
│ └── 分类2/
│ └── 文档C.md
│
├── public/ # 前端静态资源
│ ├── css/
│ │ └── style.css # 统一样式表 (24KB)
│ ├── js/
│ │ ├── index.js # 首页逻辑 (1.5KB)
│ │ └── reader.js # 阅读器逻辑 (15KB)
│ ├── index.html # 首页
│ └── reader.html # 阅读器页面
│
├── server.js # Express 服务器 (5KB)
├── package.json # 项目依赖
└── README.md # 本文档
npm install
npm start
# 或
node server.js
服务器将运行在 http://localhost:3000
编辑 docs/index.md 定义文档结构:
[我的分类]
1: 文档1.md
2: 文档2.md
2.1: 子文档2-1.md
[另一个分类]
1: 文档3.md
在对应的目录下创建 Markdown 文件:
docs/
├── 我的分类/
│ ├── 文档1.md
│ ├── 文档2.md
│ └── 子文档2-1.md
└── 另一个分类/
└── 文档3.md
GET /api/structure
响应示例:
[
{
"name": "分类1",
"docs": [
{
"number": "1",
"name": "文档A",
"level": 0,
"fullName": "文档A.md"
},
{
"number": "1.1",
"name": "文档A1",
"level": 1,
"fullName": "文档A1.md"
}
]
}
]
GET /api/category/:category
参数:
category - 分类名称(经过 sanitizePath 清理)响应示例:
{
"name": "分类1",
"docs": [...]
}
GET /api/doc/:category/:docName
参数:
category - 分类名称docName - 文档名称(不含 .md 后缀)响应示例:
{
"content": "# 文档标题\n\n文档内容...",
"category": "分类1",
"docName": "文档A"
}
GET /api/search/:category?q=关键词¤tDoc=当前文档
参数:
category - 分类名称q - 搜索关键词currentDoc - 当前文档名称(用于区分结果)响应示例:
{
"query": "关键词",
"currentDoc": [
{
"docName": "文档A",
"matchCount": 3,
"matches": [
{
"line": 15,
"snippet": "...匹配的内容片段...",
"fullLine": "完整的行内容"
}
]
}
],
"otherDocs": [...]
}
server.js 中添加新的路由reader.js 或 index.js 中实现逻辑style.css 中添加样式