feat: initialize VitePress CMS
This commit is contained in:
283
vite.config.ts
Normal file
283
vite.config.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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()],
|
||||
});
|
||||
Reference in New Issue
Block a user