feat: initialize VitePress CMS

This commit is contained in:
Coldsmile_7
2026-06-05 23:21:41 +08:00
commit 928f742d5e
45 changed files with 8214 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules/
test-vitepress/node_modules/
# Build output
dist/
test-vitepress/.vitepress/dist/
test-vitepress/.vitepress/.temp/
test-vitepress/.vitepress/cache/
# Local data
data/
*.sqlite
*.sqlite-shm
*.sqlite-wal
# Environment
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# VitePress-CMS
VitePress-CMS 是一个面向 VitePress 的可视化内容管理后台。它的目标是让用户通过网页界面管理 Markdown 页面、站点结构、主题配置与 GitHub 发布流程。
## 当前版本
当前项目已经从静态原型升级为 Vue 3 + TypeScript + Vite 工程。
已完成的原型能力:
- 页面目录、搜索与新建页面
- Markdown 编辑与预览切换
- Frontmatter 标题和描述编辑
- 导航栏与侧边栏管理界面
- 站点基础配置界面
- 发布中心与部署状态模拟
## 本地运行
```bash
npm install
npm run dev
```
Windows PowerShell 如果拦截 `npm.ps1`,可以使用:
```bash
npm.cmd install
npm.cmd run dev
```
## 初始账户
项目启动时会自动创建 SQLite 数据库:
```text
data/vitepress-cms.sqlite
```
如果数据库里还没有系统管理员,会自动创建初始账户:
```text
邮箱admin@example.com
密码admin123456
角色system_admin
```
可以通过环境变量修改初始账户:
```bash
CMS_ADMIN_EMAIL=admin@your-domain.com
CMS_ADMIN_PASSWORD=change-me
```
## 构建验证
```bash
npm run build
```
## 测试站点
VitePress 测试站点放在项目内的独立子目录:
```text
test-vitepress/
```
它有自己的 `package.json` 和依赖,不会让 VitePress 成为 CMS 主项目的一部分。这样既方便开发测试,也能保持主项目依赖干净。
运行测试站点:
```bash
cd test-vitepress
npm install
npm run dev
```
## GitHub 连接
当前版本支持两种 GitHub 连接方式:
1. GitHub OAuth 登录后选择仓库。
2. 手动输入 token、owner、repo、branch 和站点目录。
使用 OAuth 登录前,需要创建 GitHub OAuth App并配置环境变量
```bash
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
```
开发环境 callback URL
```text
http://localhost:5173/api/github/callback
```
Token 或 OAuth 授权至少需要仓库内容读写权限。保存页面或配置时CMS 会通过 GitHub Contents API 创建 commit。
站点目录示例:
- VitePress 在仓库根目录:留空
- VitePress 在 `docs/`:填写 `docs`
## 下一步
1. 建立 GitHub OAuth 登录流程。
2. 读取仓库中的 `docs/` 页面文件。
3. 解析 `.vitepress/config.ts` 中的常用配置。
4. 将页面和配置修改提交为 GitHub commit。
5. 显示 GitHub Pages 或 Actions 部署状态。

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VitePress-CMS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1422
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "vitepress-cms",
"version": "0.1.0",
"private": true,
"description": "A visual CMS prototype for managing VitePress sites.",
"type": "module",
"scripts": {
"dev": "node server/index.mjs",
"dev:vite": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"start": "node server/index.mjs",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}

45
server.mjs Normal file
View File

@@ -0,0 +1,45 @@
import { createReadStream, existsSync } from "node:fs";
import { extname, join, normalize } from "node:path";
import { createServer } from "node:http";
const port = Number(process.env.PORT || 5173);
const root = process.cwd();
const mimeTypes = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
};
function resolveRequest(url) {
const pathname = decodeURIComponent(new URL(url, `http://localhost:${port}`).pathname);
const requestedPath = normalize(join(root, pathname === "/" ? "index.html" : pathname));
if (!requestedPath.startsWith(root)) {
return null;
}
return requestedPath;
}
createServer((request, response) => {
const filePath = resolveRequest(request.url || "/");
if (!filePath || !existsSync(filePath)) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
response.writeHead(200, {
"Content-Type": mimeTypes[extname(filePath)] || "application/octet-stream",
});
createReadStream(filePath).pipe(response);
}).listen(port, () => {
console.log(`VitePress-CMS prototype running at http://localhost:${port}`);
});

96
server/auth.mjs Normal file
View File

@@ -0,0 +1,96 @@
import { db } from "./db.mjs";
import { json, readBody } from "./http.mjs";
import { clearSessionCookie, createSession, deleteSession, getSession, parseCookies, sessionCookie } from "./session.mjs";
import { verifyPassword } from "./security.mjs";
function publicUser(user) {
if (!user) return null;
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatar_url,
role: user.role,
};
}
export function getRequestSession(request) {
const cookies = parseCookies(request.headers.cookie);
const sessionId = cookies.vpc_session;
const session = getSession(sessionId);
return { sessionId, session };
}
export function requireCmsUser(request, response) {
const { session } = getRequestSession(request);
if (!session?.user?.id) {
json(response, 401, { error: "Not signed in" });
return undefined;
}
return session.user;
}
async function handleMe(request, response) {
const { session } = getRequestSession(request);
json(response, 200, { user: session?.user ?? null });
}
async function handleLogin(request, response) {
const body = JSON.parse(await readBody(request) || "{}");
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
if (!email || !password) {
json(response, 400, { error: "Email and password are required" });
return;
}
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
if (!user?.password_hash || !verifyPassword(password, user.password_hash)) {
json(response, 401, { error: "Invalid email or password" });
return;
}
const sessionUser = publicUser(user);
const sessionId = createSession({
user: sessionUser,
});
json(response, 200, { user: sessionUser }, { "Set-Cookie": sessionCookie(sessionId) });
}
async function handleLogout(request, response) {
const { sessionId } = getRequestSession(request);
deleteSession(sessionId);
json(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() });
}
export async function handleAuthApi(request, response, url) {
try {
if (url.pathname === "/api/auth/me" && request.method === "GET") {
await handleMe(request, response);
return true;
}
if (url.pathname === "/api/auth/login" && request.method === "POST") {
await handleLogin(request, response);
return true;
}
if (url.pathname === "/api/auth/logout" && request.method === "POST") {
await handleLogout(request, response);
return true;
}
return false;
} catch (error) {
json(response, 500, {
error: error instanceof Error ? error.message : "Unknown auth API error",
});
return true;
}
}

47
server/bootstrap.mjs Normal file
View File

@@ -0,0 +1,47 @@
import { db, getSetting, migrate, setSetting } from "./db.mjs";
import { hashPassword } from "./security.mjs";
const defaultAdminEmail = process.env.CMS_ADMIN_EMAIL || "admin@example.com";
const defaultAdminPassword = process.env.CMS_ADMIN_PASSWORD || "admin123456";
function seedAdmin() {
const admin = db.prepare("SELECT id FROM users WHERE role = 'system_admin' LIMIT 1").get();
if (admin) return;
db.prepare(`
INSERT INTO users (email, password_hash, name, role, email_verified)
VALUES (?, ?, ?, 'system_admin', 1)
`).run(defaultAdminEmail, hashPassword(defaultAdminPassword), "Administrator");
}
function seedSettings() {
const seeds = {
"site.title": "VitePress-CMS",
"auth.allow_email_login": "true",
"auth.allow_github_login": "true",
"auth.allow_registration": "false",
"smtp.host": "",
"smtp.port": "587",
"smtp.username": "",
"smtp.password": "",
"smtp.sender_email": "",
"github.api_base_url": "https://api.github.com",
"github.web_base_url": "https://github.com",
"github.oauth_client_id": "",
"github.oauth_client_secret": "",
};
Object.entries(seeds).forEach(([key, value]) => {
if (getSetting(key, undefined) === undefined) {
setSetting(key, value, key.includes("secret") || key.includes("password"));
}
});
}
export function bootstrap() {
migrate();
seedAdmin();
seedSettings();
console.log(`Initial admin: ${defaultAdminEmail}`);
}

86
server/db.mjs Normal file
View File

