Files
VitPress-CMS/src/api/githubSite.ts
T
2026-06-12 00:02:23 +08:00

304 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DocPage, GitHubConnection, SiteSettings } from "../types";
import { parseFrontmatter, parseSiteSettings, stringifyMarkdownPage, stringifySiteConfig } from "../utils/siteContent";
interface InitSiteResponse {
files: Array<{
path: string;
status: "created" | "updated" | "skipped" | "failed";
sha?: string;
error?: string;
}>;
}
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 readApiError(response: Response, fallback: string) {
const data = await response.json().catch(() => null);
return data?.error || `${fallback}${response.status}`;
}
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(await readApiError(response, "读取 GitHub 页面失败"));
}
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(await readApiError(response, "保存 GitHub 页面失败"));
}
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(await readApiError(response, "读取 GitHub 配置失败"));
}
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 initializeGitHubSite(connection: GitHubConnection) {
const response = await fetch("/api/github/site/init", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
site: {
owner: connection.owner,
repo: connection.repo,
branch: connection.branch,
siteRoot: connection.siteRoot,
},
}),
});
if (!response.ok) {
throw new Error(await readApiError(response, "初始化 VitePress 模板失败"));
}
return (await response.json()) as InitSiteResponse;
}
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(await readApiError(response, "保存 GitHub 配置失败"));
}
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;
}