284 lines
8.7 KiB
TypeScript
284 lines
8.7 KiB
TypeScript
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<string[]> {
|
|
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<string>((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()],
|
|
});
|