Improve breadcrumb and code block controls
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
# Toolbar Breadcrumb Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the static toolbar title with a breadcrumb that reflects the current document location.
|
||||||
|
|
||||||
|
Target format:
|
||||||
|
|
||||||
|
`Docs / 目录1 / 目录2 / 文件标题`
|
||||||
|
|
||||||
|
The final segment uses the current file title instead of the raw file name.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- Update the toolbar breadcrumb display in the reading view.
|
||||||
|
- Derive breadcrumb segments from the current markdown file path.
|
||||||
|
- Use the resolved markdown title for the last segment.
|
||||||
|
- Keep the breadcrumb display-only for now.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Clicking breadcrumb segments to navigate.
|
||||||
|
- Renaming directory segments with custom labels.
|
||||||
|
- Changing sidebar navigation behavior.
|
||||||
|
|
||||||
|
## Data Source
|
||||||
|
|
||||||
|
The client already has the data needed for breadcrumb rendering:
|
||||||
|
|
||||||
|
- `currentFile` tracks the selected markdown file path.
|
||||||
|
- `navTree` contains file metadata, including titles extracted from markdown.
|
||||||
|
- `initialContent` and `defaultFile` cover first-load rendering.
|
||||||
|
|
||||||
|
No server-side schema changes are required for this feature.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
### First load
|
||||||
|
|
||||||
|
- If the page loads with an initial document, render the breadcrumb immediately.
|
||||||
|
- If the page loads at `/` and auto-selects a homepage, render the breadcrumb for that file.
|
||||||
|
|
||||||
|
### On file change
|
||||||
|
|
||||||
|
- When a new markdown file finishes loading, refresh the toolbar breadcrumb.
|
||||||
|
- Keep the final segment synced with the rendered file title.
|
||||||
|
|
||||||
|
### Segment rules
|
||||||
|
|
||||||
|
- Always start with `Docs`.
|
||||||
|
- For files in the root directory, show `Docs / 文件标题`.
|
||||||
|
- For nested files, show each directory name in order, followed by the file title.
|
||||||
|
- If a file has no H1 title, fall back to the file name without extension.
|
||||||
|
|
||||||
|
### Failure behavior
|
||||||
|
|
||||||
|
- If a hash points to a file that fails to load, do not replace the breadcrumb with a broken path.
|
||||||
|
- Keep the most recent valid breadcrumb until a valid file is rendered.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Template
|
||||||
|
|
||||||
|
Replace the single static toolbar title node with a breadcrumb container that the client can fully control.
|
||||||
|
|
||||||
|
### Client logic
|
||||||
|
|
||||||
|
Add two small helpers in `public/app.js`:
|
||||||
|
|
||||||
|
- A title index builder that maps file paths to their resolved display titles from `navTree`.
|
||||||
|
- A breadcrumb renderer that splits `currentFile` into directory segments and appends the resolved file title as the last segment.
|
||||||
|
|
||||||
|
Call the breadcrumb renderer in both places that establish visible content:
|
||||||
|
|
||||||
|
- Initial content hydration
|
||||||
|
- Successful `loadFile()` completion
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
Keep the existing toolbar visual style, but allow breadcrumb segments to wrap the available width more gracefully:
|
||||||
|
|
||||||
|
- Directory segments may truncate first.
|
||||||
|
- The final file-title segment should remain most visible.
|
||||||
|
- Mobile layout should continue to fit within the existing toolbar structure.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Verify these cases:
|
||||||
|
|
||||||
|
- Root path auto-loads a homepage and shows `Docs / 文件标题`
|
||||||
|
- Root-level markdown file selection
|
||||||
|
- Nested markdown file selection
|
||||||
|
- Direct refresh on a deep hash URL
|
||||||
|
- Markdown file without an H1 title
|
||||||
|
- Narrow viewport layout does not break the toolbar
|
||||||
|
|
||||||
|
## Review Notes
|
||||||
|
|
||||||
|
- Breadcrumb remains display-only by design.
|
||||||
|
- Directory labels come from real folder names, not markdown metadata.
|
||||||
|
- This change is intentionally client-side only to keep the diff narrow.
|
||||||
+147
-1
@@ -13,6 +13,7 @@
|
|||||||
const btnToggleSidebar = document.getElementById('btnToggleSidebar');
|
const btnToggleSidebar = document.getElementById('btnToggleSidebar');
|
||||||
const btnToggleTheme = document.getElementById('btnToggleTheme');
|
const btnToggleTheme = document.getElementById('btnToggleTheme');
|
||||||
const themeSelect = document.getElementById('themeSelect');
|
const themeSelect = document.getElementById('themeSelect');
|
||||||
|
const toolbarBreadcrumb = document.getElementById('toolbarBreadcrumb');
|
||||||
const contentBody = document.getElementById('contentBody');
|
const contentBody = document.getElementById('contentBody');
|
||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
const markdownContent = document.getElementById('markdownContent');
|
const markdownContent = document.getElementById('markdownContent');
|
||||||
@@ -23,11 +24,13 @@
|
|||||||
const tocPanel = document.getElementById('tocPanel');
|
const tocPanel = document.getElementById('tocPanel');
|
||||||
const loadingBar = document.getElementById('loadingBar');
|
const loadingBar = document.getElementById('loadingBar');
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const app = document.getElementById('app');
|
||||||
const mobileNavBtn = document.getElementById('mobileNavBtn');
|
const mobileNavBtn = document.getElementById('mobileNavBtn');
|
||||||
const themeStylesheet = document.getElementById('themeStylesheet');
|
const themeStylesheet = document.getElementById('themeStylesheet');
|
||||||
const themeHighlightStylesheet = document.getElementById('themeHighlightStylesheet');
|
const themeHighlightStylesheet = document.getElementById('themeHighlightStylesheet');
|
||||||
|
|
||||||
let currentFile = null;
|
let currentFile = null;
|
||||||
|
const titleByPath = buildTitleIndex(navTree);
|
||||||
const fallbackTheme = themes[0] || {
|
const fallbackTheme = themes[0] || {
|
||||||
id: 'default',
|
id: 'default',
|
||||||
label: 'Default',
|
label: 'Default',
|
||||||
@@ -115,10 +118,12 @@
|
|||||||
function setSidebarVisible(visible) {
|
function setSidebarVisible(visible) {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
sidebar.classList.remove('collapsed');
|
sidebar.classList.remove('collapsed');
|
||||||
|
app.classList.remove('sidebar-collapsed');
|
||||||
localStorage.setItem('webook-sidebar', 'visible');
|
localStorage.setItem('webook-sidebar', 'visible');
|
||||||
btnToggleSidebar.textContent = '☰';
|
btnToggleSidebar.textContent = '☰';
|
||||||
} else {
|
} else {
|
||||||
sidebar.classList.add('collapsed');
|
sidebar.classList.add('collapsed');
|
||||||
|
app.classList.add('sidebar-collapsed');
|
||||||
localStorage.setItem('webook-sidebar', 'hidden');
|
localStorage.setItem('webook-sidebar', 'hidden');
|
||||||
btnToggleSidebar.textContent = '☷';
|
btnToggleSidebar.textContent = '☷';
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,63 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTitleIndex(items, index = {}) {
|
||||||
|
if (!Array.isArray(items)) return index;
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.type === 'file') {
|
||||||
|
const title = item.meta && item.meta.title
|
||||||
|
? item.meta.title
|
||||||
|
: item.name.replace(/\.(md|markdown)$/i, '');
|
||||||
|
index[item.path] = title;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'dir' && Array.isArray(item.children)) {
|
||||||
|
buildTitleIndex(item.children, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileDisplayTitle(filePath, preferredTitle) {
|
||||||
|
if (preferredTitle) return preferredTitle;
|
||||||
|
if (titleByPath[filePath]) return titleByPath[filePath];
|
||||||
|
if (!filePath) return '';
|
||||||
|
const fileName = filePath.split('/').pop() || filePath;
|
||||||
|
return fileName.replace(/\.(md|markdown)$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolbarBreadcrumb(filePath, preferredTitle) {
|
||||||
|
if (!toolbarBreadcrumb) return;
|
||||||
|
|
||||||
|
const segments = ['<span class="toolbar-kicker">Docs</span>'];
|
||||||
|
const parts = typeof filePath === 'string' && filePath
|
||||||
|
? filePath.split('/').filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!parts.length) {
|
||||||
|
toolbarBreadcrumb.innerHTML = segments.join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryParts = parts.slice(0, -1);
|
||||||
|
const fileTitle = getFileDisplayTitle(filePath, preferredTitle);
|
||||||
|
|
||||||
|
directoryParts.forEach((part) => {
|
||||||
|
segments.push('<span class="toolbar-divider">/</span>');
|
||||||
|
segments.push(`<span class="toolbar-segment">${escapeHtml(part)}</span>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileTitle) {
|
||||||
|
segments.push('<span class="toolbar-divider">/</span>');
|
||||||
|
segments.push(`<span class="toolbar-segment is-current">${escapeHtml(fileTitle)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbarBreadcrumb.innerHTML = segments.join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Render nav
|
// Render nav
|
||||||
sidebarNav.innerHTML = renderNavTree(navTree);
|
sidebarNav.innerHTML = renderNavTree(navTree);
|
||||||
|
|
||||||
@@ -308,7 +370,6 @@
|
|||||||
async function loadFile(filePath) {
|
async function loadFile(filePath) {
|
||||||
if (currentFile === filePath) return;
|
if (currentFile === filePath) return;
|
||||||
|
|
||||||
currentFile = filePath;
|
|
||||||
showLoading();
|
showLoading();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -325,11 +386,14 @@
|
|||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
markdownContent.style.display = '';
|
markdownContent.style.display = '';
|
||||||
|
|
||||||
|
currentFile = filePath;
|
||||||
articleTitle.textContent = data.title;
|
articleTitle.textContent = data.title;
|
||||||
articleBody.innerHTML = data.html;
|
articleBody.innerHTML = data.html;
|
||||||
|
enhanceCodeBlocks();
|
||||||
buildTOC();
|
buildTOC();
|
||||||
|
|
||||||
renderArticleMeta(data.meta);
|
renderArticleMeta(data.meta);
|
||||||
|
renderToolbarBreadcrumb(filePath, data.title);
|
||||||
|
|
||||||
// Scroll to top
|
// Scroll to top
|
||||||
contentBody.scrollTop = 0;
|
contentBody.scrollTop = 0;
|
||||||
@@ -379,6 +443,77 @@
|
|||||||
// For now, server-side rendering handles highlighting.
|
// For now, server-side rendering handles highlighting.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enhanceCodeBlocks() {
|
||||||
|
const blocks = articleBody.querySelectorAll('pre.hljs');
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
if (block.parentElement && block.parentElement.classList.contains('code-block-shell')) return;
|
||||||
|
|
||||||
|
const shell = document.createElement('div');
|
||||||
|
shell.className = 'code-block-shell';
|
||||||
|
block.parentNode.insertBefore(shell, block);
|
||||||
|
shell.appendChild(block);
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'code-copy-btn';
|
||||||
|
button.textContent = '复制';
|
||||||
|
button.setAttribute('aria-label', '复制代码');
|
||||||
|
shell.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeClipboardText(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
copied = document.execCommand('copy');
|
||||||
|
} finally {
|
||||||
|
textarea.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error('copy command failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCodeFromButton(button) {
|
||||||
|
const shell = button.closest('.code-block-shell');
|
||||||
|
const block = shell ? shell.querySelector('pre') : null;
|
||||||
|
const code = block ? block.querySelector('code') : null;
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const text = code.textContent || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeClipboardText(text);
|
||||||
|
button.textContent = '已复制';
|
||||||
|
} catch (_) {
|
||||||
|
button.textContent = '复制失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (button.isConnected) {
|
||||||
|
button.textContent = '复制';
|
||||||
|
}
|
||||||
|
}, 1600);
|
||||||
|
}
|
||||||
|
|
||||||
function renderArticleMeta(meta) {
|
function renderArticleMeta(meta) {
|
||||||
const created = formatDate(meta.created);
|
const created = formatDate(meta.created);
|
||||||
const modified = formatDate(meta.modified);
|
const modified = formatDate(meta.modified);
|
||||||
@@ -442,11 +577,13 @@
|
|||||||
markdownContent.style.display = '';
|
markdownContent.style.display = '';
|
||||||
articleTitle.textContent = initialContent.title;
|
articleTitle.textContent = initialContent.title;
|
||||||
articleBody.innerHTML = initialContent.html;
|
articleBody.innerHTML = initialContent.html;
|
||||||
|
enhanceCodeBlocks();
|
||||||
buildTOC();
|
buildTOC();
|
||||||
|
|
||||||
renderArticleMeta(initialContent.meta);
|
renderArticleMeta(initialContent.meta);
|
||||||
|
|
||||||
currentFile = defaultFile;
|
currentFile = defaultFile;
|
||||||
|
renderToolbarBreadcrumb(defaultFile, initialContent.title);
|
||||||
if (defaultFile && history.replaceState) {
|
if (defaultFile && history.replaceState) {
|
||||||
history.replaceState(null, '', '#' + encodeURIComponent(defaultFile));
|
history.replaceState(null, '', '#' + encodeURIComponent(defaultFile));
|
||||||
}
|
}
|
||||||
@@ -472,6 +609,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', handleHashChange);
|
window.addEventListener('hashchange', handleHashChange);
|
||||||
|
handleHashChange();
|
||||||
|
|
||||||
// === Event Listeners ===
|
// === Event Listeners ===
|
||||||
btnToggleTheme.addEventListener('click', toggleColorMode);
|
btnToggleTheme.addEventListener('click', toggleColorMode);
|
||||||
@@ -490,6 +628,14 @@
|
|||||||
scrollToHeading(link.getAttribute('data-target'));
|
scrollToHeading(link.getAttribute('data-target'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (articleBody) {
|
||||||
|
articleBody.addEventListener('click', function (e) {
|
||||||
|
const button = e.target.closest('.code-copy-btn');
|
||||||
|
if (!button) return;
|
||||||
|
e.preventDefault();
|
||||||
|
copyCodeFromButton(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// === Keyboard Shortcuts ===
|
// === Keyboard Shortcuts ===
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
|
|||||||
+124
-13
@@ -14,6 +14,10 @@
|
|||||||
--brand-soft: rgba(58, 111, 247, 0.12);
|
--brand-soft: rgba(58, 111, 247, 0.12);
|
||||||
--code-bg: #0f172a;
|
--code-bg: #0f172a;
|
||||||
--code-fg: #e5edf9;
|
--code-fg: #e5edf9;
|
||||||
|
--code-toolbar-bg: #f4f7fb;
|
||||||
|
--code-panel-border: rgba(148, 163, 184, 0.24);
|
||||||
|
--code-panel-shadow: 0 18px 40px rgba(148, 163, 184, 0.18);
|
||||||
|
--code-line-number: #94a3b8;
|
||||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08);
|
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08);
|
||||||
--shadow-md: 0 10px 28px 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);
|
--shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||||
@@ -51,6 +55,11 @@ a:hover { color: var(--brand-strong); }
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
transition: grid-template-columns var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app.sidebar-collapsed {
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -63,8 +72,18 @@ a:hover { color: var(--brand-strong); }
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
margin-left var(--transition),
|
||||||
|
opacity var(--transition),
|
||||||
|
visibility var(--transition);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed {
|
||||||
|
margin-left: calc(-1 * var(--sidebar-width));
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-width)); }
|
|
||||||
.sidebar-header { display: grid; gap: 14px; padding-bottom: 14px; }
|
.sidebar-header { display: grid; gap: 14px; padding-bottom: 14px; }
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
@@ -167,14 +186,41 @@ a:hover { color: var(--brand-strong); }
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
.toolbar-breadcrumb { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
.toolbar-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.toolbar-kicker {
|
.toolbar-kicker {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
padding: 5px 10px; border-radius: 999px;
|
padding: 5px 10px; border-radius: 999px;
|
||||||
background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 700;
|
background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 700;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.toolbar-divider,
|
||||||
|
.toolbar-segment {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.toolbar-divider {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.toolbar-segment {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.toolbar-segment.is-current {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: min(42vw, 420px);
|
||||||
}
|
}
|
||||||
.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; }
|
.toolbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.theme-select-wrap {
|
.theme-select-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -295,15 +341,70 @@ a:hover { color: var(--brand-strong); }
|
|||||||
padding: 0.18em 0.42em; border-radius: 8px;
|
padding: 0.18em 0.42em; border-radius: 8px;
|
||||||
background: rgba(148, 163, 184, 0.14); color: var(--text);
|
background: rgba(148, 163, 184, 0.14); color: var(--text);
|
||||||
}
|
}
|
||||||
.article-body pre {
|
.code-block-shell {
|
||||||
margin: 1.2em 0; padding: 18px 20px;
|
position: relative;
|
||||||
border-radius: 20px; overflow: auto;
|
margin: 1.2em 0;
|
||||||
background: var(--code-bg) !important; color: var(--code-fg);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
.article-body pre code {
|
.article-body .code-block-shell pre {
|
||||||
padding: 0; background: transparent; color: inherit; font-size: 0.92rem;
|
margin: 0;
|
||||||
|
padding: 52px 20px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--code-fg);
|
||||||
|
border: 1px solid var(--code-panel-border);
|
||||||
|
box-shadow: var(--code-panel-shadow);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24px 18px, #ff5f57 0 5px, transparent 5.5px),
|
||||||
|
radial-gradient(circle at 44px 18px, #febc2e 0 5px, transparent 5.5px),
|
||||||
|
radial-gradient(circle at 64px 18px, #28c840 0 5px, transparent 5.5px),
|
||||||
|
linear-gradient(180deg, var(--code-toolbar-bg) 0 36px, var(--code-bg) 36px 100%) !important;
|
||||||
|
}
|
||||||
|
.article-body .code-block-shell pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.code-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 12px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--code-panel-border);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--transition), color var(--transition), background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.code-copy-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.code-block-shell:hover .code-copy-btn,
|
||||||
|
.code-block-shell:focus-within .code-copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
[data-color-mode="light"] .article-body .code-block-shell pre .hljs-ln-n {
|
||||||
|
color: var(--code-line-number);
|
||||||
|
}
|
||||||
|
[data-color-mode="dark"] .code-copy-btn {
|
||||||
|
background: rgba(15, 23, 42, 0.78);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
[data-color-mode="dark"] .code-copy-btn:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.94);
|
||||||
}
|
}
|
||||||
.article-body table {
|
.article-body table {
|
||||||
width: 100%; margin: 1.2em 0; border-collapse: collapse;
|
width: 100%; margin: 1.2em 0; border-collapse: collapse;
|
||||||
@@ -404,11 +505,17 @@ a:hover { color: var(--brand-strong); }
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed; inset: 0 auto 0 0; z-index: 220;
|
position: fixed; inset: 0 auto 0 0; z-index: 220;
|
||||||
width: min(86vw, 320px); transform: translateX(-100%);
|
width: min(86vw, 320px); transform: translateX(-100%);
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
transition: transform 220ms ease;
|
transition: transform 220ms ease;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
.sidebar.mobile-visible { transform: translateX(0); }
|
.sidebar.mobile-visible { transform: translateX(0); }
|
||||||
.sidebar.collapsed { margin-left: 0; }
|
.sidebar.collapsed {
|
||||||
|
margin-left: 0;
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.content-toolbar { padding-left: 18px; }
|
.content-toolbar { padding-left: 18px; }
|
||||||
.toolbar-breadcrumb { min-width: 0; }
|
.toolbar-breadcrumb { min-width: 0; }
|
||||||
.theme-select-wrap { max-width: 150px; }
|
.theme-select-wrap { max-width: 150px; }
|
||||||
@@ -432,6 +539,10 @@ a:hover { color: var(--brand-strong); }
|
|||||||
width: 92px;
|
width: 92px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
.code-copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-doc-theme="toc"] .content {
|
[data-doc-theme="toc"] .content {
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ html[data-theme-id="default"][data-color-mode="light"] {
|
|||||||
--brand: #3a6ff7;
|
--brand: #3a6ff7;
|
||||||
--brand-strong: #2757d6;
|
--brand-strong: #2757d6;
|
||||||
--brand-soft: rgba(58, 111, 247, 0.12);
|
--brand-soft: rgba(58, 111, 247, 0.12);
|
||||||
--code-bg: #0f172a;
|
--code-bg: #fbfdff;
|
||||||
--code-fg: #e5edf9;
|
--code-fg: #233044;
|
||||||
|
--code-toolbar-bg: #eef3f9;
|
||||||
|
--code-panel-border: rgba(148, 163, 184, 0.28);
|
||||||
|
--code-panel-shadow: 0 20px 44px rgba(148, 163, 184, 0.18);
|
||||||
|
--code-line-number: #9aa9bc;
|
||||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08);
|
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08);
|
||||||
--shadow-md: 0 10px 28px 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);
|
--shadow-sm: 0 4px 16px rgba(15, 23, 42, 0.06);
|
||||||
@@ -42,8 +46,12 @@ html[data-theme-id="default"][data-color-mode="dark"] {
|
|||||||
--brand: #7aa2ff;
|
--brand: #7aa2ff;
|
||||||
--brand-strong: #93b4ff;
|
--brand-strong: #93b4ff;
|
||||||
--brand-soft: rgba(122, 162, 255, 0.14);
|
--brand-soft: rgba(122, 162, 255, 0.14);
|
||||||
--code-bg: #020617;
|
--code-bg: #142033;
|
||||||
--code-fg: #dbeafe;
|
--code-fg: #dbeafe;
|
||||||
|
--code-toolbar-bg: #1b2a40;
|
||||||
|
--code-panel-border: rgba(148, 163, 184, 0.16);
|
||||||
|
--code-panel-shadow: 0 14px 32px rgba(2, 6, 23, 0.22);
|
||||||
|
--code-line-number: #6f8099;
|
||||||
--shadow-lg: 0 24px 60px rgba(2, 6, 23, 0.34);
|
--shadow-lg: 0 24px 60px rgba(2, 6, 23, 0.34);
|
||||||
--shadow-md: 0 14px 32px rgba(2, 6, 23, 0.28);
|
--shadow-md: 0 14px 32px rgba(2, 6, 23, 0.28);
|
||||||
--shadow-sm: 0 6px 18px rgba(2, 6, 23, 0.2);
|
--shadow-sm: 0 6px 18px rgba(2, 6, 23, 0.2);
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ html[data-theme-id="paper"][data-color-mode="light"] {
|
|||||||
--brand: #8a5a2b;
|
--brand: #8a5a2b;
|
||||||
--brand-strong: #6f451d;
|
--brand-strong: #6f451d;
|
||||||
--brand-soft: rgba(138, 90, 43, 0.12);
|
--brand-soft: rgba(138, 90, 43, 0.12);
|
||||||
--code-bg: #2b2118;
|
--code-bg: #fffaf4;
|
||||||
--code-fg: #f8efe3;
|
--code-fg: #4a3422;
|
||||||
|
--code-toolbar-bg: #f4e8da;
|
||||||
|
--code-panel-border: rgba(138, 90, 43, 0.22);
|
||||||
|
--code-panel-shadow: 0 18px 38px rgba(122, 77, 38, 0.12);
|
||||||
|
--code-line-number: #b09072;
|
||||||
--shadow-lg: 0 18px 42px rgba(77, 52, 29, 0.1);
|
--shadow-lg: 0 18px 42px rgba(77, 52, 29, 0.1);
|
||||||
--shadow-md: 0 10px 24px rgba(77, 52, 29, 0.08);
|
--shadow-md: 0 10px 24px rgba(77, 52, 29, 0.08);
|
||||||
--shadow-sm: 0 4px 14px rgba(77, 52, 29, 0.06);
|
--shadow-sm: 0 4px 14px rgba(77, 52, 29, 0.06);
|
||||||
@@ -42,8 +46,12 @@ html[data-theme-id="paper"][data-color-mode="dark"] {
|
|||||||
--brand: #d1a16c;
|
--brand: #d1a16c;
|
||||||
--brand-strong: #e0b88c;
|
--brand-strong: #e0b88c;
|
||||||
--brand-soft: rgba(209, 161, 108, 0.14);
|
--brand-soft: rgba(209, 161, 108, 0.14);
|
||||||
--code-bg: #120d09;
|
--code-bg: #2a1f16;
|
||||||
--code-fg: #f8efe3;
|
--code-fg: #f8efe3;
|
||||||
|
--code-toolbar-bg: #3a2b20;
|
||||||
|
--code-panel-border: rgba(205, 173, 141, 0.16);
|
||||||
|
--code-panel-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||||
|
--code-line-number: #8f7762;
|
||||||
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.32);
|
--shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.32);
|
||||||
--shadow-md: 0 14px 30px rgba(0, 0, 0, 0.24);
|
--shadow-md: 0 14px 30px rgba(0, 0, 0, 0.24);
|
||||||
--shadow-sm: 0 6px 16px rgba(0, 0, 0, 0.18);
|
--shadow-sm: 0 6px 16px rgba(0, 0, 0, 0.18);
|
||||||
|
|||||||
+68
-31
@@ -103,6 +103,72 @@ function renderPage({ title, nav, docTheme, customThemeHref, themes, initialThem
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Create and start the webook server
|
||||||
*/
|
*/
|
||||||
@@ -205,37 +271,8 @@ function createServer(opts) {
|
|||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const nav = scanDirectory(dir);
|
const nav = scanDirectory(dir);
|
||||||
// Try to find a README.md or index.md as default
|
const defaultFile = selectDefaultFile(nav, dir);
|
||||||
const defaultFiles = ['README.md', 'readme.md', 'index.md', 'INDEX.md'];
|
const initialContent = buildInitialContent(renderer, dir, defaultFile);
|
||||||
let initialContent = null;
|
|
||||||
let defaultFile = null;
|
|
||||||
|
|
||||||
for (const df of defaultFiles) {
|
|
||||||
const dfPath = path.join(dir, df);
|
|
||||||
if (fs.existsSync(dfPath)) {
|
|
||||||
defaultFile = df;
|
|
||||||
const content = fs.readFileSync(dfPath, 'utf-8');
|
|
||||||
const env = { filePath: df };
|
|
||||||
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, '');
|
|
||||||
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
|
|
||||||
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
|
|
||||||
|
|
||||||
initialContent = {
|
|
||||||
title: dfTitle,
|
|
||||||
html,
|
|
||||||
meta: {
|
|
||||||
created: stats.birthtime.toISOString(),
|
|
||||||
modified: stats.mtime.toISOString(),
|
|
||||||
wordCount: chineseChars + englishWords,
|
|
||||||
fileName: df
|
|
||||||
}
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.send(renderPage({
|
res.send(renderPage({
|
||||||
title,
|
title,
|
||||||
|
|||||||
+1
-3
@@ -28,10 +28,8 @@
|
|||||||
</aside>
|
</aside>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<div class="content-toolbar">
|
<div class="content-toolbar">
|
||||||
<div class="toolbar-breadcrumb">
|
<div class="toolbar-breadcrumb" id="toolbarBreadcrumb" aria-label="当前文档路径">
|
||||||
<span class="toolbar-kicker">Docs</span>
|
<span class="toolbar-kicker">Docs</span>
|
||||||
<span class="toolbar-divider">/</span>
|
|
||||||
<span class="toolbar-title">{{SITE_TITLE}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<label class="theme-select-wrap" for="themeSelect" title="切换主题包">
|
<label class="theme-select-wrap" for="themeSelect" title="切换主题包">
|
||||||
|
|||||||
Reference in New Issue
Block a user