- Auto-discovers .md files from configurable content roots - On-the-fly markdown → HTML rendering (marked + highlight.js) - Directory tree navigation sidebar - Full-text search across all documents - Dark mode, responsive design, Ctrl+K quick search - Frontmatter (YAML) support via gray-matter - Configurable via config.json Co-Authored-By: Claude <noreply@anthropic.com>
191 lines
5.8 KiB
JavaScript
191 lines
5.8 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { marked } = require('marked');
|
|
const hljs = require('highlight.js');
|
|
const matter = require('gray-matter');
|
|
|
|
const app = express();
|
|
const CONFIG = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf-8'));
|
|
|
|
// Configure marked with highlight.js
|
|
marked.setOptions({
|
|
highlight: (code, lang) => {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
return hljs.highlight(code, { language: lang }).value;
|
|
}
|
|
return hljs.highlightAuto(code).value;
|
|
},
|
|
breaks: true,
|
|
gfm: true,
|
|
});
|
|
|
|
// Resolve content roots to absolute paths
|
|
const contentRoots = CONFIG.contentRoots.map(r => ({
|
|
label: r.label,
|
|
path: path.resolve(__dirname, r.path),
|
|
})).filter(r => fs.existsSync(r.path));
|
|
|
|
const excludeDirs = new Set(CONFIG.excludeDirs || []);
|
|
const excludeFiles = new Set(CONFIG.excludeFiles || []);
|
|
|
|
// Recursively scan directory for .md files
|
|
function scanDir(dir, basePath, label, results = []) {
|
|
if (!fs.existsSync(dir)) return results;
|
|
try {
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name);
|
|
const rel = path.relative(basePath, full);
|
|
if (e.isDirectory()) {
|
|
if (!excludeDirs.has(e.name) && !e.name.startsWith('.')) {
|
|
scanDir(full, basePath, label, results);
|
|
}
|
|
} else if (e.isFile() && e.name.endsWith('.md') && !excludeFiles.has(e.name)) {
|
|
results.push({
|
|
label,
|
|
relative: rel.replace(/\\/g, '/'),
|
|
name: e.name.replace(/\.md$/, ''),
|
|
dir: path.dirname(rel).replace(/\\/g, '/'),
|
|
});
|
|
}
|
|
}
|
|
} catch (_) { /* skip permission errors */ }
|
|
return results;
|
|
}
|
|
|
|
// Build file tree
|
|
function buildTree(files) {
|
|
const tree = {};
|
|
for (const f of files) {
|
|
const parts = f.relative.split('/');
|
|
let node = tree;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const isFile = i === parts.length - 1;
|
|
if (isFile) {
|
|
if (!node._files) node._files = [];
|
|
node._files.push(f);
|
|
} else {
|
|
if (!node[parts[i]]) node[parts[i]] = {};
|
|
node = node[parts[i]];
|
|
}
|
|
}
|
|
}
|
|
return tree;
|
|
}
|
|
|
|
// Convert tree to sortable array for JSON
|
|
function treeToJSON(tree, depth = 0) {
|
|
const result = [];
|
|
const dirs = Object.keys(tree).filter(k => k !== '_files').sort((a, b) => {
|
|
// Special ordering: numeric prefixes first, then alpha
|
|
const an = a.replace(/^\d+-/, '');
|
|
const bn = b.replace(/^\d+-/, '');
|
|
return an.localeCompare(bn, 'zh-CN');
|
|
});
|
|
for (const dir of dirs) {
|
|
const children = treeToJSON(tree[dir], depth + 1);
|
|
if (children.length > 0 || (tree[dir]._files && tree[dir]._files.length > 0)) {
|
|
result.push({ type: 'dir', name: dir, children });
|
|
}
|
|
}
|
|
if (tree._files) {
|
|
const files = tree._files.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
for (const f of files) {
|
|
result.push({
|
|
type: 'file',
|
|
name: f.name,
|
|
label: f.label,
|
|
relative: f.relative,
|
|
dir: f.dir,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// API: get file tree
|
|
app.get('/api/tree', (req, res) => {
|
|
let allFiles = [];
|
|
for (const root of contentRoots) {
|
|
allFiles = allFiles.concat(scanDir(root.path, root.path, root.label));
|
|
}
|
|
// Build merged tree grouped by content root
|
|
const byRoot = {};
|
|
for (const f of allFiles) {
|
|
if (!byRoot[f.label]) byRoot[f.label] = [];
|
|
byRoot[f.label].push(f);
|
|
}
|
|
const result = {};
|
|
for (const [label, files] of Object.entries(byRoot)) {
|
|
result[label] = treeToJSON(buildTree(files));
|
|
}
|
|
res.json(result);
|
|
});
|
|
|
|
// API: get file content
|
|
app.get('/api/file', (req, res) => {
|
|
const { label, rel } = req.query;
|
|
if (!rel) return res.status(400).json({ error: 'missing rel' });
|
|
const root = contentRoots.find(r => r.label === label);
|
|
if (!root) return res.status(400).json({ error: 'unknown label' });
|
|
const full = path.resolve(root.path, rel);
|
|
// Security: ensure path stays within root
|
|
if (!full.startsWith(root.path)) return res.status(403).json({ error: 'forbidden' });
|
|
if (!fs.existsSync(full)) return res.status(404).json({ error: 'not found' });
|
|
try {
|
|
const raw = fs.readFileSync(full, 'utf-8');
|
|
const { data, content } = matter(raw);
|
|
const html = marked.parse(content);
|
|
res.json({
|
|
name: path.basename(rel, '.md'),
|
|
frontmatter: data,
|
|
html,
|
|
raw: content,
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// API: full-text search
|
|
app.get('/api/search', (req, res) => {
|
|
const q = (req.query.q || '').toLowerCase();
|
|
if (q.length < 2) return res.json([]);
|
|
let allFiles = [];
|
|
for (const root of contentRoots) {
|
|
allFiles = allFiles.concat(scanDir(root.path, root.path, root.label));
|
|
}
|
|
const results = [];
|
|
for (const f of allFiles) {
|
|
const root = contentRoots.find(r => r.label === f.label);
|
|
if (!root) continue;
|
|
const full = path.resolve(root.path, f.relative);
|
|
try {
|
|
const raw = fs.readFileSync(full, 'utf-8');
|
|
if (raw.toLowerCase().includes(q)) {
|
|
const idx = raw.toLowerCase().indexOf(q);
|
|
const start = Math.max(0, idx - 60);
|
|
const snippet = raw.substring(start, idx + q.length + 80).replace(/\n/g, ' ');
|
|
results.push({
|
|
label: f.label,
|
|
relative: f.relative,
|
|
name: f.name,
|
|
dir: f.dir,
|
|
snippet: (start > 0 ? '...' : '') + snippet + '...',
|
|
});
|
|
}
|
|
} catch (_) { /* skip */ }
|
|
}
|
|
results.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
res.json(results.slice(0, 50));
|
|
});
|
|
|
|
// Static frontend
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
app.listen(CONFIG.port, CONFIG.host, () => {
|
|
console.log(`KB Server running at http://0.0.0.0:${CONFIG.port}`);
|
|
console.log(`Content roots: ${contentRoots.map(r => r.path).join(', ')}`);
|
|
});
|