@@ -0,0 +1,86 @@
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
const dbPath = join(process.cwd(), "data", "vitepress-cms.sqlite");
mkdirSync(dirname(dbPath), { recursive: true });
export const db = new DatabaseSync(dbPath);
db.exec("PRAGMA foreign_keys = ON");
export function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE,
password_hash TEXT,
name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
email_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
provider_login TEXT NOT NULL,
access_token TEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(provider, provider_user_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
github_owner TEXT,
github_repo TEXT,
github_branch TEXT NOT NULL DEFAULT 'main',
site_root TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(owner_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'editor',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, user_id),
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
encrypted INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
export function getSetting(key, fallback = "") {
const row = db.prepare("SELECT value FROM system_settings WHERE key = ?").get(key);
return row?.value ?? fallback;
}
export function setSetting(key, value, encrypted = false) {
db.prepare(`
INSERT INTO system_settings (key, value, encrypted, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
encrypted = excluded.encrypted,
updated_at = CURRENT_TIMESTAMP
`).run(key, value, encrypted ? 1 : 0);
}

577
server/github.mjs Normal file
View File

@@ -0,0 +1,577 @@
import { randomBytes } from "node:crypto";
import { db } from "./db.mjs";
import { json, readBody, redirect } from "./http.mjs";
import { getRequestSession } from "./auth.mjs";
import { createSession, sessionCookie, updateSession } from "./session.mjs";
const oauthStates = new Map();
function getBaseUrl(request) {
const configuredBaseUrl = process.env.GITHUB_OAUTH_REDIRECT_BASE_URL;
if (configuredBaseUrl) return configuredBaseUrl.replace(/\/$/, "");
const host = request.headers.host ?? "localhost:5173";
const protocol = host.startsWith("localhost") || host.startsWith("127.0.0.1") ? "http" : "https";
return `${protocol}://${host}`;
}
function getOAuthConfig(request) {
const clientId = process.env.GITHUB_CLIENT_ID;
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
const baseUrl = getBaseUrl(request);
if (!clientId || !clientSecret) {
throw new Error("请先配置 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET");
}
return {
clientId,
clientSecret,
redirectUri: `${baseUrl}/api/github/callback`,
};
}
async function githubFetch(session, path, init = {}) {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${session.accessToken}`,
"X-GitHub-Api-Version": "2022-11-28",
...init.headers,
},
});
if (!response.ok) {
const details = await response.text();
throw new Error(`GitHub API error ${response.status}: ${details}`);
}
return response.json();
}
function requireSession(request, response) {
const { session } = getRequestSession(request);
if (!session?.github?.accessToken) {
json(response, 401, { error: "Not signed in" });
return undefined;
}
return {
...session,
accessToken: session.github.accessToken,
};
}
function publicCmsUser(user) {
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatar_url,
role: user.role,
};
}
function findOrCreateGithubUser(githubUser, accessToken, currentSession) {
const providerUserId = String(githubUser.id);
const linkedAccount = db
.prepare("SELECT user_id FROM oauth_accounts WHERE provider = 'github' AND provider_user_id = ?")
.get(providerUserId);
let user;
if (linkedAccount) {
user = db.prepare("SELECT * FROM users WHERE id = ?").get(linkedAccount.user_id);
} else if (currentSession?.user?.id) {
user = db.prepare("SELECT * FROM users WHERE id = ?").get(currentSession.user.id);
} else {
const email = githubUser.email ? String(githubUser.email).toLowerCase() : null;
const existingByEmail = email ? db.prepare("SELECT * FROM users WHERE email = ?").get(email) : null;
if (existingByEmail) {
user = existingByEmail;
} else {
const result = db
.prepare("INSERT INTO users (email, name, avatar_url, role, email_verified) VALUES (?, ?, ?, 'user', ?)")
.run(email, githubUser.name || githubUser.login, githubUser.avatar_url, email ? 1 : 0);
user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid);
}
}
db.prepare(`
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, provider_login, access_token, avatar_url, updated_at)
VALUES (?, 'github', ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(provider, provider_user_id) DO UPDATE SET
user_id = excluded.user_id,
provider_login = excluded.provider_login,
access_token = excluded.access_token,
avatar_url = excluded.avatar_url,
updated_at = CURRENT_TIMESTAMP
`).run(user.id, providerUserId, githubUser.login, accessToken, githubUser.avatar_url);
db.prepare("UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(
githubUser.avatar_url,
user.id,
);
return db.prepare("SELECT * FROM users WHERE id = ?").get(user.id);
}
async function handleLogin(request, response) {
const { clientId, redirectUri } = getOAuthConfig(request);
const state = randomBytes(16).toString("hex");
oauthStates.set(state, Date.now());
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
scope: "repo",
state,
});
redirect(response, `https://github.com/login/oauth/authorize?${params.toString()}`);
}
async function handleCallback(request, response, url) {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code || !state || !oauthStates.has(state)) {
redirect(response, "/?github=oauth_error");
return;
}
oauthStates.delete(state);
const { clientId, clientSecret, redirectUri } = getOAuthConfig(request);
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: redirectUri,
}),
});
const tokenPayload = await tokenResponse.json();
if (!tokenPayload.access_token) {
redirect(response, "/?github=token_error");
return;
}
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${tokenPayload.access_token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const user = await userResponse.json();
const { session: currentSession } = getRequestSession(request);
const cmsUser = findOrCreateGithubUser(user, tokenPayload.access_token, currentSession);
const sessionId = createSession({
user: publicCmsUser(cmsUser),
github: {
accessToken: tokenPayload.access_token,
login: user.login,
avatarUrl: user.avatar_url,
name: user.name,
},
});
redirect(response, "/?github=connected", {
"Set-Cookie": sessionCookie(sessionId),
});
}
async function handleMe(request, response) {
const { session } = getRequestSession(request);
if (!session) {
json(response, 200, { user: null });
return;
}
json(response, 200, { user: session.github ?? null });
}
async function handleLogout(request, response) {
const { sessionId, session } = getRequestSession(request);
if (session) {
const { github, ...restSession } = session;
updateSession(sessionId, restSession);
}
json(response, 200, { ok: true });
}
async function handleRepos(request, response) {
const session = requireSession(request, response);
if (!session) return;
const repos = await githubFetch(
session,
"/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member",
);
json(response, 200, {
repos: repos.map((repo) => ({
id: repo.id,
fullName: repo.full_name,
owner: repo.owner.login,
name: repo.name,
private: repo.private,
defaultBranch: repo.default_branch,
updatedAt: repo.updated_at,
})),
});
}
async function handleCreateRepo(request, response) {
const session = requireSession(request, response);
if (!session) return;
const body = JSON.parse(await readBody(request) || "{}");
if (!body.name) {
json(response, 400, { error: "Repository name is required" });
return;
}
const repo = await githubFetch(session, "/user/repos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: body.name,
private: Boolean(body.private),
auto_init: true,
description: body.description || "Created by VitePress-CMS",
}),
});
json(response, 201, {
repo: {
id: repo.id,
fullName: repo.full_name,
owner: repo.owner.login,
name: repo.name,
private: repo.private,
defaultBranch: repo.default_branch,
updatedAt: repo.updated_at,
},
});
}
function normalizeSiteRoot(siteRoot = "") {
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
}
function toRepoPath(siteRoot, path) {
const normalizedRoot = normalizeSiteRoot(siteRoot);
return normalizedRoot ? `${normalizedRoot}/${path}` : path;
}
function fromRepoPath(siteRoot, path) {
const normalizedRoot = normalizeSiteRoot(siteRoot);
return normalizedRoot && path.startsWith(`${normalizedRoot}/`) ? path.slice(normalizedRoot.length + 1) : path;
}
function decodeBase64Content(content) {
return Buffer.from(content.replace(/\n/g, ""), "base64").toString("utf-8");
}
function encodeBase64Content(content) {
return Buffer.from(content, "utf-8").toString("base64");
}
function parseFrontmatter(source) {
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 stringifyMarkdownPage(page) {
return [
"---",
`title: ${page.title || "未命名页面"}`,
`description: ${page.description || ""}`,
"---",
"",
page.content.trimStart(),
].join("\n");
}
function readStringValue(source, key, fallback = "") {
const pattern = new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`);
return source.match(pattern)?.[1] ?? fallback;
}
function readBooleanValue(source, key, fallback = false) {
const pattern = new RegExp(`${key}:\\s*(true|false)`);
const value = source.match(pattern)?.[1];
return value ? value === "true" : fallback;
}
function parseSiteSettings(source) {
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) {
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}
},
});
`;
}
function getSiteQuery(url) {
const owner = url.searchParams.get("owner");
const repo = url.searchParams.get("repo");
const branch = url.searchParams.get("branch") || "main";
const siteRoot = url.searchParams.get("siteRoot") || "";
if (!owner || !repo) {
throw new Error("owner and repo are required");
}
return { owner, repo, branch, siteRoot };
}
async function getRepoContent(session, site, path) {
const repoPath = toRepoPath(site.siteRoot, path);
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
return githubFetch(
session,
`/repos/${site.owner}/${site.repo}/contents/${encodedPath}?ref=${encodeURIComponent(site.branch)}`,
);
}
async function handleSitePages(request, response, url) {
const session = requireSession(request, response);
if (!session) return;
if (request.method === "GET") {
const site = getSiteQuery(url);
const tree = await githubFetch(
session,
`/repos/${site.owner}/${site.repo}/git/trees/${encodeURIComponent(site.branch)}?recursive=1`,
);
const siteRoot = normalizeSiteRoot(site.siteRoot);
const prefix = siteRoot ? `${siteRoot}/` : "";
const markdownFiles = tree.tree
.filter((entry) => entry.type === "blob")
.filter((entry) => entry.path.endsWith(".md"))
.filter((entry) => !entry.path.includes("/node_modules/"))
.filter((entry) => entry.path !== `${prefix}README.md`)
.filter((entry) => !prefix || entry.path.startsWith(prefix));
const pages = await Promise.all(
markdownFiles.sort((a, b) => a.path.localeCompare(b.path)).map(async (entry) => {
const file = await getRepoContent(session, site, fromRepoPath(site.siteRoot, entry.path));
const source = decodeBase64Content(file.content);
const parsed = parseFrontmatter(source);
const path = fromRepoPath(site.siteRoot, entry.path);
return {
id: path,
path,
sha: file.sha,
title: parsed.title || path.replace(/\.md$/, ""),
description: parsed.description,
content: parsed.content,
};
}),
);
json(response, 200, { pages });
return;
}
if (request.method === "PUT") {
const body = JSON.parse(await readBody(request) || "{}");
const site = body.site;
const page = body.page;
const repoPath = toRepoPath(site.siteRoot, page.path);
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: `docs: update ${page.path} from VitePress-CMS`,
content: encodeBase64Content(stringifyMarkdownPage(page)),
branch: site.branch,
sha: page.sha,
}),
});
json(response, 200, { sha: result.content.sha });
return;
}
json(response, 405, { error: "Method not allowed" });
}
async function handleSiteSettings(request, response, url) {
const session = requireSession(request, response);
if (!session) return;
if (request.method === "GET") {
const site = getSiteQuery(url);
const file = await getRepoContent(session, site, ".vitepress/config.ts");
json(response, 200, {
settings: parseSiteSettings(decodeBase64Content(file.content)),
sha: file.sha,
});
return;
}
if (request.method === "PUT") {
const body = JSON.parse(await readBody(request) || "{}");
const site = body.site;
const settings = body.settings;
const repoPath = toRepoPath(site.siteRoot, ".vitepress/config.ts");
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
const result = await githubFetch(session, `/repos/${site.owner}/${site.repo}/contents/${encodedPath}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "docs: update VitePress config from VitePress-CMS",
content: encodeBase64Content(stringifySiteConfig(settings)),
branch: site.branch,
sha: body.sha,
}),
});
json(response, 200, { sha: result.content.sha });
return;
}
json(response, 405, { error: "Method not allowed" });
}
export async function handleGithubApi(request, response, url) {
try {
if (url.pathname === "/api/github/login" && request.method === "GET") {
await handleLogin(request, response);
return true;
}
if (url.pathname === "/api/github/callback" && request.method === "GET") {
await handleCallback(request, response, url);
return true;
}
if (url.pathname === "/api/github/me" && request.method === "GET") {
await handleMe(request, response);
return true;
}
if (url.pathname === "/api/github/logout" && request.method === "POST") {
await handleLogout(request, response);
return true;
}
if (url.pathname === "/api/github/repos" && request.method === "GET") {
await handleRepos(request, response);
return true;
}
if (url.pathname === "/api/github/repos" && request.method === "POST") {
await handleCreateRepo(request, response);
return true;
}
if (url.pathname === "/api/github/site/pages") {
await handleSitePages(request, response, url);
return true;
}
if (url.pathname === "/api/github/site/settings") {
await handleSiteSettings(request, response, url);
return true;
}
return false;
} catch (error) {
json(response, 500, {
error: error instanceof Error ? error.message : "Unknown GitHub API error",
});
return true;
}
}

26
server/http.mjs Normal file
View File

@@ -0,0 +1,26 @@
export function json(response, status, payload, headers = {}) {
response.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
...headers,
});
response.end(JSON.stringify(payload));
}
export function redirect(response, location, headers = {}) {
response.writeHead(302, {
Location: location,
...headers,
});
response.end();
}
export function readBody(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
});
request.on("end", () => resolve(body));
request.on("error", reject);
});
}

81
server/index.mjs Normal file
View File

@@ -0,0 +1,81 @@
import { createReadStream, existsSync } from "node:fs";
import { extname, join, normalize } from "node:path";
import { createServer } from "node:http";
import { createServer as createViteServer } from "vite";
import { bootstrap } from "./bootstrap.mjs";
import { handleAuthApi } from "./auth.mjs";
import { handleGithubApi } from "./github.mjs";
import { handleSettingsApi } from "./settings.mjs";
const isProduction = process.env.NODE_ENV === "production";
const port = Number(process.env.PORT || 5173);
const root = process.cwd();
bootstrap();
const mimeTypes = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
};
const vite = isProduction
? undefined
: await createViteServer({
server: {
middlewareMode: true,
host: "0.0.0.0",
},
appType: "spa",
});
function sendNotFound(response) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
function serveStatic(request, response) {
const pathname = decodeURIComponent(new URL(request.url ?? "/", `http://localhost:${port}`).pathname);
const requestedPath = normalize(join(root, "dist", pathname === "/" ? "index.html" : pathname));
const fallbackPath = join(root, "dist", "index.html");
const filePath = existsSync(requestedPath) ? requestedPath : fallbackPath;
if (!filePath.startsWith(join(root, "dist")) || !existsSync(filePath)) {
sendNotFound(response);
return;
}
response.writeHead(200, {
"Content-Type": mimeTypes[extname(filePath)] || "application/octet-stream",
});
createReadStream(filePath).pipe(response);
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
if (await handleAuthApi(request, response, url)) {
return;
}
if (await handleSettingsApi(request, response, url)) {
return;
}
if (await handleGithubApi(request, response, url)) {
return;
}
if (vite) {
vite.middlewares(request, response, () => sendNotFound(response));
return;
}
serveStatic(request, response);
});
server.listen(port, "0.0.0.0", () => {
console.log(`VitePress-CMS running at http://localhost:${port}`);
});

24
server/security.mjs Normal file
View File

@@ -0,0 +1,24 @@
import { pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
const iterations = 120000;
const keyLength = 32;
const digest = "sha256";
export function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const hash = pbkdf2Sync(password, salt, iterations, keyLength, digest).toString("hex");
return `pbkdf2:${iterations}:${salt}:${hash}`;
}
export function verifyPassword(password, storedHash) {
const [scheme, storedIterations, salt, hash] = storedHash.split(":");
if (scheme !== "pbkdf2" || !storedIterations || !salt || !hash) {
return false;
}
const candidate = pbkdf2Sync(password, salt, Number(storedIterations), keyLength, digest);
const expected = Buffer.from(hash, "hex");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}

59
server/session.mjs Normal file
View File

@@ -0,0 +1,59 @@
import { randomBytes } from "node:crypto";
const sessions = new Map();
export function createSession(data) {
const id = randomBytes(24).toString("hex");
sessions.set(id, {
...data,
createdAt: Date.now(),
});
return id;
}
export function getSession(id) {
if (!id) return undefined;
return sessions.get(id);
}
export function updateSession(id, data) {
if (!id || !sessions.has(id)) return undefined;
const nextSession = {
...sessions.get(id),
...data,
};
sessions.set(id, nextSession);
return nextSession;
}
export function deleteSession(id) {
if (!id) return;
sessions.delete(id);
}
export function parseCookies(cookieHeader = "") {
return Object.fromEntries(
cookieHeader
.split(";")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const index = item.indexOf("=");
return [item.slice(0, index), decodeURIComponent(item.slice(index + 1))];
}),
);
}
export function sessionCookie(id) {
return [
`vpc_session=${encodeURIComponent(id)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
"Max-Age=2592000",
].join("; ");
}
export function clearSessionCookie() {
return "vpc_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
}

70
server/settings.mjs Normal file
View File

@@ -0,0 +1,70 @@
import { db, setSetting } from "./db.mjs";
import { requireCmsUser } from "./auth.mjs";
import { json, readBody } from "./http.mjs";
function requireAdmin(request, response) {
const user = requireCmsUser(request, response);
if (!user) return undefined;
if (user.role !== "system_admin") {
json(response, 403, { error: "System admin permission is required" });
return undefined;
}
return user;
}
function publicSetting(row) {
return {
key: row.key,
value: row.encrypted ? "" : row.value,
encrypted: Boolean(row.encrypted),
updatedAt: row.updated_at,
};
}
async function handleGetSettings(request, response) {
if (!requireAdmin(request, response)) return;
const rows = db
.prepare("SELECT key, value, encrypted, updated_at FROM system_settings ORDER BY key")
.all();
json(response, 200, { settings: rows.map(publicSetting) });
}
async function handleSaveSettings(request, response) {
if (!requireAdmin(request, response)) return;
const body = JSON.parse(await readBody(request) || "{}");
const settings = Array.isArray(body.settings) ? body.settings : [];
settings.forEach((setting) => {
if (!setting.key) return;
setSetting(String(setting.key), String(setting.value ?? ""), Boolean(setting.encrypted));
});
json(response, 200, { ok: true });
}
export async function handleSettingsApi(request, response, url) {
try {
if (url.pathname === "/api/admin/settings" && request.method === "GET") {
await handleGetSettings(request, response);
return true;
}
if (url.pathname === "/api/admin/settings" && request.method === "PUT") {
await handleSaveSettings(request, response);
return true;
}
return false;
} catch (error) {
json(response, 500, {
error: error instanceof Error ? error.message : "Unknown settings API error",
});
return true;
}
}

465
src/App.vue Normal file
View File

@@ -0,0 +1,465 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import {
loadGitHubPages,
loadGitHubSettings,
saveGitHubPage,
saveGitHubSettings,
} from "./api/githubSite";
import { loadCurrentUser, loginWithEmail, logoutCms } from "./api/auth";
import {
createGitHubRepository,
loadGitHubRepositories,
loadGitHubUser,
logoutGitHub,
startGitHubLogin,
} from "./api/githubAuth";
import {
loadLocalPages,
loadLocalSettings,
saveLocalPage,
saveLocalSettings,
} from "./api/localSite";
import AppSidebar from "./components/AppSidebar.vue";
import ConfigPanel from "./components/ConfigPanel.vue";
import ConnectPanel from "./components/ConnectPanel.vue";
import ContentPanel from "./components/ContentPanel.vue";
import LoginView from "./components/LoginView.vue";
import PublishPanel from "./components/PublishPanel.vue";
import StructurePanel from "./components/StructurePanel.vue";
import { mockNavItems, mockPages, mockSidebarGroups, mockSiteSettings } from "./data/mockSite";
import type { CmsUser, DataSource, DocPage, GitHubConnection, GitHubRepository, GitHubUser, PanelKey } from "./types";
const panels: Array<{ key: PanelKey; label: string; icon: string }> = [
{ key: "connect", label: "仓库连接", icon: "G" },
{ key: "content", label: "页面管理", icon: "P" },
{ key: "structure", label: "站点结构", icon: "S" },
{ key: "config", label: "主题配置", icon: "C" },
{ key: "publish", label: "发布中心", icon: "R" },
];
const githubConnection = reactive<GitHubConnection>({
token: "",
owner: "",
repo: "",
branch: "main",
siteRoot: "",
});
const activePanel = ref<PanelKey>("content");
const dataSource = ref<DataSource>("local");
const hasGitHubConnection = ref(false);
const pages = reactive<DocPage[]>([]);
const navItems = reactive(mockNavItems.map((item) => ({ ...item })));
const sidebarGroups = reactive(
mockSidebarGroups.map((group) => ({
...group,
items: group.items.map((item) => ({ ...item })),
})),
);
const siteSettings = reactive({ ...mockSiteSettings });
const activePageId = ref("");
const settingsSha = ref<string>();
const isLoadingPages = ref(false);
const isConnecting = ref(false);
const isLoadingRepos = ref(false);
const pageSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const settingsSaveState = ref<"idle" | "saving" | "saved" | "error">("idle");
const githubUser = ref<GitHubUser | null>(null);
const currentUser = ref<CmsUser | null>(null);
const repositories = ref<GitHubRepository[]>([]);
const isAuthLoading = ref(true);
const loginError = ref("");
const activityItems = ref(["等待读取站点"]);
const activePage = computed(() => pages.find((page) => page.id === activePageId.value) ?? pages[0]);
const activePanelTitle = computed(() => panels.find((panel) => panel.key === activePanel.value)?.label ?? "");
const repoLabel = computed(() => {
if (dataSource.value === "github" && hasGitHubConnection.value) {
return `${githubConnection.owner}/${githubConnection.repo}`;
}
return "test-vitepress";
});
const repoBranch = computed(() =>
dataSource.value === "github" && hasGitHubConnection.value ? githubConnection.branch : "local",
);
const repoRoot = computed(() =>
dataSource.value === "github" && hasGitHubConnection.value ? githubConnection.siteRoot || "/" : "test-vitepress/",
);
function normalizeConnection(connection: GitHubConnection): GitHubConnection {
return {
token: connection.token.trim(),
owner: connection.owner.trim(),
repo: connection.repo.trim(),
branch: connection.branch.trim() || "main",
siteRoot: connection.siteRoot.trim().replace(/^\/+|\/+$/g, ""),
};
}
function validateConnection(connection: GitHubConnection) {
if (!connection.owner || !connection.repo || !connection.branch) {
throw new Error("请填写 GitHub owner、repo 和 branch 后再连接");
}
}
function replacePages(nextPages: DocPage[]) {
pages.splice(0, pages.length, ...nextPages.map((page) => ({ ...page })));
activePageId.value = pages[0]?.id ?? "";
}
async function loadPagesForSource(source: DataSource, connection = githubConnection) {
if (source === "github") {
return loadGitHubPages(connection);
}
return loadLocalPages();
}
async function loadSettingsForSource(source: DataSource, connection = githubConnection) {
if (source === "github") {
const result = await loadGitHubSettings(connection);
return result;
}
return {
settings: await loadLocalSettings(),
sha: undefined,
};
}
async function loadPages() {
isLoadingPages.value = true;
try {
const nextPages = await loadPagesForSource(dataSource.value);
replacePages(nextPages);
activityItems.value = [
`已从 ${dataSource.value === "github" ? "GitHub" : "test-vitepress"} 读取 ${nextPages.length} 个 Markdown 页面`,
...activityItems.value,
];
} catch (error) {
replacePages(mockPages.map((page) => ({ ...page })));
activityItems.value = [
"读取页面失败,已回退到 mock 数据",
error instanceof Error ? error.message : "未知错误",
...activityItems.value,
];
} finally {
isLoadingPages.value = false;
}
}
async function loadSettings() {
try {
const result = await loadSettingsForSource(dataSource.value);
Object.assign(siteSettings, result.settings);
settingsSha.value = result.sha;
activityItems.value = [
`已读取 ${dataSource.value === "github" ? "GitHub" : "test-vitepress"} 的 VitePress 配置`,
...activityItems.value,
];
} catch (error) {
activityItems.value = [
"读取站点配置失败,已使用默认配置",
error instanceof Error ? error.message : "未知错误",
...activityItems.value,
];
}
}
async function reloadSite() {
await Promise.all([loadPages(), loadSettings()]);
}
async function loadAuthState() {
try {
currentUser.value = await loadCurrentUser();
githubUser.value = await loadGitHubUser();
if (githubUser.value) {
await refreshRepositories();
}
} catch (error) {
activityItems.value = [
error instanceof Error ? error.message : "读取 GitHub 登录状态失败",
...activityItems.value,
];
}
}
async function handleEmailLogin(payload: { email: string; password: string }) {
isAuthLoading.value = true;
loginError.value = "";
try {
currentUser.value = await loginWithEmail(payload.email, payload.password);
await loadAuthState();
await reloadSite();
} catch (error) {
loginError.value = error instanceof Error ? error.message : "登录失败";
} finally {
isAuthLoading.value = false;
}
}
async function handleLogout() {
await logoutCms();
currentUser.value = null;
githubUser.value = null;
repositories.value = [];
}
async function refreshRepositories() {
isLoadingRepos.value = true;
try {
repositories.value = await loadGitHubRepositories();
activityItems.value = [`已读取 ${repositories.value.length} 个 GitHub 仓库`, ...activityItems.value];
} catch (error) {
activityItems.value = [
error instanceof Error ? error.message : "读取 GitHub 仓库列表失败",
...activityItems.value,
];
} finally {
isLoadingRepos.value = false;
}
}
async function connectGithub(connection: GitHubConnection) {
const nextConnection = normalizeConnection(connection);
try {
validateConnection(nextConnection);
} catch (error) {
activityItems.value = [
error instanceof Error ? error.message : "连接信息不完整",
...activityItems.value,
];
return;
}
isConnecting.value = true;
try {
const [nextPages, nextSettings] = await Promise.all([
loadPagesForSource("github", nextConnection),
loadSettingsForSource("github", nextConnection),
]);
Object.assign(githubConnection, nextConnection);
dataSource.value = "github";
hasGitHubConnection.value = true;
replacePages(nextPages);
Object.assign(siteSettings, nextSettings.settings);
settingsSha.value = nextSettings.sha;
activePanel.value = "content";
activityItems.value = [`已连接 GitHub 仓库 ${githubConnection.owner}/${githubConnection.repo}`, ...activityItems.value];
} catch (error) {
hasGitHubConnection.value = false;
dataSource.value = "local";
activityItems.value = [
"GitHub 仓库连接失败,仍保持本地测试站点模式",
error instanceof Error ? error.message : "未知错误",
...activityItems.value,
];
} finally {
isConnecting.value = false;
}
}
async function useLocalSource() {
dataSource.value = "local";
hasGitHubConnection.value = false;
settingsSha.value = undefined;
await reloadSite();
activePanel.value = "content";
}
async function signOutGithub() {
await logoutGitHub();
githubUser.value = null;
repositories.value = [];
await useLocalSource();
}
async function createRepository(payload: { name: string; private: boolean }) {
try {
const repo = await createGitHubRepository(payload);
repositories.value = [repo, ...repositories.value];
activityItems.value = [`已创建 GitHub 仓库 ${repo.fullName}`, ...activityItems.value];
} catch (error) {
activityItems.value = [
error instanceof Error ? error.message : "创建 GitHub 仓库失败",
...activityItems.value,
];
}
}
function selectPage(pageId: string) {
activePageId.value = pageId;
pageSaveState.value = "idle";
}
function addPage() {
const nextIndex = pages.length + 1;
const page: DocPage = {
id: `new-page-${Date.now()}`,
title: `新页面 ${nextIndex}`,
path: `new-page-${nextIndex}.md`,
description: "新的 VitePress 页面",
content: `# 新页面 ${nextIndex}\n\n在这里编辑内容。`,
};
pages.unshift(page);
activePageId.value = page.id;
pageSaveState.value = "idle";
}
async function saveActivePage() {
if (!activePage.value) return;
pageSaveState.value = "saving";
try {
if (dataSource.value === "github" && hasGitHubConnection.value) {
activePage.value.sha = await saveGitHubPage(githubConnection, activePage.value);
} else {
await saveLocalPage(activePage.value);
}
pageSaveState.value = "saved";
activityItems.value = [`已保存 ${activePage.value.path}`, ...activityItems.value];
} catch (error) {
pageSaveState.value = "error";
activityItems.value = [
error instanceof Error ? error.message : "保存页面失败",
...activityItems.value,
];
}
}
async function saveSettings() {
settingsSaveState.value = "saving";
try {
if (dataSource.value === "github" && hasGitHubConnection.value) {
settingsSha.value = await saveGitHubSettings(githubConnection, siteSettings, settingsSha.value);
} else {
await saveLocalSettings(siteSettings);
}
settingsSaveState.value = "saved";
activityItems.value = ["已保存 .vitepress/config.ts", ...activityItems.value];
} catch (error) {
settingsSaveState.value = "error";
activityItems.value = [
error instanceof Error ? error.message : "保存配置失败",
...activityItems.value,
];
}
}
function publishChanges() {
activityItems.value = ["已创建 commit并触发部署流程", ...activityItems.value];
}
onMounted(async () => {
try {
await loadAuthState();
if (currentUser.value) {
await reloadSite();
}
} finally {
isAuthLoading.value = false;
}
});
</script>
<template>
<LoginView
v-if="!currentUser"
:error-message="loginError"
:is-loading="isAuthLoading"
@email-login="handleEmailLogin"
@github-login="startGitHubLogin"
/>
<div v-else id="app-shell">
<AppSidebar
:active-panel="activePanel"
:data-source="dataSource"
:has-git-hub-connection="hasGitHubConnection"
:panels="panels"
:repo-branch="repoBranch"
:repo-label="repoLabel"
:repo-root="repoRoot"
@change-panel="activePanel = $event"
/>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">VitePress 后台工作台</p>
<h2>{{ activePanelTitle }}</h2>
</div>
<div class="topbar-actions">
<button class="ghost-button" type="button" @click="handleLogout">
退出
</button>
<button class="ghost-button" type="button" @click="reloadSite">重新读取</button>
<button class="primary-button" type="button" @click="activePanel = 'publish'">发布</button>
</div>
</header>
<ConnectPanel
v-if="activePanel === 'connect'"
:connection="githubConnection"
:data-source="dataSource"
:github-user="githubUser"
:has-git-hub-connection="hasGitHubConnection"
:is-connecting="isConnecting"
:is-loading-repos="isLoadingRepos"
:repositories="repositories"
@connect-github="connectGithub"
@create-repository="createRepository"
@load-repositories="refreshRepositories"
@login-github="startGitHubLogin"
@logout-github="signOutGithub"
@use-local="useLocalSource"
/>
<ContentPanel
v-else-if="activePanel === 'content' && activePage"
:active-page="activePage"
:active-page-id="activePageId"
:is-loading="isLoadingPages"
:pages="pages"
:save-state="pageSaveState"
@add-page="addPage"
@save-page="saveActivePage"
@select-page="selectPage"
/>
<StructurePanel
v-else-if="activePanel === 'structure'"
:nav-items="navItems"
:sidebar-groups="sidebarGroups"
/>
<ConfigPanel
v-else-if="activePanel === 'config'"
:save-state="settingsSaveState"
:settings="siteSettings"
@save-settings="saveSettings"
/>
<PublishPanel
v-else-if="activePanel === 'publish'"
:activity-items="activityItems"
@publish="publishChanges"
/>
<section v-else class="management-card empty-state">
正在读取站点...
</section>
</main>
</div>
</template>

