From cf2d5fc77bb821188810aac26b303bbba6c8296c Mon Sep 17 00:00:00 2001 From: wu2kong Date: Fri, 29 May 2026 14:18:59 +0800 Subject: [PATCH] Extract HTML template into separate views file --- README.md | 74 ++++++-- public/app.js | 170 +++++++++++++----- public/style.css | 163 +++++++++++------ public/themes/default/highlight-dark.css | 39 ++++ public/themes/default/highlight-light.css | 39 ++++ public/themes/default/manifest.json | 11 ++ public/themes/default/theme.css | 59 ++++++ public/themes/paper/highlight-dark.css | 39 ++++ public/themes/paper/highlight-light.css | 39 ++++ public/themes/paper/manifest.json | 11 ++ public/themes/paper/theme.css | 59 ++++++ src/renderer.js | 6 +- src/scanner.js | 93 ++++++++-- src/server.js | 209 ++++++++++++---------- views/page.html | 84 +++++++++ 15 files changed, 862 insertions(+), 233 deletions(-) create mode 100644 public/themes/default/highlight-dark.css create mode 100644 public/themes/default/highlight-light.css create mode 100644 public/themes/default/manifest.json create mode 100644 public/themes/default/theme.css create mode 100644 public/themes/paper/highlight-dark.css create mode 100644 public/themes/paper/highlight-light.css create mode 100644 public/themes/paper/manifest.json create mode 100644 public/themes/paper/theme.css create mode 100644 views/page.html diff --git a/README.md b/README.md index 16592f8..40f64da 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - **双栏布局** — 左侧文件导航树 + 右侧内容预览 - **自动导航** — 启动时递归扫描目录结构,生成可折叠的文件树 - **元信息展示** — 显示笔记标题、创建时间、修改时间、字数统计 -- **浅/暗主题** — 一键切换,主题偏好自动保存到 localStorage +- **主题包机制** — 内置多套主题,主题与明暗模式分离,方便后续扩展 - **可隐藏侧边栏** — 专注于阅读内容 - **搜索过滤** — 侧边栏顶部搜索框,快速过滤文件(快捷键 `Ctrl+K` / `Cmd+K`) - **代码高亮** — 基于 highlight.js,支持 190+ 编程语言 @@ -56,7 +56,7 @@ webook ~/my-notes --host=0.0.0.0 --title="我的知识库" | `--port, -p ` | 监听端口(被占用时自动递增) | `8000` | | `--host, -h
` | 监听地址 | `127.0.0.1` | | `--title, -t ` | 网站标题 | 根目录名 | -| `--theme <path>` | 自定义 CSS 主题文件路径 | 无 | +| `--theme <path>` | 额外叠加的自定义 CSS 文件路径 | 无 | | `--open` | 启动后自动打开浏览器 | `false` | | `--version, -V` | 显示版本号 | — | @@ -89,8 +89,9 @@ webook ./docs --theme=./my-theme.css ### 主题切换 -- 点击工具栏 `🌙` / `☀️` 按钮切换浅/暗主题 -- 选择会自动保存,下次打开保持 +- 顶部下拉框可切换内置主题包 +- 点击工具栏 `🌙` / `☀︎` 按钮切换浅/暗模式 +- 主题包与明暗模式都会自动保存,下次打开保持 ### 移动端 @@ -98,28 +99,69 @@ webook ./docs --theme=./my-theme.css - 点击右下角浮动按钮打开侧边栏 - 点击遮罩层或选文件后自动关闭 -## 自定义主题 +## 主题扩展 -通过 `--theme` 参数传入外部 CSS 文件路径(支持相对路径和绝对路径),CSS 文件必须是有效的 CSS。建议参考 `public/style.css` 中的 CSS 变量: +### 内置主题目录 + +每个主题都放在独立文件夹下,便于后续新增、替换和维护: + +```text +public/themes/ +├── default/ +│ ├── manifest.json +│ ├── theme.css +│ ├── highlight-light.css +│ └── highlight-dark.css +└── paper/ + ├── manifest.json + ├── theme.css + ├── highlight-light.css + └── highlight-dark.css +``` + +`manifest.json` 示例: + +```json +{ + "id": "paper", + "label": "Paper", + "defaultMode": "light", + "stylesheet": "theme.css", + "highlight": { + "light": "highlight-light.css", + "dark": "highlight-dark.css" + } +} +``` + +`theme.css` 通过 `data-theme-id` 和 `data-color-mode` 覆盖变量。建议参考 `public/themes/default/theme.css`: ```css -:root { - --bg-primary: #ffffff; - --bg-secondary: #f8f9fa; - --text-primary: #212529; - --text-secondary: #6c757d; - --border-color: #dee2e6; - --accent-color: #4a90d9; +html[data-theme-id="paper"][data-color-mode="light"] { + --bg: #f3ece1; + --text: #3f2f21; + --brand: #8a5a2b; + --body-bg: linear-gradient(180deg, #fff8ed 0%, #f3ece1 100%); /* ... */ } -[data-theme="dark"] { - --bg-primary: #1a1b1e; - --text-primary: #c1c2c5; +html[data-theme-id="paper"][data-color-mode="dark"] { + --bg: #17120d; + --text: #f1e4d4; + --brand: #d1a16c; /* ... */ } ``` +### 外部覆盖样式 + +通过 `--theme` 参数传入外部 CSS 文件路径(支持相对路径和绝对路径),会在内置主题之后加载,适合做局部覆盖: + +```bash +webook ./docs --theme=./my-theme.css +webook ./docs --theme=/absolute/path/to/my-theme.css +``` + ## 目录结构要求 webook 只扫描 `.md` 和 `.markdown` 文件。目录推荐按主题分组: diff --git a/public/app.js b/public/app.js index eceb922..8d61fd7 100644 --- a/public/app.js +++ b/public/app.js @@ -3,6 +3,7 @@ const DATA = window.__WEBOOK__; const navTree = DATA.nav || []; + const themes = Array.isArray(DATA.themes) ? DATA.themes : []; const defaultFile = DATA.defaultFile; const initialContent = DATA.initialContent; @@ -11,6 +12,7 @@ const searchInput = document.getElementById('searchInput'); const btnToggleSidebar = document.getElementById('btnToggleSidebar'); const btnToggleTheme = document.getElementById('btnToggleTheme'); + const themeSelect = document.getElementById('themeSelect'); const contentBody = document.getElementById('contentBody'); const emptyState = document.getElementById('emptyState'); const markdownContent = document.getElementById('markdownContent'); @@ -22,35 +24,88 @@ const loadingBar = document.getElementById('loadingBar'); const sidebar = document.getElementById('sidebar'); const mobileNavBtn = document.getElementById('mobileNavBtn'); + const themeStylesheet = document.getElementById('themeStylesheet'); + const themeHighlightStylesheet = document.getElementById('themeHighlightStylesheet'); let currentFile = null; + const fallbackTheme = themes[0] || { + id: 'default', + label: 'Default', + defaultMode: 'light', + stylesheet: '/_webook/themes/default/theme.css', + highlight: { + light: '/_webook/themes/default/highlight-light.css', + dark: '/_webook/themes/default/highlight-dark.css' + } + }; // === Theme === - function getTheme() { - return localStorage.getItem('webook-theme') || 'light'; + function getThemeConfig(themeId) { + return themes.find((item) => item.id === themeId) || fallbackTheme; } - function setTheme(theme) { - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('webook-theme', theme); - btnToggleTheme.textContent = theme === 'dark' ? '☀︎' : '☾'; - - // Swap highlight theme - const hlLink = document.querySelector('.theme-highlight'); - if (hlLink) { - hlLink.href = theme === 'dark' - ? '/_webook/highlight-dark.css' - : '/_webook/highlight-light.css'; + function getStoredThemeId() { + const stored = localStorage.getItem('webook-theme-id'); + if (stored && themes.some((item) => item.id === stored)) { + return stored; } + return DATA.initialThemeId || fallbackTheme.id; } - function toggleTheme() { - const current = getTheme(); - setTheme(current === 'light' ? 'dark' : 'light'); + function getStoredColorMode(themeId) { + const stored = localStorage.getItem('webook-color-mode'); + if (stored === 'light' || stored === 'dark') { + return stored; + } + + const legacy = localStorage.getItem('webook-theme'); + if (legacy === 'light' || legacy === 'dark') { + return legacy; + } + + const theme = getThemeConfig(themeId); + return theme.defaultMode === 'dark' ? 'dark' : 'light'; } - // Initialize theme - setTheme(getTheme()); + function renderThemeOptions() { + if (!themeSelect) return; + themeSelect.innerHTML = themes.map((item) => ( + `<option value="${escapeHtml(item.id)}">${escapeHtml(item.label)}</option>` + )).join(''); + } + + function applyTheme(themeId, colorMode) { + const theme = getThemeConfig(themeId); + const mode = colorMode === 'dark' ? 'dark' : 'light'; + + document.documentElement.setAttribute('data-theme-id', theme.id); + document.documentElement.setAttribute('data-color-mode', mode); + localStorage.setItem('webook-theme-id', theme.id); + localStorage.setItem('webook-color-mode', mode); + localStorage.setItem('webook-theme', mode); + + if (themeStylesheet) { + themeStylesheet.href = theme.stylesheet; + } + if (themeHighlightStylesheet) { + themeHighlightStylesheet.href = mode === 'dark' ? theme.highlight.dark : theme.highlight.light; + } + if (themeSelect) { + themeSelect.value = theme.id; + } + + btnToggleTheme.textContent = mode === 'dark' ? '☀︎' : '☾'; + btnToggleTheme.title = mode === 'dark' ? '切换到浅色模式' : '切换到深色模式'; + } + + function toggleColorMode() { + const currentThemeId = getStoredThemeId(); + const currentMode = getStoredColorMode(currentThemeId); + applyTheme(currentThemeId, currentMode === 'dark' ? 'light' : 'dark'); + } + + renderThemeOptions(); + applyTheme(getStoredThemeId(), getStoredColorMode(getStoredThemeId())); // === Sidebar Toggle === function getSidebarVisible() { @@ -174,6 +229,10 @@ if (fileItem) fileItem.classList.add('active'); } + function findNavFileItem(filePath) { + return sidebarNav.querySelector(`[data-file-path="${CSS.escape(filePath)}"]`); + } + // === Search === searchInput.addEventListener('input', function () { const query = this.value.trim().toLowerCase(); @@ -270,16 +329,7 @@ articleBody.innerHTML = data.html; buildTOC(); - // Update meta - const created = formatDate(data.meta.created); - const modified = formatDate(data.meta.modified); - const wordCount = data.meta.wordCount; - - articleMeta.innerHTML = ` - <span class="article-meta-item">📅 创建: ${created}</span> - <span class="article-meta-item">✏️ 修改: ${modified}</span> - <span class="article-meta-item">📝 字数: ${wordCount.toLocaleString()}</span> - `; + renderArticleMeta(data.meta); // Scroll to top contentBody.scrollTop = 0; @@ -329,6 +379,17 @@ // For now, server-side rendering handles highlighting. } + function renderArticleMeta(meta) { + const created = formatDate(meta.created); + const modified = formatDate(meta.modified); + const wordCount = meta.wordCount; + articleMeta.innerHTML = ` + <span class="article-meta-item">创建时间: ${created}</span> + <span class="article-meta-item">修改时间: ${modified}</span> + <span class="article-meta-item">字数统计: ${wordCount.toLocaleString()}</span> + `; + } + function slugifyHeading(text, index) { return 'toc-' + text .toLowerCase() @@ -361,13 +422,20 @@ }); tocNav.innerHTML = items.map((item) => ` - <a class="toc-link toc-level-${item.level}" href="#${escapeHtml(item.id)}" data-target="${escapeHtml(item.id)}"> + <button class="toc-link toc-level-${item.level}" type="button" data-target="${escapeHtml(item.id)}"> <span class="toc-bullet"></span> <span class="toc-text">${escapeHtml(item.text)}</span> - </a> + </button> `).join(''); } + function scrollToHeading(targetId) { + if (!targetId) return; + const target = articleBody.querySelector(`#${CSS.escape(targetId)}`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + // === Initial Load === if (initialContent) { emptyState.style.display = 'none'; @@ -376,14 +444,7 @@ articleBody.innerHTML = initialContent.html; buildTOC(); - const created = formatDate(initialContent.meta.created); - const modified = formatDate(initialContent.meta.modified); - const wordCount = initialContent.meta.wordCount; - articleMeta.innerHTML = ` - <span class="article-meta-item">📅 创建: ${created}</span> - <span class="article-meta-item">✏️ 修改: ${modified}</span> - <span class="article-meta-item">📝 字数: ${wordCount.toLocaleString()}</span> - `; + renderArticleMeta(initialContent.meta); currentFile = defaultFile; if (defaultFile && history.replaceState) { @@ -394,22 +455,41 @@ // === Hash-based navigation === function handleHashChange() { const hash = decodeURIComponent(window.location.hash.slice(1)); - if (hash && hash !== currentFile) { + if (!hash || hash === currentFile) { + return; + } + + const fileItem = findNavFileItem(hash); + if (fileItem) { loadFile(hash); - // Find and activate the nav item - const fileItem = sidebarNav.querySelector(`[data-file-path="${CSS.escape(hash)}"]`); - if (fileItem) { - setActiveFile(fileItem); - } + setActiveFile(fileItem); + return; + } + + if (hash.startsWith('toc-')) { + scrollToHeading(hash); } } window.addEventListener('hashchange', handleHashChange); // === Event Listeners === - btnToggleTheme.addEventListener('click', toggleTheme); + btnToggleTheme.addEventListener('click', toggleColorMode); btnToggleSidebar.addEventListener('click', toggleSidebar); mobileNavBtn.addEventListener('click', showMobileSidebar); + if (themeSelect) { + themeSelect.addEventListener('change', function () { + applyTheme(this.value, getStoredColorMode(this.value)); + }); + } + if (tocNav) { + tocNav.addEventListener('click', function (e) { + const link = e.target.closest('[data-target]'); + if (!link) return; + e.preventDefault(); + scrollToHeading(link.getAttribute('data-target')); + }); + } // === Keyboard Shortcuts === document.addEventListener('keydown', function (e) { diff --git a/public/style.css b/public/style.css index 3b1fe1b..7df636c 100644 --- a/public/style.css +++ b/public/style.css @@ -23,29 +23,17 @@ --sidebar-width: 300px; --toolbar-height: 68px; --transition: 180ms ease; - --font-sans: "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; + --font-sans: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; --font-mono: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Menlo", monospace; -} - -[data-theme="dark"] { - --bg: #0b1120; - --bg-elevated: rgba(15, 23, 42, 0.82); - --bg-panel: #0f172a; - --bg-soft: #111827; - --bg-hover: #182033; - --text: #e5edf9; - --text-muted: #a3b2c6; - --text-soft: #6f8099; - --border: rgba(148, 163, 184, 0.16); - --border-strong: rgba(148, 163, 184, 0.26); - --brand: #7aa2ff; - --brand-strong: #93b4ff; - --brand-soft: rgba(122, 162, 255, 0.14); - --code-bg: #020617; - --code-fg: #dbeafe; - --shadow-lg: 0 24px 60px rgba(2, 6, 23, 0.34); - --shadow-md: 0 14px 32px rgba(2, 6, 23, 0.28); - --shadow-sm: 0 6px 18px rgba(2, 6, 23, 0.2); + --body-bg: + radial-gradient(circle at top left, rgba(58, 111, 247, 0.12), transparent 30%), + radial-gradient(circle at top right, rgba(56, 189, 248, 0.10), transparent 26%), + linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%); + --brand-mark-bg: linear-gradient(135deg, var(--brand), #7c3aed); + --active-file-bg: linear-gradient(180deg, rgba(58,111,247,0.12), rgba(58,111,247,0.08)); + --active-file-border: rgba(58,111,247,0.18); + --active-file-shadow: 0 8px 18px rgba(58,111,247,0.08); + --loading-bar-bg: linear-gradient(90deg, var(--brand), #38bdf8); } *, *::before, *::after { box-sizing: border-box; } @@ -53,18 +41,9 @@ html, body { margin: 0; min-height: 100%; } body { font-family: var(--font-sans); color: var(--text); - background: - radial-gradient(circle at top left, rgba(58, 111, 247, 0.12), transparent 30%), - radial-gradient(circle at top right, rgba(56, 189, 248, 0.10), transparent 26%), - linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%); + background: var(--body-bg); overflow: hidden; } -[data-theme="dark"] body { - background: - radial-gradient(circle at top left, rgba(58, 111, 247, 0.16), transparent 30%), - radial-gradient(circle at top right, rgba(56, 189, 248, 0.10), transparent 26%), - linear-gradient(180deg, #0b1120 0%, #09101c 100%); -} a { color: var(--brand); text-decoration: none; } a:hover { color: var(--brand-strong); } @@ -80,10 +59,10 @@ a:hover { color: var(--brand-strong); } min-width: 0; min-height: 0; padding: 18px 14px 18px 18px; - border-right: 1px solid var(--border); - background: var(--bg-elevated); - backdrop-filter: blur(18px); - box-shadow: inset -1px 0 0 rgba(255,255,255,0.3); + border-right: 0; + background: var(--bg); + backdrop-filter: none; + box-shadow: none; } .sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-width)); } .sidebar-header { display: grid; gap: 14px; padding-bottom: 14px; } @@ -96,7 +75,7 @@ a:hover { color: var(--brand-strong); } .sidebar-brand-mark { width: 36px; height: 36px; border-radius: 12px; display: grid; place-items: center; - background: linear-gradient(135deg, var(--brand), #7c3aed); + background: var(--brand-mark-bg); color: #fff; font-weight: 700; box-shadow: var(--shadow-sm); } .sidebar-brand-text { display: grid; line-height: 1.15; } @@ -157,9 +136,9 @@ a:hover { color: var(--brand-strong); } } .nav-file:hover { background: var(--bg-soft); } .nav-file.active { - background: linear-gradient(180deg, rgba(58,111,247,0.12), rgba(58,111,247,0.08)); - border-color: rgba(58,111,247,0.18); - box-shadow: 0 8px 18px rgba(58,111,247,0.08); + background: var(--active-file-bg); + border-color: var(--active-file-border); + box-shadow: var(--active-file-shadow); } .nav-file-icon { width: 9px; @@ -184,8 +163,9 @@ a:hover { color: var(--brand-strong); } height: var(--toolbar-height); display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 18px 0 22px; - background: var(--bg-elevated); border-bottom: 1px solid var(--border); - backdrop-filter: blur(18px); + background: var(--bg-elevated); + border-bottom: 0; + backdrop-filter: none; } .toolbar-breadcrumb { display: flex; align-items: center; gap: 10px; min-width: 0; } .toolbar-kicker { @@ -196,6 +176,32 @@ a:hover { color: var(--brand-strong); } .toolbar-divider, .toolbar-title { color: var(--text-muted); font-size: 14px; } .toolbar-title { color: var(--text); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .toolbar-actions { display: flex; align-items: center; gap: 8px; } +.theme-select-wrap { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 0 12px; + height: 38px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-panel); + box-shadow: var(--shadow-sm); +} +.theme-select-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-muted); +} +.theme-select { + border: 0; + background: transparent; + color: var(--text); + font: inherit; + min-width: 104px; + outline: none; + cursor: pointer; +} .btn-icon { width: 38px; height: 38px; border: 1px solid transparent; border-radius: 12px; background: transparent; color: var(--text-muted); @@ -203,7 +209,11 @@ a:hover { color: var(--brand-strong); } } .btn-icon:hover { background: var(--bg-soft); color: var(--text); border-color: var(--border); } -.content-body { flex: 1; overflow: auto; } +.content-body { + flex: 1; + overflow: auto; + background: var(--bg-elevated); +} .empty-state { display: grid; place-items: center; min-height: 100%; padding: 32px; @@ -219,27 +229,44 @@ a:hover { color: var(--brand-strong); } .empty-card p { margin: 0; color: var(--text-muted); line-height: 1.7; } .markdown-body { - width: min(880px, calc(100% - 48px)); + width: min(1120px, calc(100% - 48px)); margin: 0 auto; padding: 36px 0 72px; } .article-layout { display: grid; - grid-template-columns: minmax(0, 1fr) 230px; + grid-template-columns: minmax(0, 1fr) 250px; gap: 24px; align-items: start; } .article-header { - padding: 22px 24px; margin-bottom: 24px; - border: 1px solid var(--border); border-radius: 24px; - background: var(--bg-elevated); box-shadow: var(--shadow-md); backdrop-filter: blur(18px); + padding: 10px 0 14px; + margin-bottom: 20px; + border: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + background: transparent; + box-shadow: none; + backdrop-filter: none; } .article-title { - margin: 0 0 12px; font-size: clamp(30px, 4vw, 44px); line-height: 1.08; + margin: 0; + font-size: clamp(30px, 4vw, 44px); line-height: 1.08; letter-spacing: -0.03em; color: var(--text); } .article-meta { display: flex; flex-wrap: wrap; gap: 10px 16px; color: var(--text-muted); font-size: 13px; } -.article-meta-item { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 999px; background: var(--bg-soft); } +.article-meta-side { + display: grid; + gap: 10px; +} +.article-meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border-radius: 0; + background: transparent; +} .article-body { font-size: 16px; line-height: 1.85; color: var(--text); } @@ -291,7 +318,12 @@ a:hover { color: var(--brand-strong); } .article-body hr { margin: 2em 0; border: 0; border-top: 1px solid var(--border); } .article-body input[type="checkbox"] { margin-right: 8px; } -.toc-panel { display: none; position: sticky; top: 18px; } +.toc-panel { + display: grid; + gap: 14px; + position: sticky; + top: 18px; +} .toc-card { padding: 16px 14px; border: 1px solid var(--border); @@ -300,6 +332,9 @@ a:hover { color: var(--brand-strong); } box-shadow: var(--shadow-sm); backdrop-filter: blur(18px); } +.meta-card { + padding-bottom: 14px; +} .toc-title { font-size: 12px; font-weight: 700; @@ -316,11 +351,16 @@ a:hover { color: var(--brand-strong); } display: flex; align-items: center; gap: 10px; + width: 100%; padding: 8px 10px; + border: 0; border-radius: 12px; + background: transparent; color: var(--text-muted); font-size: 13px; + text-align: left; line-height: 1.35; + cursor: pointer; } .toc-link:hover { background: var(--bg-soft); @@ -344,7 +384,7 @@ a:hover { color: var(--brand-strong); } .loading-bar { position: fixed; top: 0; left: 0; height: 3px; width: 0; - background: linear-gradient(90deg, var(--brand), #38bdf8); + background: var(--loading-bar-bg); z-index: 1000; pointer-events: none; transition: width 0.25s ease, opacity 0.2s ease; } .loading-bar.visible { width: 55%; } @@ -371,10 +411,12 @@ a:hover { color: var(--brand-strong); } .sidebar.collapsed { margin-left: 0; } .content-toolbar { padding-left: 18px; } .toolbar-breadcrumb { min-width: 0; } + .theme-select-wrap { max-width: 150px; } + .theme-select { min-width: 0; width: 100%; } .markdown-body { width: min(100%, calc(100% - 28px)); padding-top: 20px; padding-bottom: 72px; } .article-layout { grid-template-columns: minmax(0, 1fr); } .toc-panel { display: none !important; } - .article-header { padding: 20px; } + .article-header { padding: 8px 0 12px; } .mobile-nav-btn { display: grid; place-items: center; position: fixed; right: 18px; bottom: 18px; width: 52px; height: 52px; border-radius: 18px; @@ -384,6 +426,14 @@ a:hover { color: var(--brand-strong); } } } +@media (max-width: 640px) { + .theme-select-label { display: none; } + .theme-select-wrap { + width: 92px; + padding: 0 10px; + } +} + [data-doc-theme="toc"] .content { background: radial-gradient(circle at top right, rgba(58, 111, 247, 0.08), transparent 28%), @@ -393,14 +443,9 @@ a:hover { color: var(--brand-strong); } width: min(1180px, calc(100% - 48px)); } [data-doc-theme="toc"] .article-layout { - grid-template-columns: minmax(0, 1fr) 240px; + grid-template-columns: minmax(0, 1fr) 250px; gap: 28px; } -[data-doc-theme="toc"] .toc-panel { display: block; } -[data-doc-theme="toc"] .toc-card { - position: sticky; - top: 18px; -} .sidebar-overlay { display: none; position: fixed; inset: 0; z-index: 210; diff --git a/public/themes/default/highlight-dark.css b/public/themes/default/highlight-dark.css new file mode 100644 index 0000000..12f0dc2 --- /dev/null +++ b/public/themes/default/highlight-dark.css @@ -0,0 +1,39 @@ +/* Dark theme for highlight.js - GitHub Dark-like */ +.hljs { display: block; overflow-x: auto; padding: 0; background: transparent; color: #c9d1d9; } +.hljs-comment, +.hljs-quote { color: #8b949e; font-style: italic; } +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { color: #ff7b72; font-weight: bold; } +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { color: #79c0ff; } +.hljs-string, +.hljs-doctag { color: #a5d6ff; } +.hljs-title, +.hljs-section, +.hljs-selector-id { color: #d2a8ff; font-weight: bold; } +.hljs-subst { font-weight: normal; } +.hljs-type, +.hljs-class .hljs-title { color: #d2a8ff; font-weight: bold; } +.hljs-tag, +.hljs-name, +.hljs-attribute { color: #7ee787; font-weight: normal; } +.hljs-regexp, +.hljs-link { color: #a5d6ff; } +.hljs-symbol, +.hljs-bullet { color: #ffa657; } +.hljs-built_in, +.hljs-builtin-name { color: #79c0ff; } +.hljs-meta { color: #8b949e; font-weight: bold; } +.hljs-deletion { background: #490202; } +.hljs-addition { background: #04260f; } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } + +/* Line numbers */ +.hljs-ln { border-collapse: collapse; } +.hljs-ln td { padding: 0; } +.hljs-ln-n { text-align: right; padding-right: 8px; color: #484f58; user-select: none; } diff --git a/public/themes/default/highlight-light.css b/public/themes/default/highlight-light.css new file mode 100644 index 0000000..79fa9ef --- /dev/null +++ b/public/themes/default/highlight-light.css @@ -0,0 +1,39 @@ +/* Light theme for highlight.js - GitHub-like */ +.hljs { display: block; overflow-x: auto; padding: 0; background: transparent; color: #24292e; } +.hljs-comment, +.hljs-quote { color: #6a737d; font-style: italic; } +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { color: #d73a49; font-weight: bold; } +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { color: #005cc5; } +.hljs-string, +.hljs-doctag { color: #032f62; } +.hljs-title, +.hljs-section, +.hljs-selector-id { color: #6f42c1; font-weight: bold; } +.hljs-subst { font-weight: normal; } +.hljs-type, +.hljs-class .hljs-title { color: #6f42c1; font-weight: bold; } +.hljs-tag, +.hljs-name, +.hljs-attribute { color: #22863a; font-weight: normal; } +.hljs-regexp, +.hljs-link { color: #032f62; } +.hljs-symbol, +.hljs-bullet { color: #e36209; } +.hljs-built_in, +.hljs-builtin-name { color: #005cc5; } +.hljs-meta { color: #6a737d; font-weight: bold; } +.hljs-deletion { background: #ffeef0; } +.hljs-addition { background: #f0fff4; } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } + +/* Line numbers */ +.hljs-ln { border-collapse: collapse; } +.hljs-ln td { padding: 0; } +.hljs-ln-n { text-align: right; padding-right: 8px; color: #959da5; user-select: none; } diff --git a/public/themes/default/manifest.json b/public/themes/default/manifest.json new file mode 100644 index 0000000..37537b5 --- /dev/null +++ b/public/themes/default/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "default", + "label": "Aurora", + "description": "当前默认的玻璃拟态文档主题。", + "defaultMode": "light", + "stylesheet": "theme.css", + "highlight": { + "light": "highlight-light.css", + "dark": "highlight-dark.css" + } +} diff --git a/public/themes/default/theme.css b/public/themes/default/theme.css new file mode 100644 index 0000000..42b795c --- /dev/null +++ b/public/themes/default/theme.css @@ -0,0 +1,59 @@ +html[data-theme-id="default"][data-color-mode="light"] { + --bg: #f6f7fb; + --bg-elevated: rgba(255, 255, 255, 0.86); + --bg-panel: #ffffff; + --bg-soft: #eef2f7; + --bg-hover: #e8eef7; + --text: #1f2937; + --text-muted: #6b7280; + --text-soft: #94a3b8; + --border: rgba(148, 163, 184, 0.22); + --border-strong: rgba(148, 163, 184, 0.35); + --brand: #3a6ff7; + --brand-strong: #2757d6; + --brand-soft: rgba(58, 111, 247, 0.12); + --code-bg: #0f172a; + --code-fg: #e5edf9; + --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08); + --shadow-md: 0 10px 28px rgba(15, 23, 42, 0.08); + --shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.06); + --body-bg: + radial-gradient(circle at top left, rgba(58, 111, 247, 0.12), transparent 30%), + radial-gradient(circle at top right, rgba(56, 189, 248, 0.10), transparent 26%), + linear-gradient(180deg, #fbfcff 0%, var(--bg) 100%); + --brand-mark-bg: linear-gradient(135deg, var(--brand), #7c3aed); + --active-file-bg: linear-gradient(180deg, rgba(58,111,247,0.12), rgba(58,111,247,0.08)); + --active-file-border: rgba(58,111,247,0.18); + --active-file-shadow: 0 8px 18px rgba(58,111,247,0.08); + --loading-bar-bg: linear-gradient(90deg, var(--brand), #38bdf8); +} + +html[data-theme-id="default"][data-color-mode="dark"] { + --bg: #0b1120; + --bg-elevated: rgba(15, 23, 42, 0.82); + --bg-panel: #0f172a; + --bg-soft: #111827; + --bg-hover: #182033; + --text: #e5edf9; + --text-muted: #a3b2c6; + --text-soft: #6f8099; + --border: rgba(148, 163, 184, 0.16); + --border-strong: rgba(148, 163, 184, 0.26); + --brand: #7aa2ff; + --brand-strong: #93b4ff; + --brand-soft: rgba(122, 162, 255, 0.14); + --code-bg: #020617; + --code-fg: #dbeafe; + --shadow-lg: 0 24px 60px rgba(2, 6, 23, 0.34); + --shadow-md: 0 14px 32px rgba(2, 6, 23, 0.28); + --shadow-sm: 0 6px 18px rgba(2, 6, 23, 0.2); + --body-bg: + radial-gradient(circle at top left, rgba(58, 111, 247, 0.16), transparent 30%), + radial-gradient(circle at top right, rgba(56, 189, 248, 0.10), transparent 26%), + linear-gradient(180deg, #0b1120 0%, #09101c 100%); + --brand-mark-bg: linear-gradient(135deg, #7aa2ff, #38bdf8); + --active-file-bg: linear-gradient(180deg, rgba(122,162,255,0.18), rgba(122,162,255,0.09)); + --active-file-border: rgba(122,162,255,0.24); + --active-file-shadow: 0 12px 22px rgba(2,6,23,0.26); + --loading-bar-bg: linear-gradient(90deg, #7aa2ff, #38bdf8); +} diff --git a/public/themes/paper/highlight-dark.css b/public/themes/paper/highlight-dark.css new file mode 100644 index 0000000..12f0dc2 --- /dev/null +++ b/public/themes/paper/highlight-dark.css @@ -0,0 +1,39 @@ +/* Dark theme for highlight.js - GitHub Dark-like */ +.hljs { display: block; overflow-x: auto; padding: 0; background: transparent; color: #c9d1d9; } +.hljs-comment, +.hljs-quote { color: #8b949e; font-style: italic; } +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { color: #ff7b72; font-weight: bold; } +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { color: #79c0ff; } +.hljs-string, +.hljs-doctag { color: #a5d6ff; } +.hljs-title, +.hljs-section, +.hljs-selector-id { color: #d2a8ff; font-weight: bold; } +.hljs-subst { font-weight: normal; } +.hljs-type, +.hljs-class .hljs-title { color: #d2a8ff; font-weight: bold; } +.hljs-tag, +.hljs-name, +.hljs-attribute { color: #7ee787; font-weight: normal; } +.hljs-regexp, +.hljs-link { color: #a5d6ff; } +.hljs-symbol, +.hljs-bullet { color: #ffa657; } +.hljs-built_in, +.hljs-builtin-name { color: #79c0ff; } +.hljs-meta { color: #8b949e; font-weight: bold; } +.hljs-deletion { background: #490202; } +.hljs-addition { background: #04260f; } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } + +/* Line numbers */ +.hljs-ln { border-collapse: collapse; } +.hljs-ln td { padding: 0; } +.hljs-ln-n { text-align: right; padding-right: 8px; color: #484f58; user-select: none; } diff --git a/public/themes/paper/highlight-light.css b/public/themes/paper/highlight-light.css new file mode 100644 index 0000000..79fa9ef --- /dev/null +++ b/public/themes/paper/highlight-light.css @@ -0,0 +1,39 @@ +/* Light theme for highlight.js - GitHub-like */ +.hljs { display: block; overflow-x: auto; padding: 0; background: transparent; color: #24292e; } +.hljs-comment, +.hljs-quote { color: #6a737d; font-style: italic; } +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { color: #d73a49; font-weight: bold; } +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { color: #005cc5; } +.hljs-string, +.hljs-doctag { color: #032f62; } +.hljs-title, +.hljs-section, +.hljs-selector-id { color: #6f42c1; font-weight: bold; } +.hljs-subst { font-weight: normal; } +.hljs-type, +.hljs-class .hljs-title { color: #6f42c1; font-weight: bold; } +.hljs-tag, +.hljs-name, +.hljs-attribute { color: #22863a; font-weight: normal; } +.hljs-regexp, +.hljs-link { color: #032f62; } +.hljs-symbol, +.hljs-bullet { color: #e36209; } +.hljs-built_in, +.hljs-builtin-name { color: #005cc5; } +.hljs-meta { color: #6a737d; font-weight: bold; } +.hljs-deletion { background: #ffeef0; } +.hljs-addition { background: #f0fff4; } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } + +/* Line numbers */ +.hljs-ln { border-collapse: collapse; } +.hljs-ln td { padding: 0; } +.hljs-ln-n { text-align: right; padding-right: 8px; color: #959da5; user-select: none; } diff --git a/public/themes/paper/manifest.json b/public/themes/paper/manifest.json new file mode 100644 index 0000000..a417969 --- /dev/null +++ b/public/themes/paper/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "paper", + "label": "Paper", + "description": "偏纸质阅读感的暖色主题。", + "defaultMode": "light", + "stylesheet": "theme.css", + "highlight": { + "light": "highlight-light.css", + "dark": "highlight-dark.css" + } +} diff --git a/public/themes/paper/theme.css b/public/themes/paper/theme.css new file mode 100644 index 0000000..9765aba --- /dev/null +++ b/public/themes/paper/theme.css @@ -0,0 +1,59 @@ +html[data-theme-id="paper"][data-color-mode="light"] { + --bg: #f3ece1; + --bg-elevated: rgba(255, 251, 245, 0.9); + --bg-panel: #fffaf2; + --bg-soft: #ede2d0; + --bg-hover: #e6d6bb; + --text: #3f2f21; + --text-muted: #7d6752; + --text-soft: #a48b73; + --border: rgba(108, 84, 56, 0.18); + --border-strong: rgba(108, 84, 56, 0.28); + --brand: #8a5a2b; + --brand-strong: #6f451d; + --brand-soft: rgba(138, 90, 43, 0.12); + --code-bg: #2b2118; + --code-fg: #f8efe3; + --shadow-lg: 0 18px 42px rgba(77, 52, 29, 0.1); + --shadow-md: 0 10px 24px rgba(77, 52, 29, 0.08); + --shadow-sm: 0 4px 14px rgba(77, 52, 29, 0.06); + --body-bg: + radial-gradient(circle at top left, rgba(199, 141, 76, 0.15), transparent 28%), + radial-gradient(circle at top right, rgba(121, 171, 150, 0.12), transparent 24%), + linear-gradient(180deg, #fff8ed 0%, #f3ece1 100%); + --brand-mark-bg: linear-gradient(135deg, #b17633, #7a4d26); + --active-file-bg: linear-gradient(180deg, rgba(177,118,51,0.18), rgba(177,118,51,0.08)); + --active-file-border: rgba(138,90,43,0.24); + --active-file-shadow: 0 10px 20px rgba(122,77,38,0.1); + --loading-bar-bg: linear-gradient(90deg, #b17633, #79ab96); +} + +html[data-theme-id="paper"][data-color-mode="dark"] { + --bg: #17120d; + --bg-elevated: rgba(31, 24, 18, 0.88); + --bg-panel: #211912; + --bg-soft: #2b2118; + --bg-hover: #35291e; + --text: #f1e4d4; + --text-muted: #c2ab93; + --text-soft: #8f7762; + --border: rgba(205, 173, 141, 0.14); + --border-strong: rgba(205, 173, 141, 0.24); + --brand: #d1a16c; + --brand-strong: #e0b88c; + --brand-soft: rgba(209, 161, 108, 0.14); + --code-bg: #120d09; + --code-fg: #f8efe3; + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.32); + --shadow-md: 0 14px 30px rgba(0, 0, 0, 0.24); + --shadow-sm: 0 6px 16px rgba(0, 0, 0, 0.18); + --body-bg: + radial-gradient(circle at top left, rgba(209, 161, 108, 0.16), transparent 30%), + radial-gradient(circle at top right, rgba(121, 171, 150, 0.1), transparent 24%), + linear-gradient(180deg, #1a140f 0%, #120d09 100%); + --brand-mark-bg: linear-gradient(135deg, #d1a16c, #79ab96); + --active-file-bg: linear-gradient(180deg, rgba(209,161,108,0.18), rgba(209,161,108,0.08)); + --active-file-border: rgba(209,161,108,0.22); + --active-file-shadow: 0 12px 20px rgba(0,0,0,0.22); + --loading-bar-bg: linear-gradient(90deg, #d1a16c, #79ab96); +} diff --git a/src/renderer.js b/src/renderer.js index 688f30f..d476b25 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -67,4 +67,8 @@ function createRenderer(notesDir) { }; } -module.exports = { createRenderer }; +function stripLeadingTitle(content) { + return content.replace(/^\s*#\s+.+\r?\n+/, ''); +} + +module.exports = { createRenderer, stripLeadingTitle }; diff --git a/src/scanner.js b/src/scanner.js index 2b7ec39..9fcdd2f 100644 --- a/src/scanner.js +++ b/src/scanner.js @@ -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' }); } diff --git a/src/server.js b/src/server.js index 108fcb8..f493a07 100644 --- a/src/server.js +++ b/src/server.js @@ -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} - - - ${themeLink} - - -
- -
-
-
- Docs - / - ${title} -
-
- - -
-
-
-
-
-
📘
-

选择左侧文件开始阅读

-

支持 Markdown、代码块、图片和基础排版。

-
-
- -
-
-
-
-
- - - - -`; + 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 + ? `` + : '', + 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(`

Error

${err.message}
`); } diff --git a/views/page.html b/views/page.html new file mode 100644 index 0000000..26009fa --- /dev/null +++ b/views/page.html @@ -0,0 +1,84 @@ + + + + + + {{PAGE_TITLE}} + + + + {{CUSTOM_THEME_LINK}} + + +
+ +
+
+
+ Docs + / + {{SITE_TITLE}} +
+
+ + + +
+
+
+
+
+
📘
+

选择左侧文件开始阅读

+

支持 Markdown、代码块、图片和基础排版。

+
+
+ +
+
+
+
+
+ + + + +