/** * Git-backed collections: entries are JSON files in the site's git repo, * `content//.json`, read from and written to GitHub * directly — saving in the admin is a commit, and the same files are what * the static frontend build renders from, so this content never round-trips * through the database. Only available once the site's GitHub connection * (Settings → General) has stored `github:token/owner/repo`. * * Entries keep the ContentItem shape the admin already understands. The id * IS the slug (files have no other identity); there are no drafts, * revisions, trash or scheduling — the git history is the revision history. */ import type { Kysely } from "kysely"; import type { Database } from "../database/types.js"; import { OptionsRepository } from "../database/repositories/options.js"; import type { ContentItem } from "../plugins/types.js"; import { slugify } from "../utils/slugify.js"; export interface GitRepoConnection { token: string; owner: string; repo: string; branch: string; } interface GitEntryFile { $schema?: string; slug: string; status: string; locale?: string | null; createdAt: string; updatedAt: string; publishedAt?: string | null; data: Record; } interface GhFile { name: string; path: string; sha: string; type: string; content?: string; encoding?: string; } export class GitStoreError extends Error { constructor( message: string, public readonly code: "NOT_CONNECTED" | "NOT_FOUND" | "CONFLICT" | "GITHUB", public readonly status = 500, ) { super(message); } } const GH = "https://api.github.com"; const B64_NEWLINES = /\n/g; /** The repo connection stored on this site, or null when GitHub isn't connected. */ export async function gitConnection(db: Kysely): Promise { const options = new OptionsRepository(db); const map = await options.getMany([ "github:token", "github:owner", "github:repo", "github:branch", ]); const token = map.get("github:token") ?? ""; const owner = map.get("github:owner") ?? ""; const repo = map.get("github:repo") ?? ""; if (!token || !owner || !repo) return null; return { token, owner, repo, branch: map.get("github:branch") || "main" }; } function b64encode(text: string): string { return btoa(String.fromCharCode(...new TextEncoder().encode(text))); } function b64decode(b64: string): string { const bin = atob(b64.replace(B64_NEWLINES, "")); const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); return new TextDecoder().decode(bytes); } export class GitContentStore { constructor( private readonly conn: GitRepoConnection, private readonly collection: string, ) {} private get dir(): string { return `content/${this.collection}`; } private async gh( method: string, path: string, body?: unknown, ): Promise<{ ok: boolean; status: number; json: T | null }> { const res = await fetch(`${GH}${path}`, { method, headers: { Authorization: `Bearer ${this.conn.token}`, Accept: "application/vnd.github+json", "User-Agent": "premium-cms", "X-GitHub-Api-Version": "2022-11-28", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }); let json: T | null = null; try { json = (await res.json()) as T; } catch { json = null; } if (res.status === 401 || res.status === 403) { throw new GitStoreError( "GitHub rejected the site's token — reconnect GitHub in Settings → General.", "GITHUB", 502, ); } return { ok: res.ok, status: res.status, json }; } private toItem(file: GitEntryFile): ContentItem { return { id: file.slug, type: this.collection, slug: file.slug, status: file.status || "published", locale: file.locale ?? null, data: file.data ?? {}, createdAt: file.createdAt, updatedAt: file.updatedAt, publishedAt: file.publishedAt ?? (file.status === "published" ? file.updatedAt : null), }; } private async readFile(slug: string): Promise<{ file: GitEntryFile; sha: string } | null> { const r = await this.gh( "GET", `/repos/${this.conn.owner}/${this.conn.repo}/contents/${this.dir}/${encodeURIComponent(slug)}.json?ref=${encodeURIComponent(this.conn.branch)}`, ); if (r.status === 404 || !r.json?.content) return null; try { return { file: JSON.parse(b64decode(r.json.content)) as GitEntryFile, sha: r.json.sha }; } catch { return null; } } private async writeFile( slug: string, file: GitEntryFile, message: string, sha?: string, ): Promise { const body: Record = { message, content: b64encode(`${JSON.stringify(file, null, "\t")}\n`), branch: this.conn.branch, }; if (sha) body.sha = sha; const r = await this.gh<{ message?: string }>( "PUT", `/repos/${this.conn.owner}/${this.conn.repo}/contents/${this.dir}/${encodeURIComponent(slug)}.json`, body, ); if (r.status === 409 || r.status === 422) { throw new GitStoreError( "The file changed in git since it was loaded — reload and try again.", "CONFLICT", 409, ); } if (!r.ok) throw new GitStoreError(r.json?.message || `GitHub ${r.status}`, "GITHUB", 502); } /** Every entry in the collection (one listing call + one read per file). */ async list(): Promise { const r = await this.gh( "GET", `/repos/${this.conn.owner}/${this.conn.repo}/contents/${this.dir}?ref=${encodeURIComponent(this.conn.branch)}`, ); if (r.status === 404 || !Array.isArray(r.json)) return []; const items: ContentItem[] = []; for (const f of r.json) { if (f.type !== "file" || !f.name.endsWith(".json")) continue; const read = await this.readFile(f.name.slice(0, -5)); if (read) items.push(this.toItem(read.file)); } return items.toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } async get(idOrSlug: string): Promise { const read = await this.readFile(idOrSlug); return read ? this.toItem(read.file) : null; } async create(input: { slug?: string | null; status?: string; locale?: string; data: Record; }): Promise { const title = typeof input.data.title === "string" ? input.data.title : ""; let slug = (input.slug && slugify(input.slug)) || slugify(title) || `entry-${Date.now()}`; if (await this.readFile(slug)) slug = `${slug}-${Date.now().toString(36)}`; const now = new Date().toISOString(); const file: GitEntryFile = { $schema: "../../seed/.schemas/content-entry.schema.json", slug, status: input.status || "published", locale: input.locale ?? null, createdAt: now, updatedAt: now, publishedAt: (input.status || "published") === "published" ? now : null, data: input.data, }; await this.writeFile(slug, file, `content(${this.collection}): add ${slug}`); return this.toItem(file); } async update( idOrSlug: string, input: { slug?: string | null; status?: string; data?: Record }, ): Promise { const read = await this.readFile(idOrSlug); if (!read) throw new GitStoreError(`Entry not found: ${idOrSlug}`, "NOT_FOUND", 404); const now = new Date().toISOString(); const next: GitEntryFile = { ...read.file, status: input.status ?? read.file.status, data: input.data ? { ...read.file.data, ...input.data } : read.file.data, updatedAt: now, }; if (next.status === "published" && !next.publishedAt) next.publishedAt = now; const newSlug = input.slug ? slugify(input.slug) : ""; if (newSlug && newSlug !== read.file.slug) { next.slug = newSlug; await this.writeFile(newSlug, next, `content(${this.collection}): rename ${read.file.slug} → ${newSlug}`); await this.remove(read.file.slug, `content(${this.collection}): remove ${read.file.slug} (renamed)`); return this.toItem(next); } await this.writeFile(read.file.slug, next, `content(${this.collection}): update ${read.file.slug}`, read.sha); return this.toItem(next); } async remove(idOrSlug: string, message?: string): Promise { const read = await this.readFile(idOrSlug); if (!read) return false; const r = await this.gh<{ message?: string }>( "DELETE", `/repos/${this.conn.owner}/${this.conn.repo}/contents/${this.dir}/${encodeURIComponent(idOrSlug)}.json`, { message: message ?? `content(${this.collection}): remove ${idOrSlug}`, sha: read.sha, branch: this.conn.branch }, ); if (!r.ok) throw new GitStoreError(r.json?.message || `GitHub ${r.status}`, "GITHUB", 502); return true; } }