forgepress 0.0.0 → 0.0.1

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.
@@ -0,0 +1,849 @@
1
+ import { isRecord, plain, same } from "./value.mjs";
2
+ import { assetUrl, base64ToText, createOutput, createPaths, isEntryFile, isEntryId, isMediaFile, prefixer, sortByCreation, textToBase64 } from "./output.mjs";
3
+ import "./validate.mjs";
4
+ import { parseEntry, parseSchema } from "./parse.mjs";
5
+ function mergeEntries(rows, overlay) {
6
+ if (!overlay) return [...rows];
7
+ const merged = new Map(rows.map((row) => [row.id, row]));
8
+ for (const [id, row] of Object.entries(overlay)) if (row === null) merged.delete(id);
9
+ else merged.set(id, row);
10
+ return [...merged.values()];
11
+ }
12
+ function stage(overlay, before, row) {
13
+ if (same(before, row)) delete overlay[row.id];
14
+ else overlay[row.id] = row;
15
+ }
16
+ function createContentChanges(base, ready, mutate) {
17
+ return {
18
+ schema: () => base.schema(),
19
+ list: async (collection) => sortByCreation(mergeEntries(await base.list(collection), (await ready()).entries[collection])),
20
+ entry: async (collection, id) => {
21
+ const staged = (await ready()).entries[collection]?.[id];
22
+ if (staged !== void 0) return staged ?? void 0;
23
+ return base.entry(collection, id);
24
+ },
25
+ writeEntry: async (collection, row) => {
26
+ const before = await base.entry(collection, row.id);
27
+ await mutate((changes) => {
28
+ stage(changes.entries[collection] ??= {}, before, plain(row));
29
+ });
30
+ },
31
+ removeEntry: async (collection, id) => {
32
+ const before = await base.entry(collection, id);
33
+ await mutate((changes) => {
34
+ const overlay = changes.entries[collection] ??= {};
35
+ if (before) overlay[id] = null;
36
+ else delete overlay[id];
37
+ });
38
+ }
39
+ };
40
+ }
41
+ function publishedUploads(changes) {
42
+ return Object.values(changes.publishedMedia ?? {}).filter(Boolean);
43
+ }
44
+ function createPreviews() {
45
+ const urls = /* @__PURE__ */ new Map();
46
+ function preview(upload) {
47
+ if (typeof URL.createObjectURL !== "function") return "";
48
+ let url = urls.get(upload.name);
49
+ if (!url) {
50
+ url = URL.createObjectURL(new Blob([upload.data], { type: upload.type }));
51
+ urls.set(upload.name, url);
52
+ }
53
+ return url;
54
+ }
55
+ function forget(name) {
56
+ const url = urls.get(name);
57
+ if (!url) return;
58
+ URL.revokeObjectURL(url);
59
+ urls.delete(name);
60
+ }
61
+ return {
62
+ forget,
63
+ asset: (upload, prefix) => {
64
+ const local = preview(upload);
65
+ return {
66
+ name: upload.name,
67
+ url: assetUrl(prefix, upload.name),
68
+ type: upload.type,
69
+ size: upload.size,
70
+ modifiedAt: upload.modifiedAt,
71
+ ...local ? { preview: local } : {}
72
+ };
73
+ },
74
+ clear: () => {
75
+ for (const name of [...urls.keys()]) forget(name);
76
+ }
77
+ };
78
+ }
79
+ const PROVIDERS = {
80
+ github: {
81
+ name: "GitHub",
82
+ root: "https://github.com",
83
+ tokens: "/settings/personal-access-tokens/new",
84
+ permissions: "read and write access to contents, and read access to actions and commit statuses",
85
+ commits: "/commit/"
86
+ },
87
+ gitlab: {
88
+ name: "GitLab",
89
+ root: "https://gitlab.com",
90
+ tokens: "/-/user_settings/personal_access_tokens",
91
+ permissions: "the api scope",
92
+ commits: "/-/commit/"
93
+ },
94
+ forgejo: {
95
+ name: "Forgejo",
96
+ root: "https://codeberg.org",
97
+ tokens: "/user/settings/applications",
98
+ permissions: "read access to your user, and read and write access to repositories",
99
+ commits: "/commit/"
100
+ }
101
+ };
102
+ function describe(config) {
103
+ const provider = PROVIDERS[config.type];
104
+ const root = (config.url ?? provider.root).replace(/\/+$/, "");
105
+ const shared = {
106
+ name: provider.name,
107
+ root,
108
+ tokens: `${root}${provider.tokens}`,
109
+ permissions: provider.permissions
110
+ };
111
+ if (config.type === "github") return {
112
+ ...shared,
113
+ api: root === PROVIDERS.github.root ? "https://api.github.com" : `${root}/api/v3`,
114
+ scopes: config.scopes ?? []
115
+ };
116
+ if (config.type === "gitlab") return {
117
+ ...shared,
118
+ api: `${root}/api/v4`,
119
+ scopes: config.scopes ?? ["api"],
120
+ oauth: {
121
+ authorize: `${root}/oauth/authorize`,
122
+ token: `${root}/oauth/token`
123
+ }
124
+ };
125
+ return {
126
+ ...shared,
127
+ api: `${root}/api/v1`,
128
+ scopes: config.scopes ?? ["read:user", "write:repository"],
129
+ oauth: {
130
+ authorize: `${root}/login/oauth/authorize`,
131
+ token: `${root}/login/oauth/access_token`
132
+ }
133
+ };
134
+ }
135
+ function publishable(files) {
136
+ if (files.length === 0) throw new Error("[forgepress] there is nothing to publish");
137
+ return files;
138
+ }
139
+ function createRepositoryApi(config, base, token, headers = {}) {
140
+ const { name } = describe(config);
141
+ let known = config.repository.branch;
142
+ async function send(path, init = {}) {
143
+ return fetch(path.startsWith("http") ? path : `${base}${path}`, {
144
+ ...init,
145
+ headers: {
146
+ ...headers,
147
+ authorization: `Bearer ${await token()}`,
148
+ ...init.body === void 0 ? {} : { "content-type": "application/json" },
149
+ ...init.headers
150
+ }
151
+ });
152
+ }
153
+ async function check(response) {
154
+ if (!response.ok) throw new Error(`[forgepress] ${name} ${response.status}: ${(await response.text()).slice(0, 300)}`);
155
+ return response;
156
+ }
157
+ async function call(path, init) {
158
+ const response = await check(await send(path, init));
159
+ return response.status === 204 ? void 0 : await response.json();
160
+ }
161
+ function post(path, body) {
162
+ return call(path, {
163
+ method: "POST",
164
+ body: JSON.stringify(body)
165
+ });
166
+ }
167
+ async function details() {
168
+ const found = await call("");
169
+ known ??= found.default_branch;
170
+ return found;
171
+ }
172
+ async function branch() {
173
+ return known ?? (await details()).default_branch;
174
+ }
175
+ return {
176
+ send,
177
+ check,
178
+ call,
179
+ post,
180
+ details,
181
+ branch
182
+ };
183
+ }
184
+ async function findTree(commit, directory, list) {
185
+ let tree = commit;
186
+ for (const segment of directory.split("/")) {
187
+ const found = (await list(tree)).find((item) => item.path === segment && item.type === "tree");
188
+ if (!found) return void 0;
189
+ tree = found.sha;
190
+ }
191
+ return tree;
192
+ }
193
+ const PAGE_SIZE$1 = 1e3;
194
+ const STATUS_LIMIT = 50;
195
+ const SCHEDULED = /\(schedule\)$/;
196
+ function statusState$1(status) {
197
+ if (status === "success" || status === "pending") return status;
198
+ return status === "failure" || status === "error" ? "failure" : "skipped";
199
+ }
200
+ function encodePath(path) {
201
+ return path.split("/").map(encodeURIComponent).join("/");
202
+ }
203
+ function createForgejoForge(config, token) {
204
+ const { api, root } = describe(config);
205
+ const { owner, name } = config.repository;
206
+ const repo = createRepositoryApi(config, `${api}/repos/${owner}/${name}`, token, { accept: "application/json" });
207
+ async function entries(tree, recursive) {
208
+ const items = [];
209
+ for (let page = 1;; page += 1) {
210
+ const listed = await repo.call(`/git/trees/${tree}?recursive=${recursive}&per_page=${PAGE_SIZE$1}&page=${page}`);
211
+ items.push(...listed.tree);
212
+ if (listed.tree.length === 0 || items.length >= listed.total_count) return items;
213
+ }
214
+ }
215
+ async function sha(path, ref) {
216
+ const response = await repo.send(`/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
217
+ if (response.status === 404) return void 0;
218
+ return (await (await repo.check(response)).json()).sha;
219
+ }
220
+ return {
221
+ async access() {
222
+ const [user, repository] = await Promise.all([repo.call(`${api}/user`), repo.details()]);
223
+ return {
224
+ identity: {
225
+ login: user.login,
226
+ ...user.full_name ? { name: user.full_name } : {},
227
+ ...user.avatar_url ? { avatar: user.avatar_url } : {}
228
+ },
229
+ writable: repository.permissions?.push === true,
230
+ branch: await repo.branch()
231
+ };
232
+ },
233
+ async head() {
234
+ return (await repo.call(`/branches/${encodeURIComponent(await repo.branch())}`)).commit.id;
235
+ },
236
+ async files(commit, directory) {
237
+ const tree = await findTree(commit, directory, (found) => entries(found, false));
238
+ if (!tree) return [];
239
+ return (await entries(tree, true)).filter((item) => item.type === "blob").map((item) => ({
240
+ path: `${directory}/${item.path}`,
241
+ sha: item.sha
242
+ }));
243
+ },
244
+ async read(blob) {
245
+ return base64ToText((await repo.call(`/git/blobs/${blob}`)).content);
246
+ },
247
+ async commit(files, message, parent) {
248
+ const operations = publishable((await Promise.all(files.map(async (file) => {
249
+ const found = await sha(file.path, parent);
250
+ if ("removed" in file) return found ? {
251
+ operation: "delete",
252
+ path: file.path,
253
+ sha: found
254
+ } : void 0;
255
+ const content = file.encoding === "base64" ? file.data : textToBase64(file.data);
256
+ return found ? {
257
+ operation: "update",
258
+ path: file.path,
259
+ content,
260
+ sha: found
261
+ } : {
262
+ operation: "create",
263
+ path: file.path,
264
+ content
265
+ };
266
+ }))).filter((operation) => operation !== void 0));
267
+ return (await repo.post("/contents", {
268
+ branch: await repo.branch(),
269
+ message,
270
+ files: operations
271
+ })).commit.sha;
272
+ },
273
+ async checks(commit) {
274
+ return ((await repo.call(`/commits/${commit}/status?limit=${STATUS_LIMIT}`)).statuses ?? []).filter((status) => !SCHEDULED.test(status.context)).map((status) => ({
275
+ name: status.context,
276
+ state: statusState$1(status.status),
277
+ ...status.target_url ? { url: new URL(status.target_url, `${root}/`).href } : {}
278
+ }));
279
+ },
280
+ async contains(commit, ancestor) {
281
+ return commit === ancestor || (await repo.call(`/compare/${commit}...${ancestor}`)).total_commits === 0;
282
+ }
283
+ };
284
+ }
285
+ const BUILD_EVENTS = /* @__PURE__ */ new Set(["push", "workflow_run"]);
286
+ const FAILED = /* @__PURE__ */ new Set([
287
+ "failure",
288
+ "timed_out",
289
+ "startup_failure"
290
+ ]);
291
+ function runState(status, conclusion) {
292
+ if (status !== "completed" || conclusion === "action_required") return "pending";
293
+ if (conclusion === "success") return "success";
294
+ return conclusion !== null && FAILED.has(conclusion) ? "failure" : "skipped";
295
+ }
296
+ function statusState(state) {
297
+ return state === "success" || state === "pending" ? state : "failure";
298
+ }
299
+ function linked(url) {
300
+ return url ? { url } : {};
301
+ }
302
+ function createGitHubForge(config, token) {
303
+ const { api } = describe(config);
304
+ const { owner, name } = config.repository;
305
+ const repo = createRepositoryApi(config, `${api}/repos/${owner}/${name}`, token, {
306
+ "accept": "application/vnd.github+json",
307
+ "x-github-api-version": "2022-11-28"
308
+ });
309
+ let apps = true;
310
+ async function readable(path) {
311
+ const response = await repo.send(path);
312
+ if (response.status === 403 || response.status === 404) return void 0;
313
+ return await (await repo.check(response)).json();
314
+ }
315
+ async function runs(commit) {
316
+ return (await readable(`/actions/runs?head_sha=${commit}&per_page=100`))?.workflow_runs.filter((run) => BUILD_EVENTS.has(run.event) || run.path.startsWith("dynamic/pages/")).map((run) => ({
317
+ name: run.name ?? run.path,
318
+ state: runState(run.status, run.conclusion),
319
+ url: run.html_url
320
+ }));
321
+ }
322
+ async function statuses(commit) {
323
+ return (await readable(`/commits/${commit}/status?per_page=100`))?.statuses.map((status) => ({
324
+ name: status.context,
325
+ state: statusState(status.state),
326
+ ...linked(status.target_url)
327
+ }));
328
+ }
329
+ async function others(commit) {
330
+ const found = apps ? await readable(`/commits/${commit}/check-runs?per_page=100`) : void 0;
331
+ if (!found) {
332
+ apps = false;
333
+ return [];
334
+ }
335
+ return found.check_runs.filter((run) => run.app?.slug !== "github-actions").map((run) => ({
336
+ name: run.name,
337
+ state: runState(run.status, run.conclusion),
338
+ ...linked(run.html_url ?? run.details_url)
339
+ }));
340
+ }
341
+ return {
342
+ async access() {
343
+ const [user, repository] = await Promise.all([repo.call(`${api}/user`), repo.details()]);
344
+ return {
345
+ identity: {
346
+ login: user.login,
347
+ ...user.name ? { name: user.name } : {},
348
+ ...user.avatar_url ? { avatar: user.avatar_url } : {}
349
+ },
350
+ writable: repository.permissions?.push === true,
351
+ branch: await repo.branch()
352
+ };
353
+ },
354
+ async head() {
355
+ return (await repo.call(`/git/ref/heads/${encodeURIComponent(await repo.branch())}`)).object.sha;
356
+ },
357
+ async files(commit, directory) {
358
+ const sha = await findTree(commit, directory, async (tree) => (await repo.call(`/git/trees/${tree}`)).tree);
359
+ if (!sha) return [];
360
+ const tree = await repo.call(`/git/trees/${sha}?recursive=1`);
361
+ if (tree.truncated) throw new Error(`[forgepress] GitHub lists too many files in ${directory} to read them in one request`);
362
+ return tree.tree.filter((item) => item.type === "blob").map((item) => ({
363
+ path: `${directory}/${item.path}`,
364
+ sha: item.sha
365
+ }));
366
+ },
367
+ async read(sha) {
368
+ return (await repo.check(await repo.send(`/git/blobs/${sha}`, { headers: { accept: "application/vnd.github.raw+json" } }))).text();
369
+ },
370
+ async commit(files, message, parent) {
371
+ const pending = publishable(files);
372
+ const current = await repo.call(`/git/commits/${parent}`);
373
+ const tree = await Promise.all(pending.map(async (file) => {
374
+ if ("removed" in file) return {
375
+ path: file.path,
376
+ mode: "100644",
377
+ type: "blob",
378
+ sha: null
379
+ };
380
+ const blob = await repo.post("/git/blobs", {
381
+ content: file.data,
382
+ encoding: file.encoding
383
+ });
384
+ return {
385
+ path: file.path,
386
+ mode: "100644",
387
+ type: "blob",
388
+ sha: blob.sha
389
+ };
390
+ }));
391
+ const next = await repo.post("/git/trees", {
392
+ base_tree: current.tree.sha,
393
+ tree
394
+ });
395
+ const created = await repo.post("/git/commits", {
396
+ message,
397
+ tree: next.sha,
398
+ parents: [parent]
399
+ });
400
+ await repo.call(`/git/refs/heads/${encodeURIComponent(await repo.branch())}`, {
401
+ method: "PATCH",
402
+ body: JSON.stringify({
403
+ sha: created.sha,
404
+ force: false
405
+ })
406
+ });
407
+ return created.sha;
408
+ },
409
+ async checks(commit) {
410
+ const [built, reported, rest] = await Promise.all([
411
+ runs(commit),
412
+ statuses(commit),
413
+ others(commit)
414
+ ]);
415
+ if (!built && !reported) throw new Error("[forgepress] the GitHub token can't read the builds of this repository; give it read access to actions and commit statuses");
416
+ return [
417
+ ...built ?? [],
418
+ ...reported ?? [],
419
+ ...rest
420
+ ];
421
+ },
422
+ async contains(commit, ancestor) {
423
+ return commit === ancestor || (await repo.call(`/compare/${commit}...${ancestor}?per_page=1`)).ahead_by === 0;
424
+ }
425
+ };
426
+ }
427
+ const DEVELOPER = 30;
428
+ const PAGE_SIZE = 100;
429
+ const RUNNING = /* @__PURE__ */ new Set([
430
+ "created",
431
+ "waiting_for_resource",
432
+ "preparing",
433
+ "pending",
434
+ "running",
435
+ "scheduled",
436
+ "waiting_for_callback"
437
+ ]);
438
+ function writable(project) {
439
+ return [project.permissions?.project_access?.access_level, project.permissions?.group_access?.access_level].some((level) => typeof level === "number" && level >= DEVELOPER);
440
+ }
441
+ function pipelineState(status) {
442
+ if (status === "success") return "success";
443
+ if (status === "failed") return "failure";
444
+ return RUNNING.has(status) ? "pending" : "skipped";
445
+ }
446
+ function nextPage(link) {
447
+ return link?.split(",").map((part) => /<([^>]+)>;\s*rel="next"/.exec(part)?.[1]).find((url) => url !== void 0);
448
+ }
449
+ function createGitLabForge(config, token) {
450
+ const { api } = describe(config);
451
+ const repo = createRepositoryApi(config, `${api}/projects/${encodeURIComponent(`${config.repository.owner}/${config.repository.name}`)}`, token);
452
+ async function existing(path, ref) {
453
+ const response = await repo.send(`/repository/files/${encodeURIComponent(path)}?ref=${encodeURIComponent(ref)}`);
454
+ if (response.status === 404) return void 0;
455
+ return await (await repo.check(response)).json();
456
+ }
457
+ return {
458
+ async access() {
459
+ const [user, project] = await Promise.all([repo.call(`${api}/user`), repo.details()]);
460
+ return {
461
+ identity: {
462
+ login: user.username,
463
+ ...user.name ? { name: user.name } : {},
464
+ ...user.avatar_url ? { avatar: user.avatar_url } : {}
465
+ },
466
+ writable: writable(project),
467
+ branch: await repo.branch()
468
+ };
469
+ },
470
+ async head() {
471
+ return (await repo.call(`/repository/branches/${encodeURIComponent(await repo.branch())}`)).commit.id;
472
+ },
473
+ async files(commit, directory) {
474
+ const files = [];
475
+ let page = `/repository/tree?path=${encodeURIComponent(directory)}&ref=${encodeURIComponent(commit)}&recursive=true&per_page=${PAGE_SIZE}&pagination=keyset`;
476
+ while (page) {
477
+ const response = await repo.send(page);
478
+ if (response.status === 404) return [];
479
+ const items = await (await repo.check(response)).json();
480
+ files.push(...items.filter((item) => item.type === "blob").map((item) => ({
481
+ path: item.path,
482
+ sha: item.id
483
+ })));
484
+ page = nextPage(response.headers.get("link"));
485
+ }
486
+ return files;
487
+ },
488
+ async read(sha) {
489
+ return (await repo.check(await repo.send(`/repository/blobs/${sha}/raw`))).text();
490
+ },
491
+ async commit(files, message, parent) {
492
+ const actions = publishable((await Promise.all(files.map(async (file) => {
493
+ const found = await existing(file.path, parent);
494
+ if ("removed" in file) return found ? {
495
+ action: "delete",
496
+ file_path: file.path,
497
+ last_commit_id: found.last_commit_id
498
+ } : void 0;
499
+ return found ? {
500
+ action: "update",
501
+ file_path: file.path,
502
+ content: file.data,
503
+ encoding: file.encoding === "base64" ? "base64" : "text",
504
+ last_commit_id: found.last_commit_id
505
+ } : {
506
+ action: "create",
507
+ file_path: file.path,
508
+ content: file.data,
509
+ encoding: file.encoding === "base64" ? "base64" : "text"
510
+ };
511
+ }))).filter((action) => action !== void 0));
512
+ return (await repo.post("/repository/commits", {
513
+ branch: await repo.branch(),
514
+ commit_message: message,
515
+ actions
516
+ })).id;
517
+ },
518
+ async checks(commit) {
519
+ return (await repo.call(`/pipelines?sha=${commit}&per_page=${PAGE_SIZE}`)).filter((pipeline) => pipeline.source !== "schedule").map((pipeline) => ({
520
+ name: pipeline.name || `Pipeline #${pipeline.iid ?? pipeline.id}`,
521
+ state: pipelineState(pipeline.status),
522
+ url: pipeline.web_url
523
+ }));
524
+ },
525
+ async contains(commit, ancestor) {
526
+ const refs = new URLSearchParams([["refs[]", commit], ["refs[]", ancestor]]);
527
+ return commit === ancestor || (await repo.call(`/repository/merge_base?${refs.toString()}`)).id === ancestor;
528
+ }
529
+ };
530
+ }
531
+ function createForge(config, token) {
532
+ if (config.type === "gitlab") return createGitLabForge(config, token);
533
+ if (config.type === "forgejo") return createForgejoForge(config, token);
534
+ return createGitHubForge(config, token);
535
+ }
536
+ const SKEW = 3e4;
537
+ function toTokens(payload, now = Date.now()) {
538
+ const access = payload.access_token;
539
+ if (typeof access !== "string" || !access) throw new Error("[forgepress] the forge returned no access token");
540
+ const refresh = payload.refresh_token;
541
+ const expires = payload.expires_in;
542
+ return {
543
+ access,
544
+ ...typeof refresh === "string" && refresh ? { refresh } : {},
545
+ ...typeof expires === "number" ? { expires: now + expires * 1e3 } : {}
546
+ };
547
+ }
548
+ function expired(tokens, now = Date.now()) {
549
+ return tokens.expires !== void 0 && tokens.expires - SKEW <= now;
550
+ }
551
+ async function form(endpoints, body) {
552
+ const response = await fetch(endpoints.token, {
553
+ method: "POST",
554
+ headers: {
555
+ "accept": "application/json",
556
+ "content-type": "application/x-www-form-urlencoded"
557
+ },
558
+ body: new URLSearchParams(body).toString()
559
+ });
560
+ if (!response.ok) throw new Error(`[forgepress] the forge rejected the token request (${response.status}): ${(await response.text()).slice(0, 200)}`);
561
+ return toTokens(await response.json());
562
+ }
563
+ function renew(endpoints, clientId, refresh) {
564
+ return form(endpoints, {
565
+ client_id: clientId,
566
+ grant_type: "refresh_token",
567
+ refresh_token: refresh
568
+ });
569
+ }
570
+ function storedTokens(value) {
571
+ if (typeof value === "string") return value ? { access: value } : void 0;
572
+ return value;
573
+ }
574
+ async function refreshed(tokens, config) {
575
+ const oauth = describe(config).oauth;
576
+ if (!expired(tokens) || !tokens.refresh || !oauth || !config.clientId) return tokens;
577
+ return renew(oauth, config.clientId, tokens.refresh);
578
+ }
579
+ const CONCURRENCY = 8;
580
+ function limit(size) {
581
+ const waiting = [];
582
+ let active = 0;
583
+ return async (task) => {
584
+ if (active < size) active += 1;
585
+ else await new Promise((resolve) => waiting.push(resolve));
586
+ try {
587
+ return await task();
588
+ } finally {
589
+ const next = waiting.shift();
590
+ if (next) next();
591
+ else active -= 1;
592
+ }
593
+ };
594
+ }
595
+ function createForgeSource(forge, paths, base, cache, mediaDir) {
596
+ const at = prefixer(base);
597
+ const queue = limit(CONCURRENCY);
598
+ const texts = /* @__PURE__ */ new Map();
599
+ let snapshot;
600
+ let pinned;
601
+ function client() {
602
+ const current = forge();
603
+ if (!current) throw new Error("[forgepress] sign in to read the content from the repository");
604
+ return current;
605
+ }
606
+ async function restore(hashes) {
607
+ const cached = await cache?.keep(hashes).catch(() => void 0);
608
+ for (const [sha, content] of cached ?? []) if (!texts.has(sha)) texts.set(sha, Promise.resolve(content));
609
+ }
610
+ async function listing(commit, directory) {
611
+ const stored = await cache?.readListing(commit, directory).catch(() => void 0);
612
+ if (stored) return stored;
613
+ const listed = new Map((await client().files(commit, directory)).map((file) => [file.path, file.sha]));
614
+ if (forge()) cache?.writeListing(commit, directory, listed).catch(() => void 0);
615
+ return listed;
616
+ }
617
+ async function load() {
618
+ const commit = pinned ?? await client().head();
619
+ const listed = await listing(commit, at(paths.dir));
620
+ await restore(new Set(listed.values()));
621
+ return {
622
+ commit,
623
+ files: listed
624
+ };
625
+ }
626
+ function current() {
627
+ if (!snapshot) {
628
+ const loading = load();
629
+ snapshot = loading;
630
+ loading.catch(() => {
631
+ if (snapshot === loading) snapshot = void 0;
632
+ });
633
+ }
634
+ return snapshot;
635
+ }
636
+ function text(sha) {
637
+ let pending = texts.get(sha);
638
+ if (!pending) {
639
+ pending = queue(() => client().read(sha));
640
+ texts.set(sha, pending);
641
+ pending.then((content) => forge() && cache?.writeFile(sha, content), () => texts.delete(sha)).catch(() => void 0);
642
+ }
643
+ return pending;
644
+ }
645
+ async function files() {
646
+ return (await current()).files;
647
+ }
648
+ async function hash(path) {
649
+ return (await files()).get(path);
650
+ }
651
+ async function media() {
652
+ if (mediaDir === void 0) return [];
653
+ const found = await current();
654
+ const directory = `${at(mediaDir)}/`;
655
+ if (!found.media) {
656
+ const loading = listing(found.commit, at(mediaDir)).then((listed) => [...listed.keys()].map((path) => path.slice(directory.length)).filter(isMediaFile));
657
+ found.media = loading;
658
+ loading.catch(() => {
659
+ if (found.media === loading) delete found.media;
660
+ });
661
+ }
662
+ return found.media;
663
+ }
664
+ async function list(collection) {
665
+ const directory = `${at(paths.collection(collection))}/`;
666
+ const rows = [...await files()].filter(([path]) => path.startsWith(directory) && isEntryFile(path.slice(directory.length))).map(async ([path, sha]) => parseEntry(await text(sha), path));
667
+ return sortByCreation(await Promise.all(rows));
668
+ }
669
+ return {
670
+ schema: async () => {
671
+ const path = at(paths.schema);
672
+ const sha = await hash(path);
673
+ if (sha === void 0) throw new Error(`[forgepress] ${path} does not exist in the repository`);
674
+ return parseSchema(await text(sha), path);
675
+ },
676
+ list,
677
+ entry: async (collection, id) => {
678
+ const path = at(paths.entry(collection, id));
679
+ const sha = isEntryId(id) ? await hash(path) : void 0;
680
+ return sha === void 0 ? void 0 : parseEntry(await text(sha), path);
681
+ },
682
+ hashes: { entry: async (collection, id) => isEntryId(id) ? hash(at(paths.entry(collection, id))) : void 0 },
683
+ media,
684
+ read: text,
685
+ reset: (commit) => {
686
+ pinned = commit;
687
+ snapshot = void 0;
688
+ }
689
+ };
690
+ }
691
+ const DATABASE = "forgepress";
692
+ const STORE = "changes";
693
+ const CACHE = "forgepress-cache";
694
+ const FILES = "files";
695
+ const LISTINGS = "listings";
696
+ const STORES = {
697
+ [DATABASE]: [STORE],
698
+ [CACHE]: [FILES, LISTINGS]
699
+ };
700
+ function settle(request) {
701
+ return new Promise((resolve, reject) => {
702
+ request.onsuccess = () => resolve(request.result);
703
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("[forgepress] IndexedDB request failed"));
704
+ });
705
+ }
706
+ function open(name) {
707
+ return new Promise((resolve, reject) => {
708
+ const opening = indexedDB.open(name, 1);
709
+ opening.onupgradeneeded = () => {
710
+ for (const store of STORES[name] ?? []) if (!opening.result.objectStoreNames.contains(store)) opening.result.createObjectStore(store);
711
+ };
712
+ opening.onsuccess = () => resolve(opening.result);
713
+ opening.onerror = () => reject(opening.error ?? /* @__PURE__ */ new Error("[forgepress] IndexedDB is unavailable"));
714
+ opening.onblocked = () => reject(/* @__PURE__ */ new Error("[forgepress] IndexedDB is blocked by another open tab"));
715
+ });
716
+ }
717
+ const databases = /* @__PURE__ */ new Map();
718
+ async function transact(name, store, mode, run) {
719
+ let database = databases.get(name);
720
+ if (!database) {
721
+ database = open(name);
722
+ databases.set(name, database);
723
+ }
724
+ return await run((await database).transaction(store, mode).objectStore(store));
725
+ }
726
+ function createIdbStore(key) {
727
+ return {
728
+ read: () => transact(DATABASE, STORE, "readonly", (objects) => settle(objects.get(key))),
729
+ write: async (value) => {
730
+ await transact(DATABASE, STORE, "readwrite", (objects) => settle(objects.put(value, key)));
731
+ },
732
+ clear: async () => {
733
+ await transact(DATABASE, STORE, "readwrite", (objects) => settle(objects.delete(key)));
734
+ }
735
+ };
736
+ }
737
+ function createIdbCache() {
738
+ return {
739
+ readListing: async (commit, directory) => {
740
+ const stored = await transact(CACHE, LISTINGS, "readonly", (objects) => settle(objects.get(directory)));
741
+ return stored?.commit === commit ? stored.files : void 0;
742
+ },
743
+ writeListing: async (commit, directory, files) => {
744
+ await transact(CACHE, LISTINGS, "readwrite", (objects) => settle(objects.put({
745
+ commit,
746
+ files
747
+ }, directory)));
748
+ },
749
+ keep: (hashes) => transact(CACHE, FILES, "readwrite", (objects) => new Promise((resolve, reject) => {
750
+ const keys = objects.getAllKeys();
751
+ const values = objects.getAll();
752
+ keys.onerror = () => reject(keys.error ?? /* @__PURE__ */ new Error("[forgepress] IndexedDB request failed"));
753
+ values.onerror = () => reject(values.error ?? /* @__PURE__ */ new Error("[forgepress] IndexedDB request failed"));
754
+ values.onsuccess = () => {
755
+ const kept = /* @__PURE__ */ new Map();
756
+ keys.result.forEach((key, index) => {
757
+ if (typeof key === "string" && hashes.has(key)) kept.set(key, values.result[index]);
758
+ else objects.delete(key);
759
+ });
760
+ resolve(kept);
761
+ };
762
+ })),
763
+ writeFile: async (hash, text) => {
764
+ await transact(CACHE, FILES, "readwrite", (objects) => settle(objects.put(text, hash)));
765
+ },
766
+ clear: async () => {
767
+ await Promise.all([FILES, LISTINGS].map((store) => transact(CACHE, store, "readwrite", (objects) => settle(objects.clear()))));
768
+ }
769
+ };
770
+ }
771
+ function createMemoryStore() {
772
+ let value;
773
+ return {
774
+ read: async () => value,
775
+ write: async (next) => {
776
+ value = next;
777
+ },
778
+ clear: async () => {
779
+ value = void 0;
780
+ }
781
+ };
782
+ }
783
+ const TOKEN_KEY = "token";
784
+ const CHANGES_KEY = "changes";
785
+ function persist(key) {
786
+ return typeof indexedDB === "undefined" ? createMemoryStore() : createIdbStore(key);
787
+ }
788
+ function repositoryCache() {
789
+ return typeof indexedDB === "undefined" ? void 0 : createIdbCache();
790
+ }
791
+ function swap(value, urls) {
792
+ if (typeof value === "string") return [...urls].reduce((text, [url, local]) => text.split(url).join(local), value);
793
+ if (Array.isArray(value)) return value.map((item) => swap(item, urls));
794
+ if (isRecord(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, swap(item, urls)]));
795
+ return value;
796
+ }
797
+ function uploads(changes) {
798
+ return [...Object.values(changes.uploads), ...publishedUploads(changes)];
799
+ }
800
+ function createPreviewReader(settings) {
801
+ const tokens = persist(TOKEN_KEY);
802
+ const pending = persist(CHANGES_KEY);
803
+ const previews = createPreviews();
804
+ const shown = /* @__PURE__ */ new Set();
805
+ let token;
806
+ async function access() {
807
+ const saved = storedTokens(await tokens.read());
808
+ if (!saved) throw new Error("[forgepress] sign in to the editor to preview unpublished content");
809
+ const current = await refreshed(saved, settings.provider);
810
+ if (current !== saved) await tokens.write(current);
811
+ return current.access;
812
+ }
813
+ const forge = createForge(settings.provider, () => {
814
+ token ??= access();
815
+ return token;
816
+ });
817
+ const source = createForgeSource(() => forge, createPaths(settings.contentPath), settings.provider.base, repositoryCache());
818
+ function local(changes) {
819
+ const assets = uploads(changes).map((upload) => previews.asset(upload, settings.mediaUrl));
820
+ const names = new Set(assets.map((asset) => asset.name));
821
+ for (const name of shown) if (!names.has(name)) previews.forget(name);
822
+ shown.clear();
823
+ for (const name of names) shown.add(name);
824
+ return new Map(assets.flatMap((asset) => asset.preview ? [[asset.url, asset.preview]] : []));
825
+ }
826
+ return { build: async () => {
827
+ token = void 0;
828
+ source.reset();
829
+ const changes = {
830
+ entries: {},
831
+ uploads: {},
832
+ removed: [],
833
+ ...await pending.read()
834
+ };
835
+ const content = createContentChanges(source, async () => changes, async () => {
836
+ throw new Error("[forgepress] the preview only reads content");
837
+ });
838
+ const schema = await content.schema();
839
+ const urls = local(changes);
840
+ const listed = await Promise.all(Object.keys(schema.collections).map(async (collection) => [collection, await content.list(collection)]));
841
+ const entries = Object.fromEntries(listed.map(([collection, rows]) => [collection, Object.fromEntries(rows.map((row) => [row.id, urls.size > 0 ? swap(row, urls) : row]))]));
842
+ const files = await createOutput(schema, entries, {
843
+ commit: null,
844
+ unpublished: true
845
+ });
846
+ return new Map(files.map((file) => [file.path, file.text]));
847
+ } };
848
+ }
849
+ export { createPreviewReader };