298 lines
8.8 KiB
JavaScript
298 lines
8.8 KiB
JavaScript
'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, '"')
|
|
.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(/</g, '\\u003c');
|
|
|
|
return renderTemplate(PAGE_TEMPLATE, {
|
|
PAGE_TITLE: escapeHtml(title),
|
|
SITE_TITLE: escapeHtml(title),
|
|
INITIAL_THEME_ID: escapeHtml(initialThemeId),
|
|
DOC_THEME: escapeHtml(docTheme),
|
|
CUSTOM_THEME_LINK: customThemeHref
|
|
? `<link rel="stylesheet" href="${escapeHtml(customThemeHref)}" id="customThemeStylesheet">`
|
|
: '',
|
|
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(`<h1>Error</h1><pre>${err.message}</pre>`);
|
|
}
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
module.exports = { createServer };
|