47
src/api/auth.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { CmsUser } from "../types";
interface MeResponse {
user: CmsUser | null;
}
interface LoginResponse {
user: CmsUser;
}
export async function loadCurrentUser() {
const response = await fetch("/api/auth/me");
if (!response.ok) {
throw new Error(`读取当前用户失败:${response.status}`);
}
const data = (await response.json()) as MeResponse;
return data.user;
}
export async function loginWithEmail(email: string, password: string) {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error("邮箱或密码错误");
}
const data = (await response.json()) as LoginResponse;
return data.user;
}
export async function logoutCms() {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (!response.ok) {
throw new Error(`退出登录失败:${response.status}`);
}
}

70
src/api/githubAuth.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { GitHubRepository, GitHubUser } from "../types";
interface MeResponse {
user: GitHubUser | null;
}
interface ReposResponse {
repos: GitHubRepository[];
}
interface CreateRepoResponse {
repo: GitHubRepository;
}
export function startGitHubLogin() {
window.location.href = "/api/github/login";
}
export async function loadGitHubUser() {
const response = await fetch("/api/github/me");
if (!response.ok) {
throw new Error(`读取 GitHub 登录状态失败:${response.status}`);
}
const data = (await response.json()) as MeResponse;
return data.user;
}
export async function logoutGitHub() {
const response = await fetch("/api/github/logout", {
method: "POST",
});
if (!response.ok) {
throw new Error(`退出 GitHub 登录失败:${response.status}`);
}
}
export async function loadGitHubRepositories() {
const response = await fetch("/api/github/repos");
if (!response.ok) {
throw new Error(`读取 GitHub 仓库列表失败:${response.status}`);
}
const data = (await response.json()) as ReposResponse;
return data.repos;
}
export async function createGitHubRepository(payload: {
name: string;
private: boolean;
description?: string;
}) {
const response = await fetch("/api/github/repos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`创建 GitHub 仓库失败:${response.status}`);
}
const data = (await response.json()) as CreateRepoResponse;
return data.repo;
}

