Files
kb-server/server.js
T
newbieandClaude 696e6d8c92 init: personal markdown knowledge base server
- 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>
2026-08-09 15:48:33 +00:00

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