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 = { 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(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( 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( 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; }