266
src/api/githubSite.ts Normal file
View File

@@ -0,0 +1,266 @@
import type { DocPage, GitHubConnection, SiteSettings } from "../types";
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent";
interface GitTreeResponse {
tree: Array<{
path: string;
type: "blob" | "tree";
sha: string;
}>;
}
interface GitHubContentResponse {
content: string;
encoding: string;
sha: string;
path: string;
}
function githubHeaders(connection: GitHubConnection) {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (connection.token.trim()) {
headers.Authorization = `Bearer ${connection.token.trim()}`;
}
return headers;
}
function usesSessionProxy(connection: GitHubConnection) {
return !connection.token.trim();
}
function siteParams(connection: GitHubConnection) {
return new URLSearchParams({
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
});
}
function normalizeSiteRoot(siteRoot: string) {
return siteRoot.trim().replace(/^\/+|\/+$/g, "");
}
function toRepoPath(connection: GitHubConnection, path: string) {
const siteRoot = normalizeSiteRoot(connection.siteRoot);
return siteRoot ? `${siteRoot}/${path}` : path;
}
function fromRepoPath(connection: GitHubConnection, path: string) {
const siteRoot = normalizeSiteRoot(connection.siteRoot);
return siteRoot && path.startsWith(`${siteRoot}/`) ? path.slice(siteRoot.length + 1) : path;
}
function decodeBase64Content(content: string) {
const binary = atob(content.replace(/\n/g, ""));
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
function encodeBase64Content(content: string) {
const bytes = new TextEncoder().encode(content);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
async function githubFetch<T>(connection: GitHubConnection, path: string, init?: RequestInit) {
const response = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
...githubHeaders(connection),
...init?.headers,
},
});
if (!response.ok) {
const details = await response.text();
throw new Error(`GitHub 请求失败:${response.status} ${details}`);
}
return (await response.json()) as T;
}
async function getContent(connection: GitHubConnection, repoPath: string) {
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
return githubFetch<GitHubContentResponse>(
connection,
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}?ref=${encodeURIComponent(connection.branch)}`,
);
}
export async function loadGitHubPages(connection: GitHubConnection) {
if (usesSessionProxy(connection)) {
const response = await fetch(`/api/github/site/pages?${siteParams(connection).toString()}`);
if (!response.ok) {
throw new Error(`读取 GitHub 页面失败:${response.status}`);
}
const data = (await response.json()) as { pages: DocPage[] };
return data.pages;
}
const tree = await githubFetch<GitTreeResponse>(
connection,
`/repos/${connection.owner}/${connection.repo}/git/trees/${encodeURIComponent(connection.branch)}?recursive=1`,
);
const siteRoot = normalizeSiteRoot(connection.siteRoot);
const prefix = siteRoot ? `${siteRoot}/` : "";
const markdownFiles = tree.tree
.filter((entry) => entry.type === "blob")
.filter((entry) => entry.path.endsWith(".md"))
.filter((entry) => !entry.path.includes("/node_modules/"))
.filter((entry) => entry.path !== `${prefix}README.md`)
.filter((entry) => !prefix || entry.path.startsWith(prefix));
return Promise.all(
markdownFiles.sort((a, b) => a.path.localeCompare(b.path)).map(async (entry) => {
const file = await getContent(connection, entry.path);
const source = decodeBase64Content(file.content);
const parsed = parseFrontmatter(source);
const path = fromRepoPath(connection, entry.path);
return {
id: path,
path,
sha: file.sha,
title: parsed.title || path.replace(/\.md$/, ""),
description: parsed.description,
content: parsed.content,
} satisfies DocPage;
}),
);
}
export async function saveGitHubPage(connection: GitHubConnection, page: DocPage) {
if (usesSessionProxy(connection)) {
const response = await fetch("/api/github/site/pages", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
site: {
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
},
page,
}),
});
if (!response.ok) {
throw new Error(`保存 GitHub 页面失败:${response.status}`);
}
const data = (await response.json()) as { sha: string };
return data.sha;
}
const repoPath = toRepoPath(connection, page.path);
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
const response = await githubFetch<{ content: { sha: string } }>(
connection,
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: `docs: update ${page.path} from VitePress-CMS`,
content: encodeBase64Content(stringifyMarkdownPage(page)),
branch: connection.branch,
sha: page.sha,
}),
},
);
return response.content.sha;
}
export async function loadGitHubSettings(connection: GitHubConnection) {
if (usesSessionProxy(connection)) {
const response = await fetch(`/api/github/site/settings?${siteParams(connection).toString()}`);
if (!response.ok) {
throw new Error(`读取 GitHub 配置失败:${response.status}`);
}
return (await response.json()) as {
settings: SiteSettings;
sha: string;
};
}
const configPath = toRepoPath(connection, ".vitepress/config.ts");
const file = await getContent(connection, configPath);
const source = decodeBase64Content(file.content);
return {
settings: parseSiteSettings(source),
sha: file.sha,
};
}
export async function saveGitHubSettings(
connection: GitHubConnection,
settings: SiteSettings,
sha?: string,
) {
if (usesSessionProxy(connection)) {
const response = await fetch("/api/github/site/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
site: {
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
},
settings,
sha,
}),
});
if (!response.ok) {
throw new Error(`保存 GitHub 配置失败:${response.status}`);
}
const data = (await response.json()) as { sha: string };
return data.sha;
}
const repoPath = toRepoPath(connection, ".vitepress/config.ts");
const encodedPath = repoPath.split("/").map(encodeURIComponent).join("/");
const response = await githubFetch<{ content: { sha: string } }>(
connection,
`/repos/${connection.owner}/${connection.repo}/contents/${encodedPath}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "docs: update VitePress config from VitePress-CMS",
content: encodeBase64Content(stringifySiteConfig(settings)),
branch: connection.branch,
sha,
}),
},
);
return response.content.sha;
}

