import type { ServeCommandHandler } from '../types.js'; import { createServer, Server, IncomingMessage, ServerResponse } from 'http'; import { readdirSync, statSync, readFileSync, existsSync } from 'fs'; import { createReadStream } from 'fs'; import { join, resolve, extname, normalize, sep, relative } from 'path'; import { watch } from 'chokidar'; interface ContentIndex { [path: string]: string; } /** * 扫描目录内的 .md 和 .csv 文件,生成索引 */ function scanDirectory(dir: string): ContentIndex { const index: ContentIndex = {}; function scan(currentPath: string, relativePath: string) { const entries = readdirSync(currentPath); for (const entry of entries) { if (entry.startsWith('.') || entry === 'node_modules') continue; const fullPath = join(currentPath, entry); const relPath = relativePath ? join(relativePath, entry) : entry; const normalizedRelPath = '/' + relPath.split(sep).join('/'); const stats = statSync(fullPath); if (stats.isDirectory()) { scan(fullPath, relPath); } else if (entry.endsWith('.md') || entry.endsWith('.csv')) { try { const content = readFileSync(fullPath, 'utf-8'); index[normalizedRelPath] = content; } catch (e) { console.error(`读取文件失败:${fullPath}`, e); } } } } scan(dir, ''); return index; } /** * 获取文件扩展名对应的 MIME 类型 */ function getMimeType(filePath: string): string { const ext = extname(filePath).toLowerCase(); const mimeTypes: Record = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.md': 'text/markdown', '.csv': 'text/csv', '.woff': 'font/woff', '.woff2': 'font/woff2', }; return mimeTypes[ext] || 'application/octet-stream'; } /** * 启动开发服务器 */ export const serveCommand: ServeCommandHandler = async (dir, options) => { const contentDir = resolve(dir); const distDir = resolve(process.cwd(), 'dist/web'); let contentIndex: ContentIndex = {}; // 扫描内容目录生成索引 console.log('扫描内容目录...'); contentIndex = scanDirectory(contentDir); console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); // 监听文件变化 console.log('监听文件变化...'); const watcher = watch(contentDir, { ignored: /(^|[\/\\])\../, persistent: true, ignoreInitial: true, }); watcher .on('add', (path) => { if (path.endsWith('.md') || path.endsWith('.csv')) { try { const content = readFileSync(path, 'utf-8'); const relPath = '/' + relative(contentDir, path).split(sep).join('/'); contentIndex[relPath] = content; console.log(`[新增] ${relPath}`); } catch (e) { console.error(`读取新增文件失败:${path}`, e); } } }) .on('change', (path) => { if (path.endsWith('.md') || path.endsWith('.csv')) { try { const content = readFileSync(path, 'utf-8'); const relPath = '/' + relative(contentDir, path).split(sep).join('/'); contentIndex[relPath] = content; console.log(`[更新] ${relPath}`); } catch (e) { console.error(`读取更新文件失败:${path}`, e); } } }) .on('unlink', (path) => { if (path.endsWith('.md') || path.endsWith('.csv')) { const relPath = '/' + relative(contentDir, path).split(sep).join('/'); delete contentIndex[relPath]; console.log(`[删除] ${relPath}`); } }); // 创建 HTTP 服务器 const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => { const url = req.url || '/'; // 处理 /__CONTENT_INDEX.json 请求 if (url === '/__CONTENT_INDEX.json') { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); res.end(JSON.stringify(contentIndex, null, 2)); return; } // 解析 URL 路径 let filePath = decodeURIComponent(url.split('?')[0]); // 处理 /content/ 路径 if (filePath.startsWith('/content/')) { const relativePath = filePath.slice(1); // 去掉开头的 / const fullPath = join(contentDir, relativePath); if (existsSync(fullPath)) { const stats = statSync(fullPath); if (stats.isFile()) { res.writeHead(200, { 'Content-Type': getMimeType(fullPath), 'Access-Control-Allow-Origin': '*' }); createReadStream(fullPath).pipe(res); return; } } } // 处理静态资源(从 dist/web) let staticPath = join(distDir, filePath === '/' ? 'index.html' : filePath); // 如果文件不存在,回退到 index.html if (!existsSync(staticPath)) { staticPath = join(distDir, 'index.html'); } if (existsSync(staticPath)) { const stats = statSync(staticPath); if (stats.isFile()) { res.writeHead(200, { 'Content-Type': getMimeType(staticPath), 'Access-Control-Allow-Origin': '*' }); createReadStream(staticPath).pipe(res); return; } } // 404 res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); }); const port = parseInt(options.port, 10); server.listen(port, () => { console.log(`\n开发服务器已启动:http://localhost:${port}`); console.log(`内容目录:${contentDir}`); console.log(`索引文件:http://localhost:${port}/__CONTENT_INDEX.json\n`); }); };