From b5fbdc17d28cf65b993ef66e675803242162a381 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 26 Feb 2026 13:37:10 +0800 Subject: [PATCH] fix: path --- src/cli/commands/serve.ts | 223 +++++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 88 deletions(-) diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 4c79a3b..99f0af4 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -2,29 +2,56 @@ 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 { 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 和 .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); @@ -38,32 +65,102 @@ function scanDirectory(dir: string): ContentIndex { } } } - + 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', +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); }; - return mimeTypes[ext] || 'application/octet-stream'; } /** @@ -73,12 +170,12 @@ 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, { @@ -86,7 +183,7 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => { persistent: true, ignoreInitial: true, }); - + watcher .on('add', (path) => { if (path.endsWith('.md') || path.endsWith('.csv')) { @@ -119,69 +216,19 @@ export const serveCommand: ServeCommandHandler = async (dir, options) => { console.log(`[删除] ${relPath}`); } }); - + + // 创建请求处理器 + const handleRequest = createRequestHandler( + contentDir, + distDir, + () => contentIndex, + ); + // 创建 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 server = createServer(handleRequest); + const port = parseInt(options.port, 10); - + server.listen(port, () => { console.log(`\n开发服务器已启动:http://localhost:${port}`); console.log(`内容目录:${contentDir}`);