64
src/api/localSite.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { DocPage, SiteSettings } from "../types";
interface PagesResponse {
pages: DocPage[];
}
interface SettingsResponse {
settings: SiteSettings;
}
export async function loadLocalPages() {
const response = await fetch("/api/local-site/pages");
if (!response.ok) {
throw new Error(`读取测试站点失败:${response.status}`);
}
const data = (await response.json()) as PagesResponse;
return data.pages;
}
export async function saveLocalPage(page: DocPage) {
const response = await fetch("/api/local-site/pages", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
path: page.path,
title: page.title,
description: page.description,
content: page.content,
}),
});
if (!response.ok) {
throw new Error(`保存测试站点失败:${response.status}`);
}
}
export async function loadLocalSettings() {
const response = await fetch("/api/local-site/settings");
if (!response.ok) {
throw new Error(`读取测试站点配置失败:${response.status}`);
}
const data = (await response.json()) as SettingsResponse;
return data.settings;
}
export async function saveLocalSettings(settings: SiteSettings) {
const response = await fetch("/api/local-site/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(`保存测试站点配置失败:${response.status}`);
}
}

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { DataSource, PanelKey } from "../types";
defineProps<{
activePanel: PanelKey;
dataSource: DataSource;
hasGitHubConnection: boolean;
panels: Array<{ key: PanelKey; label: string; icon: string }>;
repoLabel: string;
repoBranch: string;
repoRoot: string;
}>();
defineEmits<{
changePanel: [panel: PanelKey];
}>();
</script>
<template>
<aside class="workspace-sidebar">
<div class="brand-block">
<div class="brand-mark">VC</div>
<div>
<h1>VitePress-CMS</h1>
<p>VitePress 可视化后台</p>
</div>
</div>
<button class="connect-button" type="button" @click="$emit('changePanel', 'connect')">
<span>{{ hasGitHubConnection ? "GitHub" : dataSource === "github" ? "GitHub" : "Local" }}</span>
<strong>{{ hasGitHubConnection ? "已连接仓库" : "连接仓库" }}</strong>
</button>
<nav class="main-nav" aria-label="后台模块">
<button
v-for="panel in panels"
:key="panel.key"
class="nav-item"
:class="{ 'is-active': activePanel === panel.key }"
type="button"
@click="$emit('changePanel', panel.key)"
>
<span class="nav-icon">{{ panel.icon }}</span>
{{ panel.label }}
</button>
</nav>
<section class="repo-card" aria-label="当前仓库">
<p class="eyebrow">当前数据源</p>
<h2>{{ repoLabel }}</h2>
<dl>
<div>
<dt>分支</dt>
<dd>{{ repoBranch }}</dd>
</div>
<div>
<dt>目录</dt>
<dd>{{ repoRoot }}</dd>
</div>
<div>
<dt>状态</dt>
<dd class="status-dot">Ready</dd>
</div>
</dl>
</section>
</aside>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { SiteSettings } from "../types";
defineProps<{
settings: SiteSettings;
saveState: "idle" | "saving" | "saved" | "error";
}>();
defineEmits<{
saveSettings: [];
}>();
</script>
<template>
<section class="panel is-visible">
<div class="settings-toolbar">
<p class="save-status" :class="`is-${saveState}`">
<template v-if="saveState === 'saved'">已保存到 test-vitepress/.vitepress/config.ts</template>
<template v-else-if="saveState === 'error'">保存配置失败请查看发布中心日志</template>
<template v-else-if="saveState === 'saving'">正在写入 VitePress 配置</template>
<template v-else>读取自 test-vitepress/.vitepress/config.ts</template>
</p>
<button class="primary-button" type="button" @click="$emit('saveSettings')">
{{ saveState === "saving" ? "保存中" : "保存配置" }}
</button>
</div>
<div class="settings-grid">
<section class="management-card">
<p class="eyebrow">site</p>
<h3>站点信息</h3>
<label>
站点标题
<input v-model="settings.title" />
</label>
<label>
描述
<input v-model="settings.description" />
</label>
<label>
Logo 路径
<input v-model="settings.logo" />
</label>
</section>
<section class="management-card">
<p class="eyebrow">theme</p>
<h3>主题选项</h3>
<label class="toggle-row">
<span>显示最后更新时间</span>
<input v-model="settings.lastUpdated" type="checkbox" />
</label>
<label class="toggle-row">
<span>开启本地搜索</span>
<input v-model="settings.localSearch" type="checkbox" />
</label>
<label class="toggle-row">
<span>显示大纲导航</span>
<input v-model="settings.outline" type="checkbox" />
</label>
</section>
<section class="management-card wide-card">
<p class="eyebrow">socialLinks</p>
<h3>社交链接</h3>
<div class="sort-row">
<span class="drag-handle">::</span>
<input v-model="settings.socialKind" />
<input v-model="settings.socialLink" />
</div>
</section>
</div>
</section>
</template>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import type { DataSource, GitHubConnection, GitHubRepository, GitHubUser } from "../types";
const props = defineProps<{
connection: GitHubConnection;
dataSource: DataSource;
githubUser: GitHubUser | null;
hasGitHubConnection: boolean;
isConnecting: boolean;
isLoadingRepos: boolean;
repositories: GitHubRepository[];
}>();
const emit = defineEmits<{
connectGithub: [connection: GitHubConnection];
createRepository: [payload: { name: string; private: boolean }];
loginGithub: [];
loadRepositories: [];
logoutGithub: [];
useLocal: [];
}>();
const draft = reactive<GitHubConnection>({ ...props.connection });
const newRepo = reactive({
name: "",
private: false,
});
const selectedRepo = computed({
get() {
return draft.owner && draft.repo ? `${draft.owner}/${draft.repo}` : "";
},
set(fullName: string) {
const repo = props.repositories.find((item) => item.fullName === fullName);
if (!repo) return;
draft.owner = repo.owner;
draft.repo = repo.name;
draft.branch = repo.defaultBranch || "main";
},
});
const canConnect = computed(() => Boolean(draft.owner.trim() && draft.repo.trim() && draft.branch.trim()));
const canCreateRepo = computed(() => Boolean(newRepo.name.trim()));
watch(
() => props.connection,
(connection) => {
Object.assign(draft, connection);
},
{ deep: true },
);
function connectGithub() {
if (!canConnect.value) return;
emit("connectGithub", { ...draft });
}
function createRepository() {
if (!canCreateRepo.value) return;
emit("createRepository", {
name: newRepo.name.trim(),
private: newRepo.private,
});
}
</script>
<template>
<section class="panel is-visible">
<div class="connect-layout">
<section class="management-card">
<p class="eyebrow">GitHub</p>
<h3>登录与仓库选择</h3>
<div v-if="githubUser" class="github-user">
<img v-if="githubUser.avatarUrl" :src="githubUser.avatarUrl" alt="" />
<div>
<strong>{{ githubUser.name || githubUser.login }}</strong>
<span>@{{ githubUser.login }}</span>
</div>
<button class="ghost-button" type="button" @click="$emit('logoutGithub')">退出</button>
</div>
<div v-else class="connect-actions">
<button class="primary-button" type="button" @click="$emit('loginGithub')">
使用 GitHub 登录
</button>
<button class="ghost-button" type="button" @click="$emit('useLocal')">
使用本地测试站点
</button>
</div>
<template v-if="githubUser">
<div class="connect-actions">
<button class="secondary-button" type="button" @click="$emit('loadRepositories')">
{{ isLoadingRepos ? "读取中" : "刷新仓库列表" }}
</button>
</div>
<div class="form-grid">
<label class="wide-field">
仓库
<select v-model="selectedRepo">
<option value="">选择仓库</option>
<option v-for="repo in repositories" :key="repo.id" :value="repo.fullName">
{{ repo.fullName }}{{ repo.private ? " private" : "" }}
</option>
</select>
</label>
<label>
Branch
<input v-model="draft.branch" placeholder="main" />
</label>
<label>
站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
</label>
</div>
<div class="connect-actions">
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
{{ isConnecting ? "连接中" : "读取选中仓库" }}
</button>
</div>
<div class="new-repo-box">
<p class="eyebrow">create</p>
<h3>新建仓库</h3>
<div class="form-grid">
<label>
仓库名
<input v-model="newRepo.name" placeholder="my-vitepress-site" />
</label>
<label class="toggle-row">
<span>私有仓库</span>
<input v-model="newRepo.private" type="checkbox" />
</label>
</div>
<button class="secondary-button" type="button" :disabled="!canCreateRepo" @click="createRepository">
创建仓库
</button>
</div>
</template>
</section>
<section class="management-card">
<p class="eyebrow">manual</p>
<h3>手动连接</h3>
<div class="form-grid">
<label>
Token
<input v-model="draft.token" type="password" placeholder="ghp_..." autocomplete="off" />
</label>
<label>
Owner
<input v-model="draft.owner" placeholder="octocat" />
</label>
<label>
Repo
<input v-model="draft.repo" placeholder="vitepress-site" />
</label>
<label>
Branch
<input v-model="draft.branch" placeholder="main" />
</label>
<label class="wide-field">
站点目录
<input v-model="draft.siteRoot" placeholder="docs 或留空" />
</label>
</div>
<p v-if="!canConnect" class="helper-text">
请至少填写 ownerrepo branch私有仓库或保存操作还需要 token
</p>
<div class="connect-actions">
<button class="primary-button" type="button" :disabled="!canConnect || isConnecting" @click="connectGithub">
{{ isConnecting ? "连接中" : "读取 GitHub 仓库" }}
</button>
<button class="ghost-button" type="button" @click="$emit('useLocal')">
使用本地测试站点
</button>
</div>
<dl class="source-summary">
<div>
<dt>模式</dt>
<dd>{{ dataSource === "github" && hasGitHubConnection ? "GitHub 仓库" : "本地测试站点" }}</dd>
</div>
<div>
<dt>仓库</dt>
<dd>{{ hasGitHubConnection ? `${connection.owner}/${connection.repo}` : "未连接" }}</dd>
</div>
<div>
<dt>分支</dt>
<dd>{{ hasGitHubConnection ? connection.branch : "-" }}</dd>
</div>
<div>
<dt>目录</dt>
<dd>{{ hasGitHubConnection ? connection.siteRoot || "/" : "-" }}</dd>
</div>
</dl>
</section>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { markdownToHtml } from "../utils/markdown";
import type { DocPage, EditorMode } from "../types";
const props = defineProps<{
pages: DocPage[];
activePage: DocPage;
activePageId: string;
isLoading: boolean;
saveState: "idle" | "saving" | "saved" | "error";
}>();
defineEmits<{
addPage: [];
savePage: [];
selectPage: [pageId: string];
}>();
const search = ref("");
const mode = ref<EditorMode>("write");
const filteredPages = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) return props.pages;
return props.pages.filter((page) => `${page.title} ${page.path}`.toLowerCase().includes(query));
});
const previewHtml = computed(() => markdownToHtml(props.activePage.content));
</script>
<template>
<section class="panel is-visible">
<div class="content-layout">
<aside class="document-tree" aria-label="文档目录">
<div class="section-head">
<div>
<p class="eyebrow">docs</p>
<h3>页面</h3>
</div>
<button class="icon-button" type="button" title="新建页面" @click="$emit('addPage')">+</button>
</div>
<div class="search-box">
<input v-model="search" type="search" placeholder="搜索页面" />
</div>
<ul class="page-list">
<li v-for="page in filteredPages" :key="page.id">
<button
class="page-item"
:class="{ 'is-active': page.id === activePageId }"
type="button"
@click="$emit('selectPage', page.id)"
>
<strong>{{ page.title }}</strong>
<span>{{ page.path }}</span>
</button>
</li>
</ul>
<p v-if="isLoading" class="helper-text">正在读取 test-vitepress...</p>
</aside>
<section class="editor-pane" aria-label="编辑器">
<div class="editor-header">
<div>
<p class="eyebrow">{{ activePage.path }}</p>
<h3>{{ activePage.title }}</h3>
</div>
<div class="editor-actions">
<div class="segmented-control" role="tablist" aria-label="编辑模式">
<button :class="{ 'is-active': mode === 'write' }" type="button" @click="mode = 'write'">
编辑
</button>
<button :class="{ 'is-active': mode === 'preview' }" type="button" @click="mode = 'preview'">
预览
</button>
</div>
<button class="primary-button" type="button" @click="$emit('savePage')">
{{ saveState === "saving" ? "保存中" : "保存" }}
</button>
</div>
</div>
<p class="save-status" :class="`is-${saveState}`">
<template v-if="saveState === 'saved'">已保存到 test-vitepress</template>
<template v-else-if="saveState === 'error'">保存失败请查看发布中心日志</template>
<template v-else-if="saveState === 'saving'">正在写入 Markdown 文件</template>
<template v-else>读取自 test-vitepress本次修改需要手动保存</template>
</p>
<div class="frontmatter-grid">
<label>
标题
<input v-model="activePage.title" type="text" />
</label>
<label>
描述
<input v-model="activePage.description" type="text" />
</label>
</div>
<textarea v-if="mode === 'write'" v-model="activePage.content" spellcheck="false"></textarea>
<article v-else class="markdown-preview" v-html="previewHtml"></article>
</section>
</div>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { reactive } from "vue";
defineProps<{
errorMessage: string;
isLoading: boolean;
}>();
const emit = defineEmits<{
emailLogin: [payload: { email: string; password: string }];
githubLogin: [];
}>();
const form = reactive({
email: "admin@example.com",
password: "admin123456",
});
function submit() {
emit("emailLogin", { ...form });
}
</script>
<template>
<main class="login-screen">
<section class="login-panel">
<div class="brand-block">
<div class="brand-mark">VC</div>
<div>
<h1>VitePress-CMS</h1>
<p>登录 CMS 后台</p>
</div>
</div>
<form class="login-form" @submit.prevent="submit">
<label>
邮箱
<input v-model="form.email" type="email" autocomplete="username" />
</label>
<label>
密码
<input v-model="form.password" type="password" autocomplete="current-password" />
</label>
<button class="primary-button" type="submit" :disabled="isLoading">
{{ isLoading ? "登录中" : "邮箱登录" }}
</button>
</form>
<button class="connect-button" type="button" @click="$emit('githubLogin')">
<span>GitHub</span>
<strong>使用 GitHub 登录</strong>
</button>
<p v-if="errorMessage" class="save-status is-error">{{ errorMessage }}</p>
<p class="helper-text">默认管理员账号admin@example.com / admin123456</p>
</section>
</main>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
defineProps<{
activityItems: string[];
}>();
defineEmits<{
publish: [];
}>();
</script>
<template>
<section class="panel is-visible">
<div class="publish-layout">
<section class="management-card publish-card">
<p class="eyebrow">commit</p>
<h3>发布修改</h3>
<textarea class="commit-message">docs: update site content from VitePress-CMS</textarea>
<button class="primary-button" type="button" @click="$emit('publish')">提交并发布</button>
</section>
<section class="management-card">
<p class="eyebrow">activity</p>
<h3>部署状态</h3>
<ol class="activity-list">
<li v-for="item in activityItems" :key="item"><span></span>{{ item }}</li>
</ol>
</section>
</div>
</section>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { NavItem, SidebarGroup } from "../types";
defineProps<{
navItems: NavItem[];
sidebarGroups: SidebarGroup[];
}>();
</script>
<template>
<section class="panel is-visible">
<div class="two-column">
<section class="management-card">
<div class="section-head">
<div>
<p class="eyebrow">nav</p>
<h3>导航栏</h3>
</div>
<button class="secondary-button" type="button">添加</button>
</div>
<div class="sortable-list">
<div v-for="item in navItems" :key="item.id" class="sort-row">
<span class="drag-handle">::</span>
<input v-model="item.label" />
<input v-model="item.link" />
</div>
</div>
</section>
<section class="management-card">
<div class="section-head">
<div>
<p class="eyebrow">sidebar</p>
<h3>侧边栏</h3>
</div>
<button class="secondary-button" type="button">自动生成</button>
</div>
<div class="sidebar-preview">
<template v-for="group in sidebarGroups" :key="group.id">
<strong>{{ group.title }}</strong>
<button v-for="item in group.items" :key="item.id" type="button">
{{ item.label }}
</button>
</template>
</div>
</section>
</div>
</section>
</template>

