'use strict'; const express = require('express'); const path = require('path'); const fs = require('fs'); const { scanDirectory } = require('./scanner'); const { createRenderer, stripLeadingTitle } = require('./renderer'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); const BUILTIN_THEMES_DIR = path.join(PUBLIC_DIR, 'themes'); const LUCIDE_DIST_DIR = path.join(__dirname, '..', 'node_modules', 'lucide', 'dist', 'umd'); const PAGE_TEMPLATE_PATH = path.join(__dirname, '..', 'views', 'page.html'); const PAGE_TEMPLATE = fs.readFileSync(PAGE_TEMPLATE_PATH, 'utf-8'); function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderTemplate(template, replacements) { return Object.entries(replacements).reduce((result, [key, value]) => { return result.split(`{{${key}}}`).join(value); }, template); } function loadBuiltInThemes() { if (!fs.existsSync(BUILTIN_THEMES_DIR)) { return []; } return fs.readdirSync(BUILTIN_THEMES_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => { const manifestPath = path.join(BUILTIN_THEMES_DIR, entry.name, 'manifest.json'); if (!fs.existsSync(manifestPath)) { return null; } const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); const themeId = manifest.id || entry.name; const themePath = `/_webook/themes/${entry.name}`; return { id: themeId, label: manifest.label || themeId, description: manifest.description || '', defaultMode: manifest.defaultMode === 'dark' ? 'dark' : 'light', stylesheet: `${themePath}/${manifest.stylesheet || 'theme.css'}`, highlight: { light: `${themePath}/${(manifest.highlight && manifest.highlight.light) || 'highlight-light.css'}`, dark: `${themePath}/${(manifest.highlight && manifest.highlight.dark) || 'highlight-dark.css'}` } }; }) .filter(Boolean) .sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')); } function resolveCustomThemeHref(notesDir, themeOption) { if (!themeOption || themeOption === 'toc') { return null; } const resolvedPath = path.isAbsolute(themeOption) ? themeOption : path.resolve(notesDir, themeOption); if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) { return null; } return { mountPath: '/_webook/custom-theme', dir: path.dirname(resolvedPath), href: `/_webook/custom-theme/${encodeURIComponent(path.basename(resolvedPath))}` }; } /** * Generate the HTML page template */ function renderPage({ title, nav, docTheme, customThemeHref, themes, initialThemeId, defaultFile, initialContent }) { const webookData = JSON.stringify({ nav, themes, initialThemeId, defaultFile: defaultFile || null, initialContent: initialContent || null }).replace(/` : '', WEBOOK_DATA: webookData }); } function normalizeEntryName(name) { return String(name) .replace(/\.(md|markdown)$/i, '') .toLowerCase() .replace(/[\s\-_.]+/g, ''); } function selectDefaultFile(nav, rootDir) { const rootFiles = Array.isArray(nav) ? nav.filter((item) => item.type === 'file') : []; if (rootFiles.length === 0) { return null; } const rootDirName = normalizeEntryName(path.basename(rootDir)); const matchers = [ (name) => name.includes('index'), (name) => name.includes('首页'), (name) => rootDirName && (name.includes(rootDirName) || rootDirName.includes(name)), (name) => name.includes('readme') ]; for (const matcher of matchers) { const matched = rootFiles.find((item) => matcher(normalizeEntryName(item.name))); if (matched) { return matched.path; } } return rootFiles[0].path; } function buildInitialContent(renderer, rootDir, relativeFilePath) { if (!relativeFilePath) { return null; } const fullPath = path.join(rootDir, relativeFilePath); if (!fs.existsSync(fullPath)) { return null; } const content = fs.readFileSync(fullPath, 'utf-8'); const env = { filePath: relativeFilePath }; const html = renderer.render(stripLeadingTitle(content), env); const stats = fs.statSync(fullPath); const match = content.match(/^#\s+(.+)$/m); const fileName = path.basename(relativeFilePath); const pageTitle = match ? match[1].trim() : fileName.replace(/\.(md|markdown)$/i, ''); const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length; const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length; return { title: pageTitle, html, meta: { created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), wordCount: chineseChars + englishWords, fileName } }; } /** * Create and start the webook server */ function createServer(opts) { const { dir, title, theme, port, host } = opts; const app = express(); const renderer = createRenderer(dir); const builtInThemes = loadBuiltInThemes(); const initialThemeId = builtInThemes.some((item) => item.id === 'default') ? 'default' : (builtInThemes[0] ? builtInThemes[0].id : 'default'); const customTheme = resolveCustomThemeHref(dir, theme); const docTheme = theme === 'toc' ? 'toc' : 'default'; // Serve webook public assets (CSS, JS) app.use('/_webook', express.static(PUBLIC_DIR)); app.use('/_vendor/lucide', express.static(LUCIDE_DIST_DIR)); if (customTheme) { app.use(customTheme.mountPath, express.static(customTheme.dir)); } // Serve notes directory as static files (for images, attachments, custom theme CSS) app.use('/_notes', express.static(dir)); // API: Get navigation tree app.get('/api/nav', (req, res) => { try { const nav = scanDirectory(dir); res.json({ success: true, data: nav }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // API: Get rendered file content app.get('/api/file', (req, res) => { try { const filePath = req.query.path; if (!filePath) { return res.status(400).json({ success: false, error: 'Missing path parameter' }); } // Security: prevent directory traversal const safePath = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, ''); const fullPath = path.join(dir, safePath); if (!fullPath.startsWith(path.resolve(dir))) { return res.status(403).json({ success: false, error: 'Access denied' }); } if (!fs.existsSync(fullPath)) { return res.status(404).json({ success: false, error: 'File not found' }); } if (!/\.(md|markdown)$/i.test(fullPath)) { return res.status(400).json({ success: false, error: 'Not a markdown file' }); } const content = fs.readFileSync(fullPath, 'utf-8'); const stats = fs.statSync(fullPath); // Render markdown to HTML without duplicating the page title in the article body. const env = { filePath: safePath }; const html = renderer.render(stripLeadingTitle(content), env); // Extract metadata const match = content.match(/^#\s+(.+)$/m); const title = match ? match[1].trim() : path.basename(filePath, path.extname(filePath)); // Count words const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length; const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length; res.json({ success: true, data: { title, html, meta: { created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), wordCount: chineseChars + englishWords, fileName: path.basename(filePath) } } }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // Main page app.get('/', (req, res) => { try { const nav = scanDirectory(dir); const defaultFile = selectDefaultFile(nav, dir); const initialContent = buildInitialContent(renderer, dir, defaultFile); res.send(renderPage({ title, nav, docTheme, customThemeHref: customTheme ? customTheme.href : null, themes: builtInThemes, initialThemeId, defaultFile, initialContent })); } catch (err) { res.status(500).send(`

Error

${err.message}
`); } }); return app; } module.exports = { createServer };