Extract HTML template into separate views file

This commit is contained in:
2026-05-29 14:18:59 +08:00
parent f9e323f70a
commit cf2d5fc77b
15 changed files with 862 additions and 233 deletions
+5 -1
View File
@@ -67,4 +67,8 @@ function createRenderer(notesDir) {
};
}
module.exports = { createRenderer };
function stripLeadingTitle(content) {
return content.replace(/^\s*#\s+.+\r?\n+/, '');
}
module.exports = { createRenderer, stripLeadingTitle };
+74 -19
View File
@@ -20,32 +20,87 @@ function countWords(content) {
return chineseChars + englishWords;
}
function normalizeSortKey(name) {
const volumeMatch = name.match(/^(?:第)?卷\s*([一二三四五六七八九十百千0-9]+)$/) || name.match(/^卷\s*([一二三四五六七八九十百千0-9]+)$/);
const chapterMatch = name.match(/^第\s*([一二三四五六七八九十百千0-9]+)\s*[章节篇卷]$/);
const numericMatch = name.match(/(\d+)/);
const toNumber = (value) => {
if (/^\d+$/.test(value)) return parseInt(value, 10);
const map = { : 1, : 2, : 2, : 3, : 4, : 5, : 6, : 7, : 8, : 9, : 10, : 100, : 1000 };
if (value.length === 1) return map[value] || Number.MAX_SAFE_INTEGER;
if (value === '十') return 10;
const digits = value.split('');
if (digits.length === 2 && digits[0] === '十') return 10 + (map[digits[1]] || 0);
if (digits.length === 2 && digits[1] === '十') return (map[digits[0]] || 0) * 10;
return value.split('').reduce((sum, ch) => sum + (map[ch] || 0), 0) || Number.MAX_SAFE_INTEGER;
};
function parseChineseNumber(value) {
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (volumeMatch) return { group: 0, value: toNumber(volumeMatch[1]), name };
if (chapterMatch) return { group: 1, value: toNumber(chapterMatch[1]), name };
if (numericMatch) return { group: 2, value: parseInt(numericMatch[1], 10), name };
return { group: 3, value: name.toLocaleLowerCase('zh-CN'), name };
const digitMap = {
: 0,
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9
};
const unitMap = { : 10, : 100, : 1000 };
let total = 0;
let current = 0;
for (const ch of value) {
if (digitMap[ch] !== undefined) {
current = digitMap[ch];
continue;
}
if (unitMap[ch]) {
total += (current || 1) * unitMap[ch];
current = 0;
}
}
return total + current || Number.MAX_SAFE_INTEGER;
}
function normalizeSortKey(name) {
const volumeMatch = name.match(/^卷\s*([一二三四五六七八九十百千两零0-9]+)(?:\b|[-_.、\s]|$)(.*)$/);
const chapterMatch = name.match(/^第\s*([一二三四五六七八九十百千两零0-9]+)\s*([章节篇卷])(?:\b|[-_.、\s]|$)(.*)$/);
const numericPrefixMatch = name.match(/^(\d+(?:\.\d+)*)(?:\b|[-_.、\s]|$)(.*)$/);
if (volumeMatch) {
return {
group: 0,
value: parseChineseNumber(volumeMatch[1]),
suffix: volumeMatch[2] || '',
name
};
}
if (chapterMatch) {
return {
group: 1,
value: parseChineseNumber(chapterMatch[1]),
suffix: chapterMatch[3] || '',
name
};
}
if (numericPrefixMatch) {
return {
group: 2,
value: numericPrefixMatch[1],
suffix: numericPrefixMatch[2] || '',
name
};
}
return { group: 3, value: name.toLocaleLowerCase('zh-CN'), suffix: '', name };
}
function compareNames(a, b) {
const ak = normalizeSortKey(a.name);
const bk = normalizeSortKey(b.name);
if (ak.group !== bk.group) return ak.group - bk.group;
if (ak.value !== bk.value) return ak.value < bk.value ? -1 : 1;
if (ak.value !== bk.value) {
return ak.value < bk.value ? -1 : 1;
}
if (ak.suffix !== bk.suffix) {
return ak.suffix.localeCompare(bk.suffix, 'zh-CN', { numeric: true, sensitivity: 'base' });
}
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
}
+116 -93
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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>`);
}