6
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./styles.css";
createApp(App).mount("#app");

757
src/styles.css Normal file
View File

@@ -0,0 +1,757 @@
:root {
color-scheme: light;
--bg: #eef1f5;
--surface: #ffffff;
--surface-soft: #f7f9fb;
--ink: #18212f;
--muted: #687386;
--line: #dfe5ed;
--accent: #117a72;
--accent-strong: #0b625d;
--accent-soft: #e1f4f1;
--warning: #c07a21;
--radius: 8px;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
#app-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.login-screen {
display: grid;
min-height: 100vh;
place-items: center;
padding: 20px;
background: var(--bg);
}
.login-panel {
display: grid;
width: min(420px, 100%);
gap: 16px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
padding: 22px;
}
.login-form {
display: grid;
gap: 12px;
}
.workspace-sidebar {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
border-right: 1px solid var(--line);
background: #fbfcfd;
}
.brand-block {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-weight: 800;
}
.brand-block h1,
.brand-block p,
.topbar h2,
.topbar p,
.section-head h3,
.section-head p,
.management-card h3,
.management-card p {
margin: 0;
}
.brand-block h1 {
font-size: 18px;
letter-spacing: 0;
}
.brand-block p,
.eyebrow {
color: var(--muted);
font-size: 12px;
}
.eyebrow {
margin-bottom: 4px;
font-weight: 700;
text-transform: uppercase;
}
.connect-button,
.primary-button,
.secondary-button,
.ghost-button,
.icon-button,
.nav-item,
.page-item,
.sidebar-preview button {
border: 1px solid transparent;
border-radius: var(--radius);
}
.connect-button {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 14px;
background: #171b23;
color: #fff;
text-align: left;
}
.connect-button span {
color: #bfc7d1;
}
.main-nav {
display: grid;
gap: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 12px;
background: transparent;
color: var(--muted);
text-align: left;
}
.nav-item.is-active {
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 700;
}
.nav-icon {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 6px;
background: #e8edf3;
font-size: 12px;
font-weight: 800;
}
.repo-card,
.management-card,
.document-tree,
.editor-pane {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.repo-card {
margin-top: auto;
padding: 16px;
}
.repo-card h2 {
margin: 0 0 14px;
font-size: 15px;
}
.repo-card dl {
display: grid;
gap: 10px;
margin: 0;
}
.repo-card dl div {
display: flex;
justify-content: space-between;
gap: 12px;
}
.repo-card dt {
color: var(--muted);
}
.repo-card dd {
margin: 0;
font-weight: 700;
}
.status-dot::before {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 999px;
background: #21a36e;
content: "";
}
.app-shell {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h2 {
font-size: 28px;
letter-spacing: 0;
}
.topbar-actions {
display: flex;
gap: 10px;
}
.primary-button,
.secondary-button,
.ghost-button,
.icon-button {
min-height: 38px;
padding: 0 14px;
font-weight: 700;
}
.primary-button {
background: var(--accent);
color: #fff;
}
.secondary-button,
.ghost-button,
.icon-button {
border-color: var(--line);
background: var(--surface);
color: var(--ink);
}
.panel {
display: none;
}
.panel.is-visible {
display: block;
}
.content-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
min-height: calc(100vh - 112px);
}
.document-tree,
.editor-pane,
.management-card {
padding: 16px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-head h3,
.management-card h3 {
font-size: 18px;
}
.search-box input,
label input,
label select,
.sort-row input,
.commit-message {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-soft);
color: var(--ink);
outline: none;
}
.search-box input,
label input,
label select,
.sort-row input {
height: 40px;
padding: 0 12px;
}
.page-list {
display: grid;
gap: 8px;
max-height: calc(100vh - 226px);
margin: 14px 0 0;
padding: 0;
overflow: auto;
list-style: none;
}
.page-item {
display: grid;
gap: 4px;
width: 100%;
padding: 12px;
background: transparent;
text-align: left;
}
.page-item span {
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-item.is-active {
border-color: #b9d8d4;
background: var(--accent-soft);
}
.editor-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 14px;
min-width: 0;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.editor-actions {
display: flex;
align-items: center;
gap: 10px;
}
.editor-header h3 {
margin: 0;
font-size: 24px;
}
.segmented-control {
display: flex;
padding: 3px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-soft);
}
.segmented-control button {
min-width: 64px;
height: 32px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-weight: 700;
}
.segmented-control button.is-active {
background: var(--surface);
color: var(--ink);
box-shadow: 0 1px 4px rgba(23, 33, 46, 0.12);
}
.frontmatter-grid,
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.helper-text,
.save-status {
margin: 10px 0 0;
color: var(--muted);
font-size: 13px;
}
.save-status {
margin: -4px 0 0;
}
.save-status.is-saved {
color: var(--accent-strong);
font-weight: 700;
}
.save-status.is-error {
color: #a64038;
font-weight: 700;
}
.empty-state {
color: var(--muted);
}
.editor-pane > textarea,
.markdown-preview {
min-height: 460px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-soft);
}
.editor-pane > textarea {
resize: none;
padding: 16px;
line-height: 1.65;
}
.markdown-preview {
padding: 18px 22px;
line-height: 1.7;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3 {
letter-spacing: 0;
}
.markdown-preview blockquote {
margin: 12px 0;
padding: 10px 14px;
border-left: 4px solid var(--accent);
background: #edf7f5;
color: #30514e;
}
.preview-list {
margin: 6px 0;
}
.two-column,
.publish-layout,
.connect-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.sortable-list,
.sidebar-preview,
.settings-grid,
.publish-card,
.form-grid {
display: grid;
gap: 12px;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 14px;
}
.wide-field {
grid-column: 1 / -1;
}
.connect-actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.github-user {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
margin-top: 14px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-soft);
}
.github-user img {
width: 42px;
height: 42px;
border-radius: 999px;
}
.github-user strong,
.github-user span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.github-user span {
color: var(--muted);
font-size: 13px;
}
.new-repo-box {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.source-summary {
display: grid;
gap: 12px;
margin: 14px 0 0;
}
.source-summary div {
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
}
.source-summary dt {
color: var(--muted);
}
.source-summary dd {
margin: 0;
overflow-wrap: anywhere;
font-weight: 700;
text-align: right;
}
.sort-row {
display: grid;
grid-template-columns: 28px 1fr 1.4fr;
gap: 10px;
align-items: center;
}
.drag-handle {
color: var(--muted);
font-weight: 800;
text-align: center;
}
.sidebar-preview {
align-content: start;
}
.sidebar-preview strong {
margin-top: 6px;
}
.sidebar-preview button {
min-height: 34px;
border-color: var(--line);
background: var(--surface-soft);
text-align: left;
}
.settings-grid {
align-items: start;
}
.wide-card {
grid-column: 1 / -1;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
border-bottom: 1px solid var(--line);
}
.toggle-row input {
width: 18px;
height: 18px;
}
.commit-message {
min-height: 140px;
padding: 12px;
resize: vertical;
}
.activity-list {
display: grid;
gap: 12px;
margin: 0;
padding-left: 0;
list-style: none;
}
.activity-list li {
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
}
.activity-list span {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--accent);
}
@media (max-width: 980px) {
#app-shell {
grid-template-columns: 1fr;
}
.workspace-sidebar {
position: static;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.main-nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.nav-item {
justify-content: center;
}
.repo-card {
margin-top: 0;
}
.content-layout,
.two-column,
.publish-layout,
.connect-layout,
.settings-grid,
.frontmatter-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.app-shell,
.workspace-sidebar {
padding: 14px;
}
.topbar,
.editor-header,
.settings-toolbar {
align-items: stretch;
flex-direction: column;
}
.editor-actions {
align-items: stretch;
flex-direction: column;
}
.topbar-actions,
.segmented-control,
.connect-actions {
width: 100%;
}
.topbar-actions button,
.segmented-control button,
.connect-actions button {
flex: 1;
}
.main-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nav-icon {
display: none;
}
.sort-row {
grid-template-columns: 1fr;
}
.editor-pane > textarea,
.markdown-preview {
min-height: 360px;
}
}

