304 lines
8.6 KiB
TypeScript
304 lines
8.6 KiB
TypeScript
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;
|
||
}
|