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>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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(', ')}`);
|
||||
});
|
||||
Reference in New Issue
Block a user