71
src/types.ts Normal file
View File

@@ -0,0 +1,71 @@
export type PanelKey = "connect" | "content" | "structure" | "config" | "publish";
export type EditorMode = "write" | "preview";
export type DataSource = "local" | "github";
export interface DocPage {
id: string;
title: string;
path: string;
description: string;
content: string;
sha?: string;
}
export interface NavItem {
id: string;
label: string;
link: string;
}
export interface SidebarGroup {
id: string;
title: string;
items: Array<{
id: string;
label: string;
link: string;
}>;
}
export interface SiteSettings {
title: string;
description: string;
logo: string;
lastUpdated: boolean;
localSearch: boolean;
outline: boolean;
socialKind: string;
socialLink: string;
}
export interface GitHubConnection {
token: string;
owner: string;
repo: string;
branch: string;
siteRoot: string;
}
export interface GitHubUser {
login: string;
avatarUrl?: string;
name?: string;
}
export interface CmsUser {
id: number;
email?: string;
name: string;
avatarUrl?: string;
role: "system_admin" | "user" | string;
}
export interface GitHubRepository {
id: number;
fullName: string;
owner: string;
name: string;
private: boolean;
defaultBranch: string;
updatedAt: string;
}

27
src/utils/markdown.ts Normal file
View File

@@ -0,0 +1,27 @@
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export function markdownToHtml(markdown: string) {
return markdown
.split("\n")
.map((rawLine) => {
const line = escapeHtml(rawLine);
if (line.startsWith("### ")) return `<h3>${line.slice(4)}</h3>`;
if (line.startsWith("## ")) return `<h2>${line.slice(3)}</h2>`;
if (line.startsWith("# ")) return `<h1>${line.slice(2)}</h1>`;
if (line.startsWith("- ")) return `<p class="preview-list">• ${line.slice(2)}</p>`;
if (/^\d+\.\s/.test(line)) return `<p class="preview-list">${line}</p>`;
if (line.startsWith("&gt; ")) return `<blockquote>${line.slice(5)}</blockquote>`;
if (!line.trim()) return "";
return `<p>${line}</p>`;
})
.join("");
}

