@stelstone/server 0.26.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 +275 -0
- package/bin/stelstone.mjs +181 -0
- package/package.json +53 -0
- package/src/adapters/_shared.mjs +401 -0
- package/src/adapters/basic-auth.mjs +102 -0
- package/src/adapters/build-netlify.mjs +60 -0
- package/src/adapters/cdn-proxy-media.mjs +79 -0
- package/src/adapters/cloudflare-access.mjs +144 -0
- package/src/adapters/fs-json-content.mjs +302 -0
- package/src/adapters/fs-templates.mjs +57 -0
- package/src/adapters/github-api.mjs +100 -0
- package/src/adapters/github-content.mjs +577 -0
- package/src/adapters/github-oauth.mjs +153 -0
- package/src/adapters/github-templates.mjs +100 -0
- package/src/adapters/index.mjs +12 -0
- package/src/adapters/local-assets-media.mjs +68 -0
- package/src/adapters/media-url.mjs +133 -0
- package/src/adapters/resend-mail.mjs +41 -0
- package/src/adapters/types.mjs +104 -0
- package/src/admin-ui-path.mjs +77 -0
- package/src/core/adapter-options.mjs +167 -0
- package/src/core/config-schema.mjs +408 -0
- package/src/core/forms.mjs +99 -0
- package/src/core/handler.mjs +209 -0
- package/src/core/node-adapter.mjs +99 -0
- package/src/core/static-files.mjs +115 -0
- package/src/default-public-config.mjs +39 -0
- package/src/index.mjs +22 -0
- package/src/routes.mjs +737 -0
- package/src/server.mjs +325 -0
- package/src/version.mjs +8 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { createGitHubApi } from "./github-api.mjs";
|
|
2
|
+
import { sanitize, safeFileName, sortPages, buildDuplicateData, listScheduledDue, commitMsg, buildListEntry, isEntryFile } from "./_shared.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GitHub Contents API adapter — serverless content backend.
|
|
6
|
+
*
|
|
7
|
+
* Every writePage/createPage/deletePage is an immediate commit to GitHub.
|
|
8
|
+
* No git binary required. Safe to run on Vercel, Cloudflare Workers, etc.
|
|
9
|
+
*
|
|
10
|
+
* With `draftBranch` set, those commits land on the draft branch instead of
|
|
11
|
+
* the deploy branch: saving stops publishing, and Publish (whole-site or a
|
|
12
|
+
* single record) moves files onto the deploy branch — the same
|
|
13
|
+
* save-then-publish model as the fs backend.
|
|
14
|
+
*
|
|
15
|
+
* Listing strategy: each collection keeps a `_index.json` manifest that
|
|
16
|
+
* contains only the listing metadata (id, slug, lang, title, file, meta).
|
|
17
|
+
* listPages fetches this single file — 1 subrequest regardless of size.
|
|
18
|
+
* On first call (no index yet) it bootstraps via GraphQL and persists the
|
|
19
|
+
* index. All write operations keep the index in sync.
|
|
20
|
+
*
|
|
21
|
+
* Config (cms.config.mjs):
|
|
22
|
+
* content: {
|
|
23
|
+
* provider: "github",
|
|
24
|
+
* githubTokenEnv: "GITHUB_TOKEN", // PAT with repo scope
|
|
25
|
+
* owner: "your-org",
|
|
26
|
+
* repo: "your-site",
|
|
27
|
+
* branch: "main",
|
|
28
|
+
* pagesDir: "src/pages-data",
|
|
29
|
+
* commitMessage: (ts) => `Content updated ${ts}`,
|
|
30
|
+
* list: {
|
|
31
|
+
* strategy: "index", // "index" (default) — only built-in strategy
|
|
32
|
+
* rebuild: "build", // "build" (default) | "lazy"
|
|
33
|
+
* indexFile: "_index.json", // manifest filename
|
|
34
|
+
* resolve: undefined, // optional async (collection, { sortConfig }) => entries[]|null
|
|
35
|
+
* },
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} opts
|
|
39
|
+
* @param {string} opts.token
|
|
40
|
+
* @param {string} opts.owner
|
|
41
|
+
* @param {string} opts.repo
|
|
42
|
+
* @param {string} opts.branch
|
|
43
|
+
* @param {string} [opts.draftBranch] Opt-in draft workflow. When set, every
|
|
44
|
+
* CMS read and write targets this branch instead of `branch`; Publish
|
|
45
|
+
* moves the record's file onto `branch` with a git-data commit. Without
|
|
46
|
+
* it, behaviour is the original: saving commits straight to `branch`,
|
|
47
|
+
* i.e. saving IS publishing. The gap this closes: with commit-on-save
|
|
48
|
+
* to the deploy branch, editing a LIVE page put it in production the
|
|
49
|
+
* moment it was saved — there was no "work on it, publish when ready".
|
|
50
|
+
* @param {string} opts.pagesDir
|
|
51
|
+
* @param {(ts: string) => string} opts.commitMessage
|
|
52
|
+
* @param {Object} [opts.list] Listing strategy config (see above)
|
|
53
|
+
* @returns {import('./types.mjs').ContentAdapter}
|
|
54
|
+
*/
|
|
55
|
+
export function createGitHubContent({ token, owner, repo, branch, draftBranch, pagesDir, commitMessage, list = {}, collections = {} }) {
|
|
56
|
+
const listConfig = {
|
|
57
|
+
strategy: "index",
|
|
58
|
+
rebuild: "build",
|
|
59
|
+
indexFile: "_index.json",
|
|
60
|
+
resolve: undefined,
|
|
61
|
+
...list,
|
|
62
|
+
};
|
|
63
|
+
const { apiGet, apiPut, apiDelete, apiPost, apiPatch, graphql } = createGitHubApi({ token, owner, repo });
|
|
64
|
+
// SHA cache: avoid extra GET before every PUT. Invalidated on write.
|
|
65
|
+
const shaCache = new Map();
|
|
66
|
+
|
|
67
|
+
// The branch the CMS works on. In draft mode that is the draft branch and
|
|
68
|
+
// `branch` remains the published/deploy branch; otherwise they are one and
|
|
69
|
+
// the adapter behaves exactly as before this option existed.
|
|
70
|
+
const draftMode = !!draftBranch && draftBranch !== branch;
|
|
71
|
+
const workBranch = draftMode ? draftBranch : branch;
|
|
72
|
+
|
|
73
|
+
// Lazily create the draft branch off the published branch. Checked once per
|
|
74
|
+
// process; a 404 on the ref is the "first ever use" case, not an error.
|
|
75
|
+
let draftBranchReady = !draftMode;
|
|
76
|
+
async function ensureDraftBranch() {
|
|
77
|
+
if (draftBranchReady) return;
|
|
78
|
+
const ref = await apiGet(`/git/ref/heads/${draftBranch}`);
|
|
79
|
+
if (!ref) {
|
|
80
|
+
const base = await apiGet(`/git/ref/heads/${branch}`);
|
|
81
|
+
if (!base) throw new Error(`Published branch "${branch}" does not exist`);
|
|
82
|
+
await apiPost(`/git/refs`, { ref: `refs/heads/${draftBranch}`, sha: base.object.sha });
|
|
83
|
+
}
|
|
84
|
+
draftBranchReady = true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function contentPath(collection, file) {
|
|
88
|
+
return `${pagesDir}/${collection}/${file}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function indexFilePath(collection) {
|
|
92
|
+
return `${pagesDir}/${collection}/${listConfig.indexFile}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getFileSha(path) {
|
|
96
|
+
if (shaCache.has(path)) return shaCache.get(path);
|
|
97
|
+
const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
|
|
98
|
+
if (!data || Array.isArray(data)) return null;
|
|
99
|
+
shaCache.set(path, data.sha);
|
|
100
|
+
return data.sha;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decodeContent(data) {
|
|
104
|
+
const raw = Buffer.from(data.content, "base64").toString("utf-8");
|
|
105
|
+
return JSON.parse(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function encodeContent(obj) {
|
|
109
|
+
return Buffer.from(JSON.stringify(obj, null, 2), "utf-8").toString("base64");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build the listing record stored in _index.json for a single entry. */
|
|
113
|
+
function buildIndexEntry(collection, file, data) {
|
|
114
|
+
return buildListEntry(collections[collection], collection, file, data);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read _index.json for a collection; returns { entries: [] } if absent. */
|
|
118
|
+
async function readIndex(collection) {
|
|
119
|
+
try {
|
|
120
|
+
const path = indexFilePath(collection);
|
|
121
|
+
const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
|
|
122
|
+
if (!data || Array.isArray(data)) return { entries: [] };
|
|
123
|
+
shaCache.set(path, data.sha);
|
|
124
|
+
return decodeContent(data);
|
|
125
|
+
} catch {
|
|
126
|
+
return { entries: [] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Persist an updated index object to _index.json (single commit). */
|
|
131
|
+
async function writeIndex(collection, index) {
|
|
132
|
+
const path = indexFilePath(collection);
|
|
133
|
+
const sha = shaCache.get(path) ?? await getFileSha(path);
|
|
134
|
+
const result = await apiPut(`/contents/${path}`, {
|
|
135
|
+
message: commitMsg(commitMessage),
|
|
136
|
+
content: encodeContent(index),
|
|
137
|
+
branch: workBranch,
|
|
138
|
+
...(sha ? { sha } : {}),
|
|
139
|
+
});
|
|
140
|
+
shaCache.set(path, result.content?.sha);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Add or replace one entry in _index.json, then persist. */
|
|
144
|
+
async function upsertIndexEntry(collection, file, data) {
|
|
145
|
+
const index = await readIndex(collection);
|
|
146
|
+
const entry = buildIndexEntry(collection, file, data);
|
|
147
|
+
const pos = index.entries.findIndex((e) => e.file === file);
|
|
148
|
+
if (pos >= 0) index.entries[pos] = entry;
|
|
149
|
+
else index.entries.push(entry);
|
|
150
|
+
await writeIndex(collection, index);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Remove one entry from _index.json, then persist. */
|
|
154
|
+
async function removeIndexEntry(collection, file) {
|
|
155
|
+
const index = await readIndex(collection);
|
|
156
|
+
index.entries = index.entries.filter((e) => e.file !== file);
|
|
157
|
+
await writeIndex(collection, index);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// GraphQL query for lazy bootstrap: fetch all blob text in one request.
|
|
161
|
+
const BOOTSTRAP_QUERY = `
|
|
162
|
+
query($owner: String!, $repo: String!, $expr: String!) {
|
|
163
|
+
repository(owner: $owner, name: $repo) {
|
|
164
|
+
object(expression: $expr) {
|
|
165
|
+
... on Tree {
|
|
166
|
+
entries {
|
|
167
|
+
name
|
|
168
|
+
oid
|
|
169
|
+
type
|
|
170
|
+
object {
|
|
171
|
+
... on Blob {
|
|
172
|
+
text
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
async listCollections() {
|
|
184
|
+
await ensureDraftBranch();
|
|
185
|
+
const items = await apiGet(`/contents/${pagesDir}?ref=${workBranch}`);
|
|
186
|
+
if (!items || !Array.isArray(items)) return [];
|
|
187
|
+
const dirs = items.filter((i) => i.type === "dir");
|
|
188
|
+
return Promise.all(
|
|
189
|
+
dirs.map(async (d) => {
|
|
190
|
+
const files = await apiGet(`/contents/${pagesDir}/${d.name}?ref=${workBranch}`);
|
|
191
|
+
const count = Array.isArray(files)
|
|
192
|
+
? files.filter((f) => isEntryFile(f.name) && f.name !== listConfig.indexFile).length
|
|
193
|
+
: 0;
|
|
194
|
+
return { name: d.name, count };
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async listPages(collection, sortConfig = null) {
|
|
200
|
+
await ensureDraftBranch();
|
|
201
|
+
const safeCollection = sanitize(collection);
|
|
202
|
+
|
|
203
|
+
// 1. Custom resolver takes full control — nothing else runs.
|
|
204
|
+
if (typeof listConfig.resolve === "function") {
|
|
205
|
+
return await listConfig.resolve(collection, { sortConfig });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 2. strategy "index": try the pre-built manifest first (1 subrequest).
|
|
209
|
+
const idxPath = indexFilePath(safeCollection);
|
|
210
|
+
try {
|
|
211
|
+
const data = await apiGet(`/contents/${idxPath}?ref=${workBranch}`);
|
|
212
|
+
if (data && !Array.isArray(data)) {
|
|
213
|
+
shaCache.set(idxPath, data.sha);
|
|
214
|
+
const index = decodeContent(data);
|
|
215
|
+
if (Array.isArray(index.entries)) {
|
|
216
|
+
return sortPages(index.entries, sortConfig);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Non-404 error reading index — fall through to rebuild logic.
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Index absent: behaviour depends on rebuild strategy.
|
|
224
|
+
if (listConfig.rebuild === "lazy") {
|
|
225
|
+
// Bootstrap: one GraphQL call fetching all blob text, then persist.
|
|
226
|
+
// On GraphQL failure, throw — never fall back to REST N+1.
|
|
227
|
+
const expression = `${workBranch}:${pagesDir}/${safeCollection}`;
|
|
228
|
+
const gqlData = await graphql(BOOTSTRAP_QUERY, { owner, repo, expr: expression });
|
|
229
|
+
|
|
230
|
+
const treeObj = gqlData?.repository?.object;
|
|
231
|
+
if (!treeObj) return null;
|
|
232
|
+
|
|
233
|
+
const pages = [];
|
|
234
|
+
for (const entry of treeObj.entries || []) {
|
|
235
|
+
if (entry.type !== "blob" || !entry.name.endsWith(".json") || entry.name === listConfig.indexFile) continue;
|
|
236
|
+
shaCache.set(`${pagesDir}/${safeCollection}/${entry.name}`, entry.oid);
|
|
237
|
+
let data;
|
|
238
|
+
try { data = JSON.parse(entry.object.text); } catch { continue; }
|
|
239
|
+
pages.push(buildIndexEntry(collection, entry.name, data));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Persist for all future calls.
|
|
243
|
+
try {
|
|
244
|
+
await writeIndex(safeCollection, { entries: pages });
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.warn(`[listPages] Could not write ${listConfig.indexFile}:`, err?.message);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return sortPages(pages, sortConfig);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// rebuild === "build" (default): never fetch all bodies at runtime.
|
|
253
|
+
// Check whether the collection directory actually exists.
|
|
254
|
+
const items = await apiGet(`/contents/${pagesDir}/${safeCollection}?ref=${workBranch}`);
|
|
255
|
+
if (!Array.isArray(items)) return null; // 404 or non-dir → unknown collection
|
|
256
|
+
|
|
257
|
+
// Collection exists but index hasn't been built yet.
|
|
258
|
+
console.warn(
|
|
259
|
+
`[listPages] No ${listConfig.indexFile} found for collection "${safeCollection}". ` +
|
|
260
|
+
`Run \`stelstone build-index\` to generate it.`,
|
|
261
|
+
);
|
|
262
|
+
return [];
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async readPage(collection, file) {
|
|
266
|
+
await ensureDraftBranch();
|
|
267
|
+
if (!safeFileName(file)) return null;
|
|
268
|
+
const path = contentPath(collection, sanitize(file));
|
|
269
|
+
const data = await apiGet(`/contents/${path}?ref=${workBranch}`);
|
|
270
|
+
if (!data || Array.isArray(data)) return null;
|
|
271
|
+
shaCache.set(path, data.sha);
|
|
272
|
+
return decodeContent(data);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
/** Version token for optimistic concurrency — the blob SHA GitHub already keeps. */
|
|
276
|
+
async versionOf(collection, file) {
|
|
277
|
+
if (!safeFileName(file)) return null;
|
|
278
|
+
return getFileSha(contentPath(collection, sanitize(file)));
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @param {Object} [opts]
|
|
283
|
+
* @param {string} [opts.expectedVersion] The blob SHA the caller last read.
|
|
284
|
+
* Passing it through means GitHub itself enforces the precondition:
|
|
285
|
+
* it rejects a PUT whose sha is not current. Re-reading the sha at
|
|
286
|
+
* write time — which is what this did before — discarded that
|
|
287
|
+
* guarantee and silently overwrote a concurrent edit.
|
|
288
|
+
*/
|
|
289
|
+
async writePage(collection, file, data, { expectedVersion } = {}) {
|
|
290
|
+
await ensureDraftBranch();
|
|
291
|
+
const path = contentPath(collection, sanitize(file));
|
|
292
|
+
const sha = expectedVersion ?? (await getFileSha(path));
|
|
293
|
+
let result;
|
|
294
|
+
try {
|
|
295
|
+
result = await apiPut(`/contents/${path}`, {
|
|
296
|
+
message: commitMsg(commitMessage),
|
|
297
|
+
content: encodeContent(data),
|
|
298
|
+
branch: workBranch,
|
|
299
|
+
...(sha ? { sha } : {}),
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// GitHub answers 409 when the supplied sha is stale.
|
|
303
|
+
if (expectedVersion && (err.upstreamStatus === 409 || err.status === 409)) {
|
|
304
|
+
shaCache.delete(path);
|
|
305
|
+
const conflict = new Error("This entry changed since you loaded it.");
|
|
306
|
+
conflict.status = 412;
|
|
307
|
+
throw conflict;
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
shaCache.set(path, result.content?.sha);
|
|
312
|
+
await upsertIndexEntry(collection, file, data);
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async createPage(collection, data) {
|
|
316
|
+
await ensureDraftBranch();
|
|
317
|
+
const slug = data.slug || data.id || `new-${Date.now()}`;
|
|
318
|
+
if (!sanitize(slug) || sanitize(slug) !== String(slug)) {
|
|
319
|
+
// Reject rather than silently stripping characters — otherwise the
|
|
320
|
+
// file name and data.slug would diverge (e.g. "ürünler" → "rnler").
|
|
321
|
+
const err = new Error(
|
|
322
|
+
`Slug "${slug}" contains unsupported characters — use only a-z, 0-9, dots, dashes, underscores.`,
|
|
323
|
+
);
|
|
324
|
+
err.status = 400;
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
const fileName = `${sanitize(data.lang || "en")}-${sanitize(slug)}.json`;
|
|
328
|
+
const path = contentPath(collection, fileName);
|
|
329
|
+
if (await getFileSha(path)) {
|
|
330
|
+
const err = new Error(
|
|
331
|
+
`An entry already exists for "${data.lang || "en"}/${slug}". Change the slug or language.`,
|
|
332
|
+
);
|
|
333
|
+
err.status = 409;
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
const result = await apiPut(`/contents/${path}`, {
|
|
337
|
+
message: commitMsg(commitMessage),
|
|
338
|
+
content: encodeContent(data),
|
|
339
|
+
branch: workBranch,
|
|
340
|
+
});
|
|
341
|
+
shaCache.set(path, result.content?.sha);
|
|
342
|
+
await upsertIndexEntry(collection, fileName, data);
|
|
343
|
+
return { file: fileName };
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async deletePage(collection, file) {
|
|
347
|
+
await ensureDraftBranch();
|
|
348
|
+
if (!safeFileName(file)) return null;
|
|
349
|
+
const path = contentPath(collection, sanitize(file));
|
|
350
|
+
const sha = await getFileSha(path);
|
|
351
|
+
if (!sha) return false;
|
|
352
|
+
await apiDelete(`/contents/${path}`, { message: commitMsg(commitMessage), sha, branch: workBranch });
|
|
353
|
+
shaCache.delete(path);
|
|
354
|
+
await removeIndexEntry(collection, file);
|
|
355
|
+
return true;
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
async duplicatePage(collection, file) {
|
|
359
|
+
if (!safeFileName(file)) return null;
|
|
360
|
+
const src = await this.readPage(collection, file);
|
|
361
|
+
if (!src) return null;
|
|
362
|
+
const { data, fileName } = buildDuplicateData(src);
|
|
363
|
+
return this.createPage(collection, data);
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
// In draft mode saving and publishing are separate steps, exactly like
|
|
367
|
+
// the fs backend. Without a draft branch, writes commit straight to the
|
|
368
|
+
// deploy branch — saving IS publishing — and the per-entry publish route
|
|
369
|
+
// answers 501 instead of pretending.
|
|
370
|
+
capabilities: draftMode
|
|
371
|
+
? { deferredPublish: true, perEntryPublish: true }
|
|
372
|
+
: { deferredPublish: false, perEntryPublish: false },
|
|
373
|
+
|
|
374
|
+
async pendingChanges() {
|
|
375
|
+
if (!draftMode) return { hasChanges: false, changedFiles: 0, files: [] };
|
|
376
|
+
await ensureDraftBranch();
|
|
377
|
+
// One compare call: which files differ between published and draft?
|
|
378
|
+
// (GitHub caps the file list at 300 — orders of magnitude above any
|
|
379
|
+
// real collection here; still, say so rather than rely on it silently.)
|
|
380
|
+
const cmp = await apiGet(`/compare/${branch}...${draftBranch}`);
|
|
381
|
+
const files = (cmp?.files ?? [])
|
|
382
|
+
.filter((f) => f.filename.startsWith(`${pagesDir}/`))
|
|
383
|
+
// Index manifests live on the draft branch only — the site build
|
|
384
|
+
// ignores files starting with "_", so they are never published.
|
|
385
|
+
.filter((f) => !f.filename.endsWith(`/${listConfig.indexFile}`))
|
|
386
|
+
.map((f) => {
|
|
387
|
+
const rel = f.filename.slice(pagesDir.length + 1);
|
|
388
|
+
const slash = rel.indexOf("/");
|
|
389
|
+
return slash > 0
|
|
390
|
+
? { path: f.filename, collection: rel.slice(0, slash), file: rel.slice(slash + 1), status: f.status }
|
|
391
|
+
: { path: f.filename, status: f.status };
|
|
392
|
+
});
|
|
393
|
+
return { hasChanges: files.length > 0, changedFiles: files.length, files };
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Draft mode: move the draft branch's version of the chosen files onto
|
|
398
|
+
* the published branch with one git-data commit. Blobs already live in
|
|
399
|
+
* the repo (they were committed to the draft branch), so the tree
|
|
400
|
+
* references them by SHA — nothing is re-uploaded. A `removed` status
|
|
401
|
+
* becomes a tree entry with `sha: null`, which is how git-data deletes.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} [message]
|
|
404
|
+
* @param {{ entries?: {collection: string, file: string}[] }} [opts]
|
|
405
|
+
*/
|
|
406
|
+
async publish(message, { entries } = {}) {
|
|
407
|
+
if (!draftMode) {
|
|
408
|
+
// Writes are committed instantly; trigger is external (Netlify webhook on push)
|
|
409
|
+
return { ok: true, message: "All changes are already committed to GitHub" };
|
|
410
|
+
}
|
|
411
|
+
await ensureDraftBranch();
|
|
412
|
+
const pending = await this.pendingChanges();
|
|
413
|
+
if (!pending.hasChanges) return { ok: false, message: "No changes to publish" };
|
|
414
|
+
|
|
415
|
+
const scoped = Array.isArray(entries) && entries.length > 0;
|
|
416
|
+
let targets = pending.files;
|
|
417
|
+
if (scoped) {
|
|
418
|
+
const wanted = new Set(
|
|
419
|
+
entries.map((e) => contentPath(sanitize(e.collection), sanitize(e.file))),
|
|
420
|
+
);
|
|
421
|
+
targets = pending.files.filter((f) => wanted.has(f.path));
|
|
422
|
+
if (!targets.length) return { ok: false, message: "No changes to publish" };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const treeItems = await Promise.all(
|
|
426
|
+
targets.map(async (f) => {
|
|
427
|
+
if (f.status === "removed") return { path: f.path, mode: "100644", type: "blob", sha: null };
|
|
428
|
+
const blob = await apiGet(`/contents/${f.path}?ref=${draftBranch}`);
|
|
429
|
+
return { path: f.path, mode: "100644", type: "blob", sha: blob.sha };
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
// The ref update races with concurrent pushes to the published branch;
|
|
434
|
+
// retry once from a fresh base, as writeBatch does.
|
|
435
|
+
let lastErr;
|
|
436
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
437
|
+
try {
|
|
438
|
+
const refData = await apiGet(`/git/ref/heads/${branch}`);
|
|
439
|
+
const baseCommitSha = refData.object.sha;
|
|
440
|
+
const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
|
|
441
|
+
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
442
|
+
const msg = message || commitMsg(commitMessage) || `cms: publish ${timestamp}`;
|
|
443
|
+
const newTree = await apiPost(`/git/trees`, { base_tree: baseCommit.tree.sha, tree: treeItems });
|
|
444
|
+
const newCommit = await apiPost(`/git/commits`, {
|
|
445
|
+
message: msg,
|
|
446
|
+
tree: newTree.sha,
|
|
447
|
+
parents: [baseCommitSha],
|
|
448
|
+
});
|
|
449
|
+
await apiPatch(`/git/refs/heads/${branch}`, { sha: newCommit.sha });
|
|
450
|
+
const shortSha = newCommit.sha.slice(0, 7);
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
message: `Published ${targets.length} file(s) to ${branch} (${shortSha})`,
|
|
454
|
+
sha: newCommit.sha,
|
|
455
|
+
shortSha,
|
|
456
|
+
branch,
|
|
457
|
+
scope: scoped ? "entries" : "all",
|
|
458
|
+
};
|
|
459
|
+
} catch (err) {
|
|
460
|
+
lastErr = err;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
throw lastErr;
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async listHistory(collection, file) {
|
|
467
|
+
if (!safeFileName(file)) return null;
|
|
468
|
+
const path = contentPath(collection, sanitize(file));
|
|
469
|
+
const commits = await apiGet(`/commits?path=${encodeURIComponent(path)}&per_page=20&sha=${workBranch}`);
|
|
470
|
+
if (!Array.isArray(commits)) return [];
|
|
471
|
+
return commits.map((c) => ({
|
|
472
|
+
ts: c.sha,
|
|
473
|
+
label: c.commit.message,
|
|
474
|
+
date: c.commit.author?.date,
|
|
475
|
+
author: c.commit.author?.name || c.commit.author?.email || "",
|
|
476
|
+
size: null,
|
|
477
|
+
}));
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
async restoreHistory(collection, file, commitSha) {
|
|
481
|
+
if (!safeFileName(file)) return null;
|
|
482
|
+
const path = contentPath(collection, sanitize(file));
|
|
483
|
+
const fileData = await apiGet(`/contents/${path}?ref=${commitSha}`);
|
|
484
|
+
if (!fileData || Array.isArray(fileData)) return false;
|
|
485
|
+
const data = decodeContent(fileData);
|
|
486
|
+
await this.writePage(collection, file, data);
|
|
487
|
+
return true;
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
async writeBatch(items, message) {
|
|
491
|
+
await ensureDraftBranch();
|
|
492
|
+
if (!items.length) return { ok: true, commitCount: 0 };
|
|
493
|
+
|
|
494
|
+
// The ref update (step 7) races with concurrent pushes: if the branch
|
|
495
|
+
// moved after we read it, GitHub rejects the non-fast-forward PATCH.
|
|
496
|
+
// Retry the whole flow once from a fresh base before giving up.
|
|
497
|
+
let lastErr;
|
|
498
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
499
|
+
try {
|
|
500
|
+
return await this._writeBatchOnce(items, message);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
lastErr = err;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
throw lastErr;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async _writeBatchOnce(items, message) {
|
|
509
|
+
// 1. Get current ref → base commit SHA
|
|
510
|
+
const refData = await apiGet(`/git/ref/heads/${workBranch}`);
|
|
511
|
+
const baseCommitSha = refData.object.sha;
|
|
512
|
+
|
|
513
|
+
// 2. Get base commit → base tree SHA
|
|
514
|
+
const baseCommit = await apiGet(`/git/commits/${baseCommitSha}`);
|
|
515
|
+
const baseTreeSha = baseCommit.tree.sha;
|
|
516
|
+
|
|
517
|
+
// 3. Create one blob per content file
|
|
518
|
+
const treeItems = await Promise.all(
|
|
519
|
+
items.map(async ({ collection, file, data }) => {
|
|
520
|
+
const filePath = contentPath(sanitize(collection), sanitize(file));
|
|
521
|
+
const content = Buffer.from(JSON.stringify(data, null, 2), "utf-8").toString("base64");
|
|
522
|
+
const blob = await apiPost(`/git/blobs`, { content, encoding: "base64" });
|
|
523
|
+
return { path: filePath, mode: "100644", type: "blob", sha: blob.sha };
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// 4. Compute updated _index.json for each affected collection and add
|
|
528
|
+
// them to the same tree so the index stays in sync atomically.
|
|
529
|
+
const byCollection = {};
|
|
530
|
+
for (const item of items) {
|
|
531
|
+
const col = sanitize(item.collection ?? item.data?.collection ?? "");
|
|
532
|
+
if (!col) continue;
|
|
533
|
+
(byCollection[col] ??= []).push(item);
|
|
534
|
+
}
|
|
535
|
+
for (const [col, colItems] of Object.entries(byCollection)) {
|
|
536
|
+
const index = await readIndex(col);
|
|
537
|
+
for (const { file, data } of colItems) {
|
|
538
|
+
const entry = buildIndexEntry(col, file, data);
|
|
539
|
+
const pos = index.entries.findIndex((e) => e.file === file);
|
|
540
|
+
if (pos >= 0) index.entries[pos] = entry;
|
|
541
|
+
else index.entries.push(entry);
|
|
542
|
+
}
|
|
543
|
+
const idxContent = Buffer.from(JSON.stringify(index, null, 2), "utf-8").toString("base64");
|
|
544
|
+
const idxBlob = await apiPost(`/git/blobs`, { content: idxContent, encoding: "base64" });
|
|
545
|
+
treeItems.push({ path: indexFilePath(col), mode: "100644", type: "blob", sha: idxBlob.sha });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 5. Create tree
|
|
549
|
+
const newTree = await apiPost(`/git/trees`, { base_tree: baseTreeSha, tree: treeItems });
|
|
550
|
+
|
|
551
|
+
// 6. Create commit
|
|
552
|
+
const msg = message || commitMsg(commitMessage, "Batch content update");
|
|
553
|
+
const newCommit = await apiPost(`/git/commits`, {
|
|
554
|
+
message: msg,
|
|
555
|
+
tree: newTree.sha,
|
|
556
|
+
parents: [baseCommitSha],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// 7. Update ref
|
|
560
|
+
await apiPatch(`/git/refs/heads/${workBranch}`, { sha: newCommit.sha });
|
|
561
|
+
|
|
562
|
+
// 8. Invalidate shaCache for all written paths
|
|
563
|
+
for (const { collection, file } of items) {
|
|
564
|
+
shaCache.delete(contentPath(sanitize(collection), sanitize(file)));
|
|
565
|
+
}
|
|
566
|
+
for (const col of Object.keys(byCollection)) {
|
|
567
|
+
shaCache.delete(indexFilePath(col));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { ok: true, sha: newCommit.sha, commitCount: 1 };
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
async listScheduled() {
|
|
574
|
+
return listScheduledDue(this);
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|