Add gitignore and initial project files
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
'use strict';
|
||||
|
||||
const { program } = require('commander');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
const { createServer } = require('./server');
|
||||
const { createWatcher } = require('./watcher');
|
||||
|
||||
/**
|
||||
* Check if a port is available
|
||||
*/
|
||||
function isPortAvailable(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close();
|
||||
resolve(true);
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
*/
|
||||
async function findAvailablePort(startPort, host) {
|
||||
let port = startPort;
|
||||
while (!(await isPortAvailable(port, host))) {
|
||||
port++;
|
||||
if (port > startPort + 100) {
|
||||
throw new Error('No available port found in range ' + startPort + '-' + (startPort + 100));
|
||||
}
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
program
|
||||
.name('webook')
|
||||
.description('A CLI tool to preview markdown notes in a web browser')
|
||||
.argument('[dir]', 'Root directory of your markdown notes', '.')
|
||||
.option('-p, --port <number>', 'Port to listen on (auto-detect if not specified)', '0')
|
||||
.option('-h, --host <address>', 'Host address to bind', '127.0.0.1')
|
||||
.option('-t, --title <title>', 'Site title (defaults to directory name)')
|
||||
.option('--theme <path>', 'Custom CSS theme file path')
|
||||
.option('--open', 'Automatically open in browser', false)
|
||||
.version(require('../package.json').version)
|
||||
.parse(process.argv);
|
||||
|
||||
const args = program.args;
|
||||
const opts = program.opts();
|
||||
|
||||
// Resolve directory
|
||||
const dir = path.resolve(args[0] || '.');
|
||||
|
||||
// Validate directory
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.error(`Error: Directory not found: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.statSync(dir).isDirectory()) {
|
||||
console.error(`Error: Not a directory: ${dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Determine title
|
||||
const title = opts.title || path.basename(dir);
|
||||
|
||||
// Determine port
|
||||
let port = parseInt(opts.port, 10);
|
||||
if (!port || isNaN(port)) {
|
||||
port = 8000;
|
||||
}
|
||||
port = await findAvailablePort(port, opts.host);
|
||||
|
||||
// Theme
|
||||
const theme = opts.theme || null;
|
||||
|
||||
// Create Express app
|
||||
const app = createServer({ dir, title, theme, port, host: opts.host });
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Create file watcher
|
||||
const watcher = createWatcher(server, dir);
|
||||
|
||||
// Start server
|
||||
server.listen(port, opts.host, () => {
|
||||
const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${port}`;
|
||||
console.log('');
|
||||
console.log(' 📖 webook is running!');
|
||||
console.log(` 📂 Notes directory: ${dir}`);
|
||||
console.log(` 🌐 Open: ${url}`);
|
||||
console.log(` 🔄 Live reload: enabled`);
|
||||
console.log('');
|
||||
console.log(' Press Ctrl+C to stop');
|
||||
console.log('');
|
||||
|
||||
// Auto open browser
|
||||
if (opts.open) {
|
||||
const { exec } = require('child_process');
|
||||
const platform = process.platform;
|
||||
const cmd = platform === 'darwin' ? 'open' :
|
||||
platform === 'win32' ? 'start' : 'xdg-open';
|
||||
exec(`${cmd} ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('\n Shutting down...');
|
||||
watcher.close();
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
// Auto-execute when run directly (not required by another module)
|
||||
if (!module.parent) {
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const hljs = require('highlight.js');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* Create a markdown renderer instance
|
||||
*/
|
||||
function createRenderer(notesDir) {
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: false,
|
||||
highlight: function (str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return '<pre class="hljs"><code>' +
|
||||
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
|
||||
'</code></pre>';
|
||||
} catch (__) {}
|
||||
}
|
||||
try {
|
||||
return '<pre class="hljs"><code>' +
|
||||
hljs.highlightAuto(str).value +
|
||||
'</code></pre>';
|
||||
} catch (__) {}
|
||||
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
|
||||
}
|
||||
});
|
||||
|
||||
// Custom image renderer: rewrite local paths to /_notes/ URLs
|
||||
const defaultImageRender = md.renderer.rules.image || function (tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
md.renderer.rules.image = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx];
|
||||
const src = token.attrGet('src');
|
||||
if (src && !/^https?:\/\//i.test(src) && !src.startsWith('/') && !src.startsWith('data:')) {
|
||||
const fileDir = env.filePath ? path.dirname(env.filePath) : '';
|
||||
const absSrc = path.resolve(notesDir, fileDir, src);
|
||||
if (fs.existsSync(absSrc)) {
|
||||
const relSrc = path.relative(notesDir, absSrc).split(path.sep).join('/');
|
||||
token.attrSet('src', '/_notes/' + relSrc);
|
||||
}
|
||||
}
|
||||
return defaultImageRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Render markdown content to HTML
|
||||
*/
|
||||
render(content, env = {}) {
|
||||
return md.render(content, env);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get markdown-it instance (for direct use)
|
||||
*/
|
||||
getInstance() {
|
||||
return md;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createRenderer };
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Extract title from markdown content (first h1 heading)
|
||||
*/
|
||||
function extractTitle(content, filename) {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : filename.replace(/\.(md|markdown)$/i, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Count words: Chinese characters + English words
|
||||
*/
|
||||
function countWords(content) {
|
||||
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
|
||||
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
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;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scan directory for markdown files
|
||||
*/
|
||||
function scanDirectory(rootPath, relativePath = '') {
|
||||
const fullPath = path.join(rootPath, relativePath);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(fullPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files/dirs and node_modules
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
|
||||
const rel = relativePath ? path.posix.join(relativePath, entry.name) : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const children = scanDirectory(rootPath, rel);
|
||||
if (children.length > 0) {
|
||||
result.push({
|
||||
name: entry.name,
|
||||
path: rel,
|
||||
type: 'dir',
|
||||
children
|
||||
});
|
||||
}
|
||||
} else if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(fullPath, entry.name), 'utf-8');
|
||||
const stats = fs.statSync(path.join(fullPath, entry.name));
|
||||
result.push({
|
||||
name: entry.name,
|
||||
path: rel,
|
||||
type: 'file',
|
||||
meta: {
|
||||
title: extractTitle(content, entry.name),
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
wordCount: countWords(content)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first (alphabetical), then files (alphabetical)
|
||||
result.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return compareNames(a, b);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { scanDirectory };
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { scanDirectory } = require('./scanner');
|
||||
const { createRenderer } = require('./renderer');
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start the webook server
|
||||
*/
|
||||
function createServer(opts) {
|
||||
const {
|
||||
dir,
|
||||
title,
|
||||
theme,
|
||||
port,
|
||||
host
|
||||
} = opts;
|
||||
|
||||
const app = express();
|
||||
const renderer = createRenderer(dir);
|
||||
|
||||
// Serve webook public assets (CSS, JS)
|
||||
app.use('/_webook', express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// Serve notes directory as static files (for images, attachments, custom theme CSS)
|
||||
app.use('/_notes', express.static(dir));
|
||||
|
||||
// API: Get navigation tree
|
||||
app.get('/api/nav', (req, res) => {
|
||||
try {
|
||||
const nav = scanDirectory(dir);
|
||||
res.json({ success: true, data: nav });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API: Get rendered file content
|
||||
app.get('/api/file', (req, res) => {
|
||||
try {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ success: false, error: 'Missing path parameter' });
|
||||
}
|
||||
|
||||
// Security: prevent directory traversal
|
||||
const safePath = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const fullPath = path.join(dir, safePath);
|
||||
|
||||
if (!fullPath.startsWith(path.resolve(dir))) {
|
||||
return res.status(403).json({ success: false, error: 'Access denied' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return res.status(404).json({ success: false, error: 'File not found' });
|
||||
}
|
||||
|
||||
if (!/\.(md|markdown)$/i.test(fullPath)) {
|
||||
return res.status(400).json({ success: false, error: 'Not a markdown file' });
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const stats = fs.statSync(fullPath);
|
||||
|
||||
// Render markdown to HTML
|
||||
const env = { filePath: safePath };
|
||||
const html = renderer.render(content, env);
|
||||
|
||||
// Extract metadata
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
const title = match ? match[1].trim() : path.basename(filePath, path.extname(filePath));
|
||||
|
||||
// Count words
|
||||
const chineseChars = (content.match(/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/g) || []).length;
|
||||
const englishWords = (content.match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
title,
|
||||
html,
|
||||
meta: {
|
||||
created: stats.birthtime.toISOString(),
|
||||
modified: stats.mtime.toISOString(),
|
||||
wordCount: chineseChars + englishWords,
|
||||
fileName: path.basename(filePath)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Main page
|
||||
app.get('/', (req, res) => {
|
||||
try {
|
||||
const nav = scanDirectory(dir);
|
||||
// Try to find a README.md or index.md as default
|
||||
const defaultFiles = ['README.md', 'readme.md', 'index.md', 'INDEX.md'];
|
||||
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(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({ title, nav, theme, defaultFile, initialContent }));
|
||||
} catch (err) {
|
||||
res.status(500).send(`<h1>Error</h1><pre>${err.message}</pre>`);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createServer };
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const chokidar = require('chokidar');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
/**
|
||||
* Create file watcher and WebSocket server for live reload
|
||||
*/
|
||||
function createWatcher(httpServer, dir) {
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/_ws' });
|
||||
|
||||
// Track connected clients
|
||||
const clients = new Set();
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clients.add(ws);
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
clients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast reload message to all clients
|
||||
function broadcast(event) {
|
||||
const msg = JSON.stringify(event);
|
||||
for (const ws of clients) {
|
||||
try {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
ws.send(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for file changes
|
||||
const watcher = chokidar.watch(dir, {
|
||||
ignored: [
|
||||
/(^|[\/\\])\../, // dotfiles
|
||||
/node_modules/,
|
||||
/\.git/
|
||||
],
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100
|
||||
}
|
||||
});
|
||||
|
||||
let debounceTimer;
|
||||
|
||||
watcher.on('all', (event, filePath) => {
|
||||
// Only trigger reload for markdown files and assets
|
||||
if (/\.(md|markdown|css|js|png|jpg|jpeg|gif|svg|webp|ico)$/i.test(filePath)) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
broadcast({ type: 'reload', file: filePath, event });
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('error', (err) => {
|
||||
console.error('Watcher error:', err.message);
|
||||
});
|
||||
|
||||
return {
|
||||
watcher,
|
||||
wss,
|
||||
close() {
|
||||
watcher.close();
|
||||
wss.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWatcher };
|
||||
Reference in New Issue
Block a user