113
src/utils/siteContent.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { SiteSettings } from "../types";
export 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),
};
}
export function stringifyMarkdownPage(page: {
title: string;
description: string;
content: string;
}) {
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;
}
export function parseSiteSettings(source: string): SiteSettings {
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"),
};
}
export function stringifySiteConfig(settings: SiteSettings) {
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}
},
});
`;
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitepress";
export default defineConfig({
title: "VitePress CMS Test",
description: "A local VitePress site used by VitePress-CMS for integration tests.",
lastUpdated: true,
themeConfig: {
logo: "/logo.svg",
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: "github", link: "https://github.com/example/vitepress-cms" },
],
footer: {
message: "Powered by VitePress-CMS",
copyright: "Copyright 2026",
},
search: {
provider: "local",
},
},
});

24
test-vitepress/README.md Normal file
View File

@@ -0,0 +1,24 @@
# VitePress CMS Test Site
这是一个独立的 VitePress 测试站点,用来模拟真实用户项目。
VitePress-CMS 主项目不依赖 VitePress。后续 CMS 会通过文件系统或 GitHub API 读取这个站点的 Markdown 页面和 `.vitepress/config.ts`
## 本地运行
```bash
npm install
npm run dev
```
默认开发地址:
```text
http://localhost:5175
```
## 构建
```bash
npm run build
```

View File

@@ -0,0 +1,12 @@
---
title: 更新日志
description: 测试站点更新记录
---
# 更新日志
## v0.1.0
- 创建 VitePress 测试站点
- 添加导航栏、侧边栏和多篇 Markdown 页面
- 准备给 VitePress-CMS 做本地读取测试

View File

@@ -0,0 +1,18 @@
---
title: 配置说明
description: 测试 VitePress 配置解析
---
# 配置说明
这个页面用于测试 `.vitepress/config.ts` 的读取与回写。
## 第一版优先支持
- `title`
- `description`
- `themeConfig.logo`
- `themeConfig.nav`
- `themeConfig.sidebar`
- `themeConfig.socialLinks`
- `themeConfig.search`

View File

@@ -0,0 +1,17 @@
---
title: 快速开始
description: 测试站点的快速开始页面
---
# 快速开始
这个页面用于测试 VitePress-CMS 的 Markdown 编辑体验。
## 编辑流程
1. CMS 读取这个文件。
2. 用户在后台修改标题、描述和正文。
3. CMS 将修改保存回 Markdown 文件。
4. VitePress 重新构建站点。
> 这条链路跑通后,项目就从界面原型进入真实可用阶段。

View File

@@ -0,0 +1,20 @@
---
title: 页面管理
description: 测试页面目录、搜索和新增页面
---
# 页面管理
这个页面用于测试页面树和文件路径管理。
## 需要覆盖的场景
- 读取嵌套目录
- 新建 Markdown 页面
- 重命名页面
- 删除页面
- 移动页面位置
## 文件路径
当前文件路径是 `test-vitepress/guide/pages.md`

20
test-vitepress/index.md Normal file
View File

@@ -0,0 +1,20 @@
---
title: 首页
description: VitePress-CMS 测试站点首页
---
# VitePress CMS Test
这是一个给 VitePress-CMS 使用的本地测试站点。
它用来验证这些能力:
- 扫描 Markdown 页面
- 解析 frontmatter
- 读取和修改 `.vitepress/config.ts`
- 管理导航栏和侧边栏
- 构建真实 VitePress 站点
## 测试入口
从 [快速开始](/guide/getting-started) 开始查看测试内容。

2552
test-vitepress/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
{
"name": "vitepress-cms-test-site",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A standalone VitePress site used to test VitePress-CMS integrations.",
"scripts": {
"dev": "vitepress dev . --host 0.0.0.0 --port 5175",
"build": "vitepress build .",
"preview": "vitepress preview . --host 0.0.0.0 --port 5176"
},
"devDependencies": {
"vitepress": "^1.6.0"
}
}

View File

@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VC">
<rect width="64" height="64" rx="12" fill="#117a72"/>
<path d="M14 18h9l9 26 9-26h9L36 50h-8L14 18Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

283
vite.config.ts Normal file
View 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()],
});