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(', ')}`); });