@mezzanine-stack/git-provider-github 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @mezzanine-stack/git-provider-github
2
+
3
+ `@mezzanine-stack/cms-core` の `GitProvider` を GitHub REST API で実装するアダプタです。
4
+
5
+ ## 責務
6
+
7
+ - Markdown エントリの一覧/取得/保存/削除
8
+ - `public/uploads/` 配下のアセット一覧取得
9
+ - 画像・ファイルのアップロード
10
+ - GitHub blob SHA を使った楽観的排他制御
11
+
12
+ ## 主要 API
13
+
14
+ - `GitHubProvider`
15
+ - `GitHubProviderOptions`
16
+ - `GitHubApiError`
17
+
18
+ ## 利用例
19
+
20
+ ```typescript
21
+ import { GitHubProvider } from "@mezzanine-stack/git-provider-github";
22
+
23
+ const provider = new GitHubProvider({
24
+ token: process.env.GITHUB_TOKEN!,
25
+ owner: "your-org",
26
+ repo: "your-site-repo",
27
+ branch: "main",
28
+ });
29
+ ```
30
+
31
+ ## 依存関係
32
+
33
+ - `@mezzanine-stack/cms-core`
34
+
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@mezzanine-stack/git-provider-github",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "import": "./src/index.ts",
13
+ "types": "./src/index.ts"
14
+ }
15
+ },
16
+ "dependencies": {
17
+ "@mezzanine-stack/cms-core": "0.1.0"
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.7.3",
21
+ "vitest": "^3.0.0"
22
+ },
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run"
26
+ }
27
+ }
@@ -0,0 +1,52 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { GitHubProvider } from "./github-provider.js";
3
+
4
+ describe("GitHubProvider", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it("saveEntry sends PUT request and returns revision", async () => {
10
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
11
+ new Response(
12
+ JSON.stringify({
13
+ content: { sha: "new-sha", path: "src/content/blog/hello.md" },
14
+ commit: { sha: "commit-sha" },
15
+ }),
16
+ { status: 200 },
17
+ ),
18
+ );
19
+
20
+ const provider = new GitHubProvider({
21
+ token: "token",
22
+ owner: "owner",
23
+ repo: "repo",
24
+ branch: "main",
25
+ });
26
+
27
+ const result = await provider.saveEntry("src/content/blog/hello.md", "hello", {
28
+ message: "save",
29
+ sha: "old-sha",
30
+ });
31
+
32
+ expect(result).toEqual({ revision: "new-sha" });
33
+ expect(fetchMock).toHaveBeenCalledTimes(1);
34
+ const [url, init] = fetchMock.mock.calls[0] ?? [];
35
+ expect(String(url)).toContain("/contents/src/content/blog/hello.md");
36
+ expect((init as RequestInit).method).toBe("PUT");
37
+ });
38
+
39
+ it("listAssets returns empty array when folder is missing", async () => {
40
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not found", { status: 404 }));
41
+
42
+ const provider = new GitHubProvider({
43
+ token: "token",
44
+ owner: "owner",
45
+ repo: "repo",
46
+ });
47
+
48
+ const assets = await provider.listAssets("public/uploads");
49
+ expect(assets).toEqual([]);
50
+ });
51
+ });
52
+
@@ -0,0 +1,370 @@
1
+ /**
2
+ * GitHub REST API を使った GitProvider 実装。
3
+ *
4
+ * v1 の保存先は default branch への直接 commit に限定する。
5
+ * asset upload (uploadAsset) は CP 4-2 で実装する。
6
+ */
7
+
8
+ import type { AssetItem, CatalogEntry, GitProvider, SaveResult } from "@mezzanine-stack/cms-core";
9
+
10
+ /** GitHub REST API のベース URL(末尾スラッシュなし) */
11
+ const GITHUB_REST_API_ORIGIN = "https://api.github.com";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // GitHub API レスポンス型 (必要最小限)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface GitHubContentItem {
18
+ type: "file" | "dir" | "symlink" | "submodule";
19
+ name: string;
20
+ path: string;
21
+ sha: string;
22
+ size: number;
23
+ download_url: string | null;
24
+ }
25
+
26
+ interface GitHubFileContent {
27
+ type: "file";
28
+ name: string;
29
+ path: string;
30
+ sha: string;
31
+ size: number;
32
+ content: string; // base64 encoded
33
+ encoding: "base64";
34
+ }
35
+
36
+ interface GitHubPutResponse {
37
+ content: {
38
+ sha: string;
39
+ path: string;
40
+ };
41
+ commit: {
42
+ sha: string;
43
+ };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // GitHubProvider
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface GitHubProviderOptions {
51
+ /** GitHub personal access token または OAuth access token */
52
+ token: string;
53
+ /** リポジトリオーナー (ユーザ名 or Org 名) */
54
+ owner: string;
55
+ /** リポジトリ名 */
56
+ repo: string;
57
+ /** 保存先ブランチ (デフォルト: "main") */
58
+ branch?: string;
59
+ }
60
+
61
+ export class GitHubProvider implements GitProvider {
62
+ private readonly token: string;
63
+ private readonly owner: string;
64
+ private readonly repo: string;
65
+ private readonly branch: string;
66
+ private readonly baseUrl: string;
67
+
68
+ constructor(options: GitHubProviderOptions) {
69
+ this.token = options.token;
70
+ this.owner = options.owner;
71
+ this.repo = options.repo;
72
+ this.branch = options.branch ?? "main";
73
+ this.baseUrl = `${GITHUB_REST_API_ORIGIN}/repos/${this.owner}/${this.repo}`;
74
+ }
75
+
76
+ // -------------------------------------------------------------------------
77
+ // listEntries
78
+ // -------------------------------------------------------------------------
79
+
80
+ async listEntries(folder: string): Promise<CatalogEntry[]> {
81
+ const url = `${this.baseUrl}/contents/${folder}?ref=${this.branch}`;
82
+ const items = await this.get<GitHubContentItem[]>(url);
83
+
84
+ const mdFiles = items.filter(
85
+ (item) =>
86
+ item.type === "file" &&
87
+ (item.name.endsWith(".md") || item.name.endsWith(".mdx"))
88
+ );
89
+
90
+ // 各ファイルの frontmatter から title / draft / date を取得する。
91
+ // 並列フェッチで N+1 を最小化する。
92
+ const entries = await Promise.all(
93
+ mdFiles.map(async (item): Promise<CatalogEntry> => {
94
+ try {
95
+ const { content, revision } = await this.getEntry(item.path);
96
+ const { title, draft, date } = extractFrontmatterMeta(content);
97
+ const slug = item.name.replace(/\.(md|mdx)$/, "");
98
+ return {
99
+ entryId: slug,
100
+ title: title ?? slug,
101
+ draft,
102
+ updatedAt: date,
103
+ sourcePath: item.path,
104
+ revision,
105
+ };
106
+ } catch {
107
+ // フェッチ失敗時はファイル名をフォールバックとして返す
108
+ const slug = item.name.replace(/\.(md|mdx)$/, "");
109
+ return {
110
+ entryId: slug,
111
+ sourcePath: item.path,
112
+ revision: item.sha,
113
+ };
114
+ }
115
+ })
116
+ );
117
+
118
+ return entries;
119
+ }
120
+
121
+ // -------------------------------------------------------------------------
122
+ // getEntry
123
+ // -------------------------------------------------------------------------
124
+
125
+ async getEntry(
126
+ sourcePath: string
127
+ ): Promise<{ content: string; revision: string }> {
128
+ const url = `${this.baseUrl}/contents/${sourcePath}?ref=${this.branch}`;
129
+ const data = await this.get<GitHubFileContent>(url);
130
+
131
+ const content = decodeBase64(data.content);
132
+ return { content, revision: data.sha };
133
+ }
134
+
135
+ // -------------------------------------------------------------------------
136
+ // saveEntry
137
+ // -------------------------------------------------------------------------
138
+
139
+ async saveEntry(
140
+ sourcePath: string,
141
+ content: string,
142
+ options: { sha?: string; message: string }
143
+ ): Promise<SaveResult> {
144
+ const url = `${this.baseUrl}/contents/${sourcePath}`;
145
+ const body: Record<string, unknown> = {
146
+ message: options.message,
147
+ content: encodeBase64(content),
148
+ branch: this.branch,
149
+ };
150
+ if (options.sha) {
151
+ body.sha = options.sha;
152
+ }
153
+
154
+ const data = await this.put<GitHubPutResponse>(url, body);
155
+ return { revision: data.content.sha };
156
+ }
157
+
158
+ // -------------------------------------------------------------------------
159
+ // deleteEntry
160
+ // -------------------------------------------------------------------------
161
+
162
+ async deleteEntry(
163
+ sourcePath: string,
164
+ sha: string,
165
+ message: string
166
+ ): Promise<void> {
167
+ const url = `${this.baseUrl}/contents/${sourcePath}`;
168
+ await this.delete(url, { message, sha, branch: this.branch });
169
+ }
170
+
171
+ // -------------------------------------------------------------------------
172
+ // listAssets
173
+ // -------------------------------------------------------------------------
174
+
175
+ /**
176
+ * 指定ディレクトリ配下のアセット一覧を返す。
177
+ * ディレクトリが存在しない場合は空配列を返す。
178
+ */
179
+ async listAssets(folder: string): Promise<AssetItem[]> {
180
+ const url = `${this.baseUrl}/contents/${folder}?ref=${this.branch}`;
181
+ let items: GitHubContentItem[];
182
+ try {
183
+ items = await this.get<GitHubContentItem[]>(url);
184
+ } catch (err) {
185
+ if (err instanceof GitHubApiError && err.status === 404) {
186
+ return [];
187
+ }
188
+ throw err;
189
+ }
190
+
191
+ // ファイルのみを返す(ディレクトリは除外)
192
+ return items
193
+ .filter((item) => item.type === "file")
194
+ .map((item) => {
195
+ // "public/uploads/foo.jpg" → "/uploads/foo.jpg"
196
+ const publicPath = folder.startsWith("public")
197
+ ? item.path.slice("public".length)
198
+ : `/${item.path}`;
199
+ return {
200
+ name: item.name,
201
+ path: item.path,
202
+ publicPath,
203
+ size: item.size,
204
+ };
205
+ });
206
+ }
207
+
208
+ // -------------------------------------------------------------------------
209
+ // uploadAsset
210
+ // -------------------------------------------------------------------------
211
+
212
+ /**
213
+ * バイナリアセットを GitHub リポジトリにアップロードする。
214
+ * 既存ファイルが存在する場合は SHA を取得して上書きする。
215
+ * @returns アップロード先のリポジトリ内パス
216
+ */
217
+ async uploadAsset(
218
+ sourcePath: string,
219
+ content: Uint8Array,
220
+ message: string
221
+ ): Promise<string> {
222
+ const url = `${this.baseUrl}/contents/${sourcePath}`;
223
+ const base64 = uint8ArrayToBase64(content);
224
+
225
+ // 既存ファイルの SHA を取得して楽観的排他を維持する
226
+ let existingSha: string | undefined;
227
+ try {
228
+ const existing = await this.get<GitHubFileContent>(
229
+ `${url}?ref=${this.branch}`
230
+ );
231
+ existingSha = existing.sha;
232
+ } catch {
233
+ // 404 = 新規ファイル、それ以外は無視して続行
234
+ }
235
+
236
+ const body: Record<string, unknown> = {
237
+ message,
238
+ content: base64,
239
+ branch: this.branch,
240
+ };
241
+ if (existingSha) {
242
+ body.sha = existingSha;
243
+ }
244
+
245
+ await this.put<GitHubPutResponse>(url, body);
246
+ return sourcePath;
247
+ }
248
+
249
+ // -------------------------------------------------------------------------
250
+ // HTTP ヘルパー
251
+ // -------------------------------------------------------------------------
252
+
253
+ private headers(): Record<string, string> {
254
+ return {
255
+ Authorization: `Bearer ${this.token}`,
256
+ Accept: "application/vnd.github+json",
257
+ "X-GitHub-Api-Version": "2022-11-28",
258
+ "Content-Type": "application/json",
259
+ };
260
+ }
261
+
262
+ private async get<T>(url: string): Promise<T> {
263
+ const res = await fetch(url, { headers: this.headers() });
264
+ if (!res.ok) {
265
+ throw new GitHubApiError(res.status, await res.text(), "GET", url);
266
+ }
267
+ return res.json() as Promise<T>;
268
+ }
269
+
270
+ private async put<T>(url: string, body: unknown): Promise<T> {
271
+ const res = await fetch(url, {
272
+ method: "PUT",
273
+ headers: this.headers(),
274
+ body: JSON.stringify(body),
275
+ });
276
+ if (!res.ok) {
277
+ throw new GitHubApiError(res.status, await res.text(), "PUT", url);
278
+ }
279
+ return res.json() as Promise<T>;
280
+ }
281
+
282
+ private async delete(url: string, body: unknown): Promise<void> {
283
+ const res = await fetch(url, {
284
+ method: "DELETE",
285
+ headers: this.headers(),
286
+ body: JSON.stringify(body),
287
+ });
288
+ if (!res.ok) {
289
+ throw new GitHubApiError(res.status, await res.text(), "DELETE", url);
290
+ }
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // エラー型
296
+ // ---------------------------------------------------------------------------
297
+
298
+ export class GitHubApiError extends Error {
299
+ constructor(
300
+ public readonly status: number,
301
+ public readonly body: string,
302
+ public readonly method: string,
303
+ public readonly url: string
304
+ ) {
305
+ super(`GitHub API ${method} ${url} failed with ${status}: ${body}`);
306
+ this.name = "GitHubApiError";
307
+ }
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // ユーティリティ
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /** GitHub API の base64 文字列 (改行含む) をデコードする */
315
+ function decodeBase64(encoded: string): string {
316
+ const cleaned = encoded.replace(/\n/g, "");
317
+ const binary = atob(cleaned);
318
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
319
+ return new TextDecoder("utf-8").decode(bytes);
320
+ }
321
+
322
+ /** テキストを base64 にエンコードする */
323
+ function encodeBase64(text: string): string {
324
+ return btoa(unescape(encodeURIComponent(text)));
325
+ }
326
+
327
+ /** Uint8Array を base64 文字列に変換する */
328
+ function uint8ArrayToBase64(bytes: Uint8Array): string {
329
+ let binary = "";
330
+ for (let i = 0; i < bytes.length; i++) {
331
+ binary += String.fromCharCode(bytes[i]);
332
+ }
333
+ return btoa(binary);
334
+ }
335
+
336
+ /**
337
+ * Markdown テキストから frontmatter の title / draft / date を簡易抽出する。
338
+ * 完全な YAML パースは cms-core の deserializeMarkdown に委ねるが、
339
+ * listEntries の軽量化のためにここでは行単位の簡易抽出を使う。
340
+ */
341
+ function extractFrontmatterMeta(content: string): {
342
+ title?: string;
343
+ draft?: boolean;
344
+ date?: string;
345
+ } {
346
+ const lines = content.split("\n");
347
+ if (lines[0]?.trim() !== "---") return {};
348
+
349
+ const result: { title?: string; draft?: boolean; date?: string } = {};
350
+
351
+ for (let i = 1; i < lines.length; i++) {
352
+ const line = lines[i];
353
+ if (line?.trim() === "---") break;
354
+
355
+ const match = line?.match(/^(\w+):\s*(.+)$/);
356
+ if (!match) continue;
357
+ const [, key, val] = match;
358
+ const value = val?.trim() ?? "";
359
+
360
+ if (key === "title") {
361
+ result.title = value.replace(/^["']|["']$/g, "");
362
+ } else if (key === "draft") {
363
+ result.draft = value === "true";
364
+ } else if (key === "date") {
365
+ result.date = value.replace(/^["']|["']$/g, "");
366
+ }
367
+ }
368
+
369
+ return result;
370
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // git-provider-github 公開 API
2
+
3
+ export {
4
+ GitHubProvider,
5
+ GitHubApiError,
6
+ type GitHubProviderOptions,
7
+ } from "./github-provider.js";
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "lib": ["ES2022", "DOM"]
7
+ },
8
+ "include": ["src"]
9
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.ts"],
6
+ },
7
+ });
8
+