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, sep, relative } from "path"; import { watch } from "chokidar"; interface ContentIndex { [path: string]: string; } /** * MIME 类型映射 */ const MIME_TYPES: 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", }; /** * 获取文件扩展名对应的 MIME 类型 */ function getMimeType(filePath: string): string { const ext = extname(filePath).toLowerCase(); return MIME_TYPES[ext] || "application/octet-stream"; } /** * 扫描目录内的 .md 文件,生成索引 */ 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")) { try { const content = readFileSync(fullPath, "utf-8"); index[normalizedRelPath] = content; } catch (e) { console.error(`读取文件失败:${fullPath}`, e); } } } } scan(dir, ""); return index; } /** * 发送文件响应 */ function sendFile(res: ServerResponse, filePath: string) { res.writeHead(200, { "Content-Type": getMimeType(filePath), "Access-Control-Allow-Origin": "*", }); createReadStream(filePath).pipe(res); } /** * 发送 404 响应 */ function send404(res: ServerResponse) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); } /** * 发送 JSON 响应 */ function sendJson(res: ServerResponse, data: unknown) { res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", }); res.end(JSON.stringify(data, null, 2)); } /** * 尝试提供静态文件 * @returns 如果文件存在并已成功发送则返回 true */ function tryServeStatic( res: ServerResponse, filePath: string, dir: string, ): boolean { const fullPath = join(dir, filePath); if (!existsSync(fullPath)) { return false; } const stats = statSync(fullPath); if (!stats.isFile()) { return false; } sendFile(res, fullPath); return true; } /** * 创建请求处理器 */ function createRequestHandler( contentDir: string, distDir: string, getIndex: () => ContentIndex, ) { return (req: IncomingMessage, res: ServerResponse) => { const url = req.url || "/"; const filePath = decodeURIComponent(url.split("?")[0]); // 1. 处理 /__CONTENT_INDEX.json if (filePath === "/__CONTENT_INDEX.json") { sendJson(res, getIndex()); return; } // 2. 处理 /static/ 目录(从 dist/web) if (filePath.startsWith("/static/")) { if (tryServeStatic(res, filePath, distDir)) { return; } send404(res); return; } // 3. 处理 /content/ 目录 const relativePath = filePath.slice(1); // 去掉开头的 / if (tryServeStatic(res, relativePath, contentDir)) { return; } // 4. 处理 SPA 路由:返回 index.html if (tryServeStatic(res, "index.html", distDir)) { return; } // 5. 404 send404(res); }; } /** * 启动开发服务器 */ 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")) { 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")) { 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")) { const relPath = "/" + relative(contentDir, path).split(sep).join("/"); delete contentIndex[relPath]; console.log(`[删除] ${relPath}`); } }); // 创建请求处理器 const handleRequest = createRequestHandler( contentDir, distDir, () => contentIndex, ); // 创建 HTTP 服务器 const server = createServer(handleRequest); 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`); }); };