From 8d7f745df2a1b7cee191a21dff0637a03fd47d72 Mon Sep 17 00:00:00 2001 From: hypercross Date: Thu, 26 Feb 2026 13:35:09 +0800 Subject: [PATCH] feat: ai's attempt --- package-lock.json | 29 ++++++ package.json | 1 + src/cli/commands/compile.ts | 4 +- src/cli/commands/serve.ts | 196 ++++++++++++++++++++++++++++++++++-- src/cli/index.ts | 1 + src/cli/types.ts | 3 +- src/data-loader/index.ts | 25 ++++- 7 files changed, 245 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index a61a6d9..3a3405a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@solidjs/router": "^0.15.0", + "chokidar": "^5.0.0", "commander": "^12.1.0", "csv-parse": "^5.5.6", "marked": "^14.1.0", @@ -2376,6 +2377,21 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -3116,6 +3132,19 @@ "node": ">=4" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reduce-configs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/reduce-configs/-/reduce-configs-1.1.1.tgz", diff --git a/package.json b/package.json index 84913ec..0296fd2 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "license": "MIT", "dependencies": { "@solidjs/router": "^0.15.0", + "chokidar": "^5.0.0", "commander": "^12.1.0", "csv-parse": "^5.5.6", "marked": "^14.1.0", diff --git a/src/cli/commands/compile.ts b/src/cli/commands/compile.ts index 2b08370..20c9538 100644 --- a/src/cli/commands/compile.ts +++ b/src/cli/commands/compile.ts @@ -1,6 +1,6 @@ -import type { CommandHandler } from '../types.js'; +import type { CompileCommandHandler } from '../types.js'; -export const compileCommand: CommandHandler = async (dir, options) => { +export const compileCommand: CompileCommandHandler = async (dir, options) => { console.log(`开始编译...`); console.log(`目录:${dir}`); console.log(`输出目录:${options.output}`); diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 1e395b3..4c79a3b 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -1,14 +1,190 @@ -import type { CommandHandler } from '../types.js'; +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'; -export const serveCommand: CommandHandler = async (dir, options) => { - console.log(`启动开发服务器...`); - console.log(`目录:${dir}`); - console.log(`端口:${options.port}`); +interface ContentIndex { + [path: string]: string; +} + +/** + * 扫描目录内的 .md 和 .csv 文件,生成索引 + */ +function scanDirectory(dir: string): ContentIndex { + const index: ContentIndex = {}; - // TODO: 实现开发服务器逻辑 - // 1. 扫描目录下的所有 .md 文件 - // 2. 启动 rsbuild 开发服务器 - // 3. 监听文件变化 + 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); + } + } + } + } - console.log('开发服务器已启动:http://localhost:' + options.port); + 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`); + }); }; diff --git a/src/cli/index.ts b/src/cli/index.ts index f45472d..3d78669 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { serveCommand } from './commands/serve.js'; import { compileCommand } from './commands/compile.js'; +import type { ServeOptions, CompileOptions } from './types.js'; const program = new Command(); diff --git a/src/cli/types.ts b/src/cli/types.ts index 1616fd2..deab7c8 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -6,7 +6,8 @@ export interface CompileOptions { output: string; } -export type CommandHandler = (dir: string, options: ServeOptions | CompileOptions) => Promise; +export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise; +export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise; export interface MarkdownFile { path: string; diff --git a/src/data-loader/index.ts b/src/data-loader/index.ts index edca696..5c33853 100644 --- a/src/data-loader/index.ts +++ b/src/data-loader/index.ts @@ -1,5 +1,6 @@ let dataIndex: Record | null = null; let isDevIndexLoaded = false; +let isCliIndexLoaded = false; /** * 设置全局数据索引(CLI 环境使用) @@ -42,13 +43,35 @@ async function loadDevIndex(): Promise { } } +/** + * 在 CLI 环境从 /__CONTENT_INDEX.json 加载索引 + */ +async function loadCliIndex(): Promise { + if (isCliIndexLoaded) return; + isCliIndexLoaded = true; + + try { + const response = await fetch('/__CONTENT_INDEX.json'); + if (!response.ok) { + throw new Error('Failed to fetch content index'); + } + const index = await response.json(); + dataIndex = { ...dataIndex, ...index }; + } catch (e) { + // CLI 索引不可用时忽略(可能不在 CLI 环境) + } +} + /** * 异步加载数据 * @param path 数据路径 * @returns 数据内容 */ export async function fetchData(path: string): Promise { - // dev 环境:先加载 glob 索引 + // CLI 环境:先从 /__CONTENT_INDEX.json 加载索引 + await loadCliIndex(); + + // dev 环境:加载 glob 索引 await loadDevIndex(); // 首先尝试从索引获取