import { createReadStream, promises as fs } from "node:fs"; import { extname, join, normalize, relative, sep } from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; import { defineConfig, type Plugin } from "vite"; import vue from "@vitejs/plugin-vue"; const siteRoot = join(process.cwd(), "test-vitepress"); interface LocalPagePayload { path: string; title: string; description: string; content: string; } interface LocalSiteSettingsPayload { title: string; description: string; logo: string; lastUpdated: boolean; localSearch: boolean; outline: boolean; socialKind: string; socialLink: string; } const configPath = join(siteRoot, ".vitepress", "config.ts"); function isMarkdownPath(filePath: string) { return filePath.endsWith(".md") && !filePath.includes(`${sep}node_modules${sep}`); } function toPosixPath(filePath: string) { return filePath.split(sep).join("/"); } function resolveSitePath(relativePath: string) { const normalizedPath = normalize(join(siteRoot, relativePath)); if (!normalizedPath.startsWith(siteRoot) || !normalizedPath.endsWith(".md")) { throw new Error("Invalid site path"); } return normalizedPath; } function parseFrontmatter(source: string) { const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); if (!match) { return { title: "", description: "", content: source, }; } const frontmatter = match[1]; const title = frontmatter.match(/^title:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; return { title, description, content: source.slice(match[0].length), }; } function stringifyPage(page: LocalPagePayload) { const frontmatter = [ "---", `title: ${page.title || "未命名页面"}`, `description: ${page.description || ""}`, "---", "", ].join("\n"); return `${frontmatter}${page.content.trimStart()}`; } function readStringValue(source: string, key: string, fallback = "") { const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`); return source.match(pattern)?.[1] ?? fallback; } function readBooleanValue(source: string, key: string, fallback = false) { const pattern = new RegExp(`${key}:\\s*(true|false)`); const value = source.match(pattern)?.[1]; return value ? value === "true" : fallback; } function parseSiteSettings(source: string): LocalSiteSettingsPayload { const socialLinksSource = source.match(/socialLinks:\s*\[([\s\S]*?)\]/)?.[1] ?? ""; return { title: readStringValue(source, "title", "VitePress Site"), description: readStringValue(source, "description"), logo: readStringValue(source, "logo", "/logo.svg"), lastUpdated: readBooleanValue(source, "lastUpdated", true), localSearch: /search:\s*{[\s\S]*?provider:\s*["']local["'][\s\S]*?}/.test(source), outline: readBooleanValue(source, "outline", true), socialKind: readStringValue(socialLinksSource, "icon", "github"), socialLink: readStringValue(socialLinksSource, "link", "https://github.com/example/vitepress-cms"), }; } function stringifySiteConfig(settings: LocalSiteSettingsPayload) { const searchConfig = settings.localSearch ? ` search: { provider: "local", },` : ""; return `import { defineConfig } from "vitepress"; export default defineConfig({ title: ${JSON.stringify(settings.title)}, description: ${JSON.stringify(settings.description)}, lastUpdated: ${settings.lastUpdated}, themeConfig: { logo: ${JSON.stringify(settings.logo)}, outline: ${settings.outline}, nav: [ { text: "首页", link: "/" }, { text: "指南", link: "/guide/getting-started" }, { text: "配置", link: "/guide/config" }, { text: "更新日志", link: "/changelog" }, ], sidebar: { "/guide/": [ { text: "指南", items: [ { text: "快速开始", link: "/guide/getting-started" }, { text: "页面管理", link: "/guide/pages" }, { text: "配置说明", link: "/guide/config" }, ], }, ], }, socialLinks: [ { icon: ${JSON.stringify(settings.socialKind)}, link: ${JSON.stringify(settings.socialLink)} }, ], footer: { message: "Powered by VitePress-CMS", copyright: "Copyright 2026", }, ${searchConfig} }, }); `; } async function collectMarkdownFiles(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); const nestedFiles = await Promise.all( entries.map(async (entry) => { const entryPath = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".vitepress") { return []; } return collectMarkdownFiles(entryPath); } return isMarkdownPath(entryPath) ? [entryPath] : []; }), ); return nestedFiles.flat(); } function readRequestBody(request: IncomingMessage) { return new Promise((resolve, reject) => { let body = ""; request.on("data", (chunk) => { body += chunk; }); request.on("end", () => resolve(body)); request.on("error", reject); }); } function sendJson(response: ServerResponse, status: number, payload: unknown) { response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.end(JSON.stringify(payload)); } function localSiteApi(): Plugin { return { name: "vitepress-cms-local-site-api", configureServer(server) { server.middlewares.use("/api/local-site/pages", async (request, response) => { try { if (request.method === "GET") { const files = await collectMarkdownFiles(siteRoot); const contentFiles = files.filter((filePath) => { const sitePath = toPosixPath(relative(siteRoot, filePath)); return sitePath !== "README.md"; }); const pages = await Promise.all( contentFiles.sort().map(async (filePath) => { const source = await fs.readFile(filePath, "utf-8"); const parsed = parseFrontmatter(source); const path = toPosixPath(relative(siteRoot, filePath)); return { id: path, path, title: parsed.title || path.replace(/\.md$/, ""), description: parsed.description, content: parsed.content, }; }), ); sendJson(response, 200, { pages }); return; } if (request.method === "PUT") { const payload = JSON.parse(await readRequestBody(request)) as LocalPagePayload; const filePath = resolveSitePath(payload.path); await fs.writeFile(filePath, stringifyPage(payload), "utf-8"); sendJson(response, 200, { ok: true }); return; } sendJson(response, 405, { error: "Method not allowed" }); } catch (error) { sendJson(response, 500, { error: error instanceof Error ? error.message : "Unknown local site API error", }); } }); server.middlewares.use("/api/local-site/settings", async (request, response) => { try { if (request.method === "GET") { const source = await fs.readFile(configPath, "utf-8"); sendJson(response, 200, { settings: parseSiteSettings(source) }); return; } if (request.method === "PUT") { const settings = JSON.parse(await readRequestBody(request)) as LocalSiteSettingsPayload; await fs.writeFile(configPath, stringifySiteConfig(settings), "utf-8"); sendJson(response, 200, { ok: true }); return; } sendJson(response, 405, { error: "Method not allowed" }); } catch (error) { sendJson(response, 500, { error: error instanceof Error ? error.message : "Unknown local site settings API error", }); } }); server.middlewares.use("/api/local-site/assets/", (request, response, next) => { const url = request.url ?? ""; const filePath = normalize(join(siteRoot, "public", decodeURIComponent(url))); if (!filePath.startsWith(join(siteRoot, "public"))) { sendJson(response, 403, { error: "Forbidden" }); return; } response.setHeader("Content-Type", extname(filePath) === ".svg" ? "image/svg+xml" : "application/octet-stream"); createReadStream(filePath).on("error", next).pipe(response); }); }, }; } export default defineConfig({ plugins: [localSiteApi(), vue()], });