Extract HTML template into separate views file
This commit is contained in:
+116
-93
@@ -4,99 +4,103 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { scanDirectory } = require('./scanner');
|
||||
const { createRenderer } = require('./renderer');
|
||||
const { createRenderer, stripLeadingTitle } = require('./renderer');
|
||||
|
||||
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
||||
const BUILTIN_THEMES_DIR = path.join(PUBLIC_DIR, 'themes');
|
||||
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, theme, defaultFile, initialContent }) {
|
||||
const navJson = JSON.stringify(nav).replace(/</g, '\\u003c');
|
||||
const isTocTheme = theme === 'toc';
|
||||
const themeLink = theme && !isTocTheme
|
||||
? `<link rel="stylesheet" href="/_notes/${theme.replace(/^\/+/, '')}">`
|
||||
: '';
|
||||
const initialContentJson = initialContent
|
||||
? JSON.stringify(initialContent).replace(/</g, '\\u003c')
|
||||
: 'null';
|
||||
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 `<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light" data-doc-theme="${isTocTheme ? 'toc' : 'default'}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
<link rel="stylesheet" href="/_webook/style.css">
|
||||
<link rel="stylesheet" href="/_webook/highlight-light.css" class="theme-highlight">
|
||||
${themeLink}
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<a class="sidebar-brand" href="/" aria-label="返回首页">
|
||||
<span class="sidebar-brand-mark">W</span>
|
||||
<span class="sidebar-brand-text">
|
||||
<strong>${title}</strong>
|
||||
<small>文档浏览器</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="搜索文件..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav" id="sidebarNav"></nav>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="content-toolbar">
|
||||
<div class="toolbar-breadcrumb">
|
||||
<span class="toolbar-kicker">Docs</span>
|
||||
<span class="toolbar-divider">/</span>
|
||||
<span class="toolbar-title">${title}</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button class="btn-icon" id="btnToggleSidebar" title="切换侧边栏">☰</button>
|
||||
<button class="btn-icon" id="btnToggleTheme" title="切换主题">🌙</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-body" id="contentBody">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-card">
|
||||
<div class="empty-icon">📘</div>
|
||||
<h2>选择左侧文件开始阅读</h2>
|
||||
<p>支持 Markdown、代码块、图片和基础排版。</p>
|
||||
</div>
|
||||
</div>
|
||||
<article class="markdown-body" id="markdownContent" style="display:none;">
|
||||
<header class="article-header" id="articleHeader">
|
||||
<h1 class="article-title" id="articleTitle"></h1>
|
||||
<div class="article-meta" id="articleMeta"></div>
|
||||
</header>
|
||||
<div class="article-layout">
|
||||
<div class="article-body" id="articleBody"></div>
|
||||
<aside class="toc-panel" id="tocPanel" aria-label="目录导航">
|
||||
<div class="toc-card">
|
||||
<div class="toc-title">目录</div>
|
||||
<nav class="toc-nav" id="tocNav"></nav>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div class="loading-bar" id="loadingBar"></div>
|
||||
<div class="mobile-nav-btn" id="mobileNavBtn">☰</div>
|
||||
<script>
|
||||
window.__WEBOOK__ = {
|
||||
nav: ${navJson},
|
||||
defaultFile: ${JSON.stringify(defaultFile || null)},
|
||||
initialContent: ${initialContentJson}
|
||||
};
|
||||
</script>
|
||||
<script src="/_webook/app.js"></script>
|
||||
<script src="/_webook/live-reload.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,9 +117,19 @@ function createServer(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(path.join(__dirname, '..', 'public')));
|
||||
app.use('/_webook', express.static(PUBLIC_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));
|
||||
@@ -157,9 +171,9 @@ function createServer(opts) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
// Render markdown to HTML
|
||||
// Render markdown to HTML without duplicating the page title in the article body.
|
||||
const env = { filePath: safePath };
|
||||
const html = renderer.render(content, env);
|
||||
const html = renderer.render(stripLeadingTitle(content), env);
|
||||
|
||||
// Extract metadata
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
@@ -202,7 +216,7 @@ function createServer(opts) {
|
||||
defaultFile = df;
|
||||
const content = fs.readFileSync(dfPath, 'utf-8');
|
||||
const env = { filePath: df };
|
||||
const html = renderer.render(content, env);
|
||||
const html = renderer.render(stripLeadingTitle(content), env);
|
||||
const stats = fs.statSync(dfPath);
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
const dfTitle = match ? match[1].trim() : df.replace(/\.md$/i, '');
|
||||
@@ -223,7 +237,16 @@ function createServer(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
res.send(renderPage({ title, nav, theme, defaultFile, initialContent }));
|
||||
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>`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user