nebula-cms 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/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +178 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Storage adapter interface and shared types.
|
|
3
|
+
* Defines the contract that FSA and GitHub storage backends implement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// A single file's name and content as returned by listFiles.
|
|
7
|
+
export type FileEntry = {
|
|
8
|
+
filename: string;
|
|
9
|
+
content: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// A file write target with collection path, filename, and content.
|
|
13
|
+
export type FileWrite = {
|
|
14
|
+
collection: string;
|
|
15
|
+
filename: string;
|
|
16
|
+
content: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Contract for storage backend adapters. Both FSA and GitHub adapters implement this.
|
|
20
|
+
export interface StorageAdapter {
|
|
21
|
+
/**
|
|
22
|
+
* Lists files in a collection matching the given extensions, returning their names and content.
|
|
23
|
+
* @param {string} collection - The collection name
|
|
24
|
+
* @param {string[]} extensions - File extensions to include (e.g. ['.md', '.mdx', '.yaml'])
|
|
25
|
+
* @return {Promise<FileEntry[]>} Array of filename + content pairs
|
|
26
|
+
*/
|
|
27
|
+
listFiles(collection: string, extensions: string[]): Promise<FileEntry[]>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deletes a single file from the collection. Used during file type conversion to remove the old file after the new one is written.
|
|
31
|
+
* @param {string} collection - The collection name
|
|
32
|
+
* @param {string} filename - The filename to delete
|
|
33
|
+
* @return {Promise<void>}
|
|
34
|
+
*/
|
|
35
|
+
deleteFile(collection: string, filename: string): Promise<void>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reads a single file's content.
|
|
39
|
+
* @param {string} collection - The collection name
|
|
40
|
+
* @param {string} filename - The filename within the collection
|
|
41
|
+
* @return {Promise<string>} The file content as a string
|
|
42
|
+
*/
|
|
43
|
+
readFile(collection: string, filename: string): Promise<string>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Writes content to a single file, creating it if it doesn't exist.
|
|
47
|
+
* @param {string} collection - The collection name
|
|
48
|
+
* @param {string} filename - The filename within the collection
|
|
49
|
+
* @param {string} content - The content to write
|
|
50
|
+
* @return {Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
writeFile(
|
|
53
|
+
collection: string,
|
|
54
|
+
filename: string,
|
|
55
|
+
content: string,
|
|
56
|
+
): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Writes multiple files atomically (single commit for GitHub, sequential for FSA).
|
|
60
|
+
* @param {FileWrite[]} files - Array of files to write
|
|
61
|
+
* @return {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
writeFiles(files: FileWrite[]): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
//////////////////////////////
|
|
68
|
+
// Message types for SharedWorker communication
|
|
69
|
+
//////////////////////////////
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
// Union of all request messages that can be sent to the storage SharedWorker.
|
|
73
|
+
export type StorageRequest =
|
|
74
|
+
| {
|
|
75
|
+
type: 'init';
|
|
76
|
+
backend:
|
|
77
|
+
| { type: 'fsa'; handle: FileSystemDirectoryHandle }
|
|
78
|
+
| { type: 'github'; token: string; repo: string };
|
|
79
|
+
}
|
|
80
|
+
| { type: 'listFiles'; collection: string; extensions: string[] }
|
|
81
|
+
| { type: 'readFile'; collection: string; filename: string }
|
|
82
|
+
| { type: 'writeFile'; collection: string; filename: string; content: string }
|
|
83
|
+
| { type: 'writeFiles'; files: FileWrite[] }
|
|
84
|
+
| { type: 'deleteFile'; collection: string; filename: string }
|
|
85
|
+
| { type: 'teardown' };
|
|
86
|
+
|
|
87
|
+
// Union of all response messages from the storage SharedWorker.
|
|
88
|
+
export type StorageResponse =
|
|
89
|
+
| { type: 'init'; ok: true }
|
|
90
|
+
| { type: 'init'; ok: false; error: string }
|
|
91
|
+
| { type: 'listFiles'; ok: true; files: FileEntry[] }
|
|
92
|
+
| { type: 'listFiles'; ok: false; error: string }
|
|
93
|
+
| { type: 'readFile'; ok: true; content: string }
|
|
94
|
+
| { type: 'readFile'; ok: false; error: string }
|
|
95
|
+
| { type: 'writeFile'; ok: true }
|
|
96
|
+
| { type: 'writeFile'; ok: false; error: string }
|
|
97
|
+
| { type: 'writeFiles'; ok: true }
|
|
98
|
+
| { type: 'writeFiles'; ok: false; error: string }
|
|
99
|
+
| { type: 'deleteFile'; ok: true }
|
|
100
|
+
| { type: 'deleteFile'; ok: false; error: string }
|
|
101
|
+
| { type: 'teardown'; ok: true }
|
|
102
|
+
| { type: 'port-connected' };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Typed client for communicating with the storage SharedWorker over a MessagePort.
|
|
3
|
+
* Wraps postMessage/onmessage into async request/response calls.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
StorageRequest,
|
|
8
|
+
StorageResponse,
|
|
9
|
+
FileEntry,
|
|
10
|
+
FileWrite,
|
|
11
|
+
} from './adapter';
|
|
12
|
+
|
|
13
|
+
export class StorageClient {
|
|
14
|
+
private port: MessagePort;
|
|
15
|
+
private pending = new Map<
|
|
16
|
+
string,
|
|
17
|
+
{ resolve: (v: any) => void; reject: (e: Error) => void }
|
|
18
|
+
>();
|
|
19
|
+
private idCounter = 0;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a client wrapping the given port.
|
|
23
|
+
* @param {MessagePort} port - The port connected to the storage SharedWorker
|
|
24
|
+
*/
|
|
25
|
+
constructor(port: MessagePort) {
|
|
26
|
+
this.port = port;
|
|
27
|
+
this.port.addEventListener('message', (event) => {
|
|
28
|
+
this.handleResponse(event.data);
|
|
29
|
+
});
|
|
30
|
+
this.port.start();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Routes responses to their pending promises. Responses without an _id are broadcast (like port-connected) and are ignored here.
|
|
35
|
+
* @param {StorageResponse & { _id?: string }} data - The response data
|
|
36
|
+
* @return {void}
|
|
37
|
+
*/
|
|
38
|
+
private handleResponse(data: StorageResponse & { _id?: string }): void {
|
|
39
|
+
if (!data._id) return;
|
|
40
|
+
const entry = this.pending.get(data._id);
|
|
41
|
+
if (!entry) return;
|
|
42
|
+
this.pending.delete(data._id);
|
|
43
|
+
if ('ok' in data && data.ok === false && 'error' in data) {
|
|
44
|
+
entry.reject(new Error((data as any).error));
|
|
45
|
+
} else {
|
|
46
|
+
entry.resolve(data);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sends a request and waits for the matching response.
|
|
52
|
+
* @param {StorageRequest} msg - The request to send
|
|
53
|
+
* @return {Promise<StorageResponse>} The matched response
|
|
54
|
+
*/
|
|
55
|
+
private send<T extends StorageResponse>(msg: StorageRequest): Promise<T> {
|
|
56
|
+
const _id = String(++this.idCounter);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
this.pending.set(_id, { resolve, reject });
|
|
59
|
+
this.port.postMessage({ ...msg, _id });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initializes the backend adapter in the SharedWorker.
|
|
65
|
+
* @param {StorageRequest & { type: 'init' }} config - The init config
|
|
66
|
+
* @return {Promise<void>}
|
|
67
|
+
*/
|
|
68
|
+
async init(config: StorageRequest & { type: 'init' }): Promise<void> {
|
|
69
|
+
await this.send(config);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lists files in a collection matching the given extensions.
|
|
74
|
+
* @param {string} collection - The collection name
|
|
75
|
+
* @param {string[]} extensions - File extensions to include (e.g. ['.md', '.mdx'])
|
|
76
|
+
* @return {Promise<FileEntry[]>} Array of file entries
|
|
77
|
+
*/
|
|
78
|
+
async listFiles(
|
|
79
|
+
collection: string,
|
|
80
|
+
extensions: string[],
|
|
81
|
+
): Promise<FileEntry[]> {
|
|
82
|
+
const res = await this.send<
|
|
83
|
+
Extract<StorageResponse, { type: 'listFiles'; ok: true }>
|
|
84
|
+
>({
|
|
85
|
+
type: 'listFiles',
|
|
86
|
+
collection,
|
|
87
|
+
extensions,
|
|
88
|
+
});
|
|
89
|
+
return res.files;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reads a single file's content.
|
|
94
|
+
* @param {string} collection - The collection name
|
|
95
|
+
* @param {string} filename - The filename
|
|
96
|
+
* @return {Promise<string>} The file content
|
|
97
|
+
*/
|
|
98
|
+
async readFile(collection: string, filename: string): Promise<string> {
|
|
99
|
+
const res = await this.send<
|
|
100
|
+
Extract<StorageResponse, { type: 'readFile'; ok: true }>
|
|
101
|
+
>({
|
|
102
|
+
type: 'readFile',
|
|
103
|
+
collection,
|
|
104
|
+
filename,
|
|
105
|
+
});
|
|
106
|
+
return res.content;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Writes a single file.
|
|
111
|
+
* @param {string} collection - The collection name
|
|
112
|
+
* @param {string} filename - The filename
|
|
113
|
+
* @param {string} content - The content to write
|
|
114
|
+
* @return {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async writeFile(
|
|
117
|
+
collection: string,
|
|
118
|
+
filename: string,
|
|
119
|
+
content: string,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await this.send({ type: 'writeFile', collection, filename, content });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Writes multiple files atomically.
|
|
126
|
+
* @param {FileWrite[]} files - Array of files to write
|
|
127
|
+
* @return {Promise<void>}
|
|
128
|
+
*/
|
|
129
|
+
async writeFiles(files: FileWrite[]): Promise<void> {
|
|
130
|
+
await this.send({ type: 'writeFiles', files });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Deletes a file from a collection. Used during file type conversion to remove the old file after the new one is written.
|
|
135
|
+
* @param {string} collection - The collection name
|
|
136
|
+
* @param {string} filename - The filename to delete
|
|
137
|
+
* @return {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async deleteFile(collection: string, filename: string): Promise<void> {
|
|
140
|
+
await this.send({ type: 'deleteFile', collection, filename });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Tears down the active backend adapter.
|
|
145
|
+
* @return {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async teardown(): Promise<void> {
|
|
148
|
+
await this.send({ type: 'teardown' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Database name for admin CMS persistence
|
|
2
|
+
const DB_NAME = 'cms-admin';
|
|
3
|
+
// Current database version — bumped from 1 to add the drafts store
|
|
4
|
+
const DB_VERSION = 2;
|
|
5
|
+
|
|
6
|
+
// Cached database promise — opened once, reused by all callers
|
|
7
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Opens (or returns the cached) shared IndexedDB database, creating or upgrading stores as needed.
|
|
11
|
+
* The connection is opened once and reused, avoiding a new indexedDB.open() round-trip per call.
|
|
12
|
+
* Version 1: handles store only. Version 2: adds drafts store.
|
|
13
|
+
* @return {Promise<IDBDatabase>} Promise resolving to the database instance
|
|
14
|
+
*/
|
|
15
|
+
export function openDB(): Promise<IDBDatabase> {
|
|
16
|
+
if (dbPromise) return dbPromise;
|
|
17
|
+
dbPromise = new Promise((resolve, reject) => {
|
|
18
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
19
|
+
request.onupgradeneeded = () => {
|
|
20
|
+
const db = request.result;
|
|
21
|
+
if (!db.objectStoreNames.contains('handles')) {
|
|
22
|
+
db.createObjectStore('handles');
|
|
23
|
+
}
|
|
24
|
+
if (!db.objectStoreNames.contains('drafts')) {
|
|
25
|
+
db.createObjectStore('drafts', { keyPath: 'id' });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
request.onsuccess = () => resolve(request.result);
|
|
29
|
+
request.onerror = () => {
|
|
30
|
+
// Reset cache so the next caller retries instead of getting a stale rejection
|
|
31
|
+
dbPromise = null;
|
|
32
|
+
reject(request.error);
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
return dbPromise;
|
|
36
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Storage adapter backed by the File System Access API.
|
|
3
|
+
* Traverses from a project root handle through src/content/{collection}/ for all operations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { StorageAdapter, FileEntry, FileWrite } from './adapter';
|
|
7
|
+
|
|
8
|
+
// FSA adapter implementation
|
|
9
|
+
export class FsaAdapter implements StorageAdapter {
|
|
10
|
+
/**
|
|
11
|
+
* Creates an FSA adapter rooted at the given directory handle.
|
|
12
|
+
* @param {FileSystemDirectoryHandle} root - The project root directory handle
|
|
13
|
+
*/
|
|
14
|
+
constructor(private root: FileSystemDirectoryHandle) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Traverses root → src → content → {collection}.
|
|
18
|
+
* @param {string} collection - The collection name
|
|
19
|
+
* @return {Promise<FileSystemDirectoryHandle>} The collection directory handle
|
|
20
|
+
*/
|
|
21
|
+
private async getCollectionDir(
|
|
22
|
+
collection: string,
|
|
23
|
+
): Promise<FileSystemDirectoryHandle> {
|
|
24
|
+
const src = await this.root.getDirectoryHandle('src');
|
|
25
|
+
const content = await src.getDirectoryHandle('content');
|
|
26
|
+
return content.getDirectoryHandle(collection);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lists files in the collection matching the given extensions, with their content.
|
|
31
|
+
* @param {string} collection - The collection name
|
|
32
|
+
* @param {string[]} extensions - File extensions to include (e.g. ['.md', '.mdx'])
|
|
33
|
+
* @return {Promise<FileEntry[]>} Array of filename + content pairs
|
|
34
|
+
*/
|
|
35
|
+
async listFiles(
|
|
36
|
+
collection: string,
|
|
37
|
+
extensions: string[],
|
|
38
|
+
): Promise<FileEntry[]> {
|
|
39
|
+
const dir = await this.getCollectionDir(collection);
|
|
40
|
+
const entries: FileEntry[] = [];
|
|
41
|
+
|
|
42
|
+
for await (const [name, entry] of dir.entries()) {
|
|
43
|
+
if (
|
|
44
|
+
entry.kind !== 'file' ||
|
|
45
|
+
!extensions.some((ext) => name.endsWith(ext))
|
|
46
|
+
) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const file = await entry.getFile();
|
|
50
|
+
const content = await file.text();
|
|
51
|
+
entries.push({ filename: name, content });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deletes a file from the collection directory.
|
|
59
|
+
* @param {string} collection - The collection name
|
|
60
|
+
* @param {string} filename - The filename to delete
|
|
61
|
+
* @return {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
async deleteFile(collection: string, filename: string): Promise<void> {
|
|
64
|
+
const dir = await this.getCollectionDir(collection);
|
|
65
|
+
await dir.removeEntry(filename);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads a single file's content from the collection.
|
|
70
|
+
* @param {string} collection - The collection name
|
|
71
|
+
* @param {string} filename - The filename
|
|
72
|
+
* @return {Promise<string>} The file content
|
|
73
|
+
*/
|
|
74
|
+
async readFile(collection: string, filename: string): Promise<string> {
|
|
75
|
+
const dir = await this.getCollectionDir(collection);
|
|
76
|
+
const fileHandle = await dir.getFileHandle(filename);
|
|
77
|
+
const file = await fileHandle.getFile();
|
|
78
|
+
return file.text();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Writes content to a file, creating it if necessary.
|
|
83
|
+
* @param {string} collection - The collection name
|
|
84
|
+
* @param {string} filename - The filename
|
|
85
|
+
* @param {string} content - The content to write
|
|
86
|
+
* @return {Promise<void>}
|
|
87
|
+
*/
|
|
88
|
+
async writeFile(
|
|
89
|
+
collection: string,
|
|
90
|
+
filename: string,
|
|
91
|
+
content: string,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const dir = await this.getCollectionDir(collection);
|
|
94
|
+
const fileHandle = await dir.getFileHandle(filename, { create: true });
|
|
95
|
+
const writable = await fileHandle.createWritable();
|
|
96
|
+
await writable.write(content);
|
|
97
|
+
await writable.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Writes multiple files sequentially (FSA has no atomic multi-file write).
|
|
102
|
+
* @param {FileWrite[]} files - Array of files to write
|
|
103
|
+
* @return {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
async writeFiles(files: FileWrite[]): Promise<void> {
|
|
106
|
+
for (const f of files) {
|
|
107
|
+
await this.writeFile(f.collection, f.filename, f.content);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Storage adapter backed by the GitHub REST API.
|
|
3
|
+
* Uses a Personal Access Token for authentication. All file operations
|
|
4
|
+
* target src/content/{collection}/ within the repository.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { StorageAdapter, FileEntry, FileWrite } from './adapter';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encodes a Uint8Array to a base64 string without using deprecated unescape().
|
|
11
|
+
* @param {Uint8Array} bytes - The bytes to encode
|
|
12
|
+
* @return {string} Base64-encoded string
|
|
13
|
+
*/
|
|
14
|
+
function uint8ToBase64(bytes: Uint8Array): string {
|
|
15
|
+
let binary = '';
|
|
16
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
17
|
+
return btoa(binary);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class GitHubAdapter implements StorageAdapter {
|
|
21
|
+
private token: string;
|
|
22
|
+
private owner: string;
|
|
23
|
+
private repo: string;
|
|
24
|
+
// Cached on validate(); defaults to 'main' before validation
|
|
25
|
+
private defaultBranch: string = 'main';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} token - GitHub Personal Access Token
|
|
29
|
+
* @param {string} repoSlug - Repository in "owner/repo" format
|
|
30
|
+
*/
|
|
31
|
+
constructor(token: string, repoSlug: string) {
|
|
32
|
+
this.token = token;
|
|
33
|
+
const [owner, repo] = repoSlug.split('/');
|
|
34
|
+
this.owner = owner;
|
|
35
|
+
this.repo = repo;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validates credentials by fetching repo metadata and caches the default branch.
|
|
40
|
+
* @return {Promise<void>}
|
|
41
|
+
*/
|
|
42
|
+
async validate(): Promise<void> {
|
|
43
|
+
const res = await this.request('GET', `/repos/${this.owner}/${this.repo}`);
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const status = res.status;
|
|
46
|
+
if (status === 401) throw new Error('Invalid or expired token');
|
|
47
|
+
if (status === 403) throw new Error('Token lacks repository access');
|
|
48
|
+
if (status === 404)
|
|
49
|
+
throw new Error(`Repository "${this.owner}/${this.repo}" not found`);
|
|
50
|
+
throw new Error(`GitHub API error: ${status}`);
|
|
51
|
+
}
|
|
52
|
+
const data = await res.json();
|
|
53
|
+
this.defaultBranch = data.default_branch;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Lists files in a collection matching the given extensions, with their raw content.
|
|
58
|
+
* @param {string} collection - The collection name
|
|
59
|
+
* @param {string[]} extensions - File extensions to include (e.g. ['.md', '.mdx'])
|
|
60
|
+
* @return {Promise<FileEntry[]>} Array of filename + content pairs
|
|
61
|
+
*/
|
|
62
|
+
async listFiles(
|
|
63
|
+
collection: string,
|
|
64
|
+
extensions: string[],
|
|
65
|
+
): Promise<FileEntry[]> {
|
|
66
|
+
const path = `src/content/${collection}`;
|
|
67
|
+
// Get directory listing
|
|
68
|
+
const listRes = await this.request(
|
|
69
|
+
'GET',
|
|
70
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}?ref=${this.defaultBranch}`,
|
|
71
|
+
);
|
|
72
|
+
if (!listRes.ok) {
|
|
73
|
+
if (listRes.status === 404) return [];
|
|
74
|
+
throw new Error(`Failed to list files: ${listRes.status}`);
|
|
75
|
+
}
|
|
76
|
+
const listing: Array<{ name: string; download_url: string }> =
|
|
77
|
+
await listRes.json();
|
|
78
|
+
|
|
79
|
+
// Filter to requested extensions and fetch all files in parallel
|
|
80
|
+
const filtered = listing.filter((f) =>
|
|
81
|
+
extensions.some((ext) => f.name.endsWith(ext)),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return Promise.all(
|
|
85
|
+
filtered.map(async (file) => ({
|
|
86
|
+
filename: file.name,
|
|
87
|
+
content: await this.readFile(collection, file.name),
|
|
88
|
+
})),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deletes a file from the repository via the Contents API.
|
|
94
|
+
* @param {string} collection - The collection name
|
|
95
|
+
* @param {string} filename - The filename to delete
|
|
96
|
+
* @return {Promise<void>}
|
|
97
|
+
*/
|
|
98
|
+
async deleteFile(collection: string, filename: string): Promise<void> {
|
|
99
|
+
const path = `src/content/${collection}/${filename}`;
|
|
100
|
+
// Get the current SHA (required by the GitHub Contents API for deletion)
|
|
101
|
+
const existing = await this.request(
|
|
102
|
+
'GET',
|
|
103
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}?ref=${this.defaultBranch}`,
|
|
104
|
+
);
|
|
105
|
+
if (!existing.ok) throw new Error(`File not found for deletion: ${path}`);
|
|
106
|
+
const data = await existing.json();
|
|
107
|
+
|
|
108
|
+
const res = await this.request(
|
|
109
|
+
'DELETE',
|
|
110
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}`,
|
|
111
|
+
undefined,
|
|
112
|
+
{
|
|
113
|
+
message: `Delete ${path}`,
|
|
114
|
+
sha: data.sha,
|
|
115
|
+
branch: this.defaultBranch,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
if (!res.ok) throw new Error(`Failed to delete ${path}: ${res.status}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reads a single file's raw content via the raw+json Accept header.
|
|
123
|
+
* @param {string} collection - The collection name
|
|
124
|
+
* @param {string} filename - The filename
|
|
125
|
+
* @return {Promise<string>} The file content
|
|
126
|
+
*/
|
|
127
|
+
async readFile(collection: string, filename: string): Promise<string> {
|
|
128
|
+
const path = `src/content/${collection}/${filename}`;
|
|
129
|
+
const res = await this.request(
|
|
130
|
+
'GET',
|
|
131
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}?ref=${this.defaultBranch}`,
|
|
132
|
+
{ Accept: 'application/vnd.github.raw+json' },
|
|
133
|
+
);
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
throw new Error(`Failed to read ${path}: ${res.status}`);
|
|
136
|
+
}
|
|
137
|
+
return res.text();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Writes a single file via the Contents API. Fetches the current SHA for updates.
|
|
142
|
+
* @param {string} collection - The collection name
|
|
143
|
+
* @param {string} filename - The filename
|
|
144
|
+
* @param {string} content - The content to write
|
|
145
|
+
* @return {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async writeFile(
|
|
148
|
+
collection: string,
|
|
149
|
+
filename: string,
|
|
150
|
+
content: string,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const path = `src/content/${collection}/${filename}`;
|
|
153
|
+
// Get current SHA if file exists (required by GitHub API for updates)
|
|
154
|
+
let sha: string | undefined;
|
|
155
|
+
const existing = await this.request(
|
|
156
|
+
'GET',
|
|
157
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}?ref=${this.defaultBranch}`,
|
|
158
|
+
);
|
|
159
|
+
if (existing.ok) {
|
|
160
|
+
const data = await existing.json();
|
|
161
|
+
sha = data.sha;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const body: Record<string, string> = {
|
|
165
|
+
message: `Update ${path}`,
|
|
166
|
+
content: uint8ToBase64(new TextEncoder().encode(content)),
|
|
167
|
+
branch: this.defaultBranch,
|
|
168
|
+
};
|
|
169
|
+
if (sha) body.sha = sha;
|
|
170
|
+
|
|
171
|
+
const res = await this.request(
|
|
172
|
+
'PUT',
|
|
173
|
+
`/repos/${this.owner}/${this.repo}/contents/${path}`,
|
|
174
|
+
undefined,
|
|
175
|
+
body,
|
|
176
|
+
);
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const errText = await res.text();
|
|
179
|
+
throw new Error(`Failed to write ${path}: ${res.status} ${errText}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Writes multiple files in a single atomic commit using the Git Trees + Commits API.
|
|
185
|
+
* @param {FileWrite[]} files - Array of files to write
|
|
186
|
+
* @return {Promise<void>}
|
|
187
|
+
*/
|
|
188
|
+
async writeFiles(files: FileWrite[]): Promise<void> {
|
|
189
|
+
if (files.length === 0) return;
|
|
190
|
+
if (files.length === 1) {
|
|
191
|
+
await this.writeFile(
|
|
192
|
+
files[0].collection,
|
|
193
|
+
files[0].filename,
|
|
194
|
+
files[0].content,
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 1. Get current commit SHA for the default branch
|
|
200
|
+
const refRes = await this.request(
|
|
201
|
+
'GET',
|
|
202
|
+
`/repos/${this.owner}/${this.repo}/git/ref/heads/${this.defaultBranch}`,
|
|
203
|
+
);
|
|
204
|
+
if (!refRes.ok)
|
|
205
|
+
throw new Error(`Failed to get branch ref: ${refRes.status}`);
|
|
206
|
+
const refData = await refRes.json();
|
|
207
|
+
const baseCommitSHA = refData.object.sha;
|
|
208
|
+
|
|
209
|
+
// Get the base tree SHA from the current commit
|
|
210
|
+
const commitRes = await this.request(
|
|
211
|
+
'GET',
|
|
212
|
+
`/repos/${this.owner}/${this.repo}/git/commits/${baseCommitSHA}`,
|
|
213
|
+
);
|
|
214
|
+
if (!commitRes.ok)
|
|
215
|
+
throw new Error(`Failed to get commit: ${commitRes.status}`);
|
|
216
|
+
const commitData = await commitRes.json();
|
|
217
|
+
const baseTreeSHA = commitData.tree.sha;
|
|
218
|
+
|
|
219
|
+
// 2. Create a new tree containing all file changes
|
|
220
|
+
const tree = files.map((f) => ({
|
|
221
|
+
path: `src/content/${f.collection}/${f.filename}`,
|
|
222
|
+
mode: '100644' as const,
|
|
223
|
+
type: 'blob' as const,
|
|
224
|
+
content: f.content,
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
const treeRes = await this.request(
|
|
228
|
+
'POST',
|
|
229
|
+
`/repos/${this.owner}/${this.repo}/git/trees`,
|
|
230
|
+
undefined,
|
|
231
|
+
{ base_tree: baseTreeSHA, tree },
|
|
232
|
+
);
|
|
233
|
+
if (!treeRes.ok)
|
|
234
|
+
throw new Error(`Failed to create tree: ${treeRes.status}`);
|
|
235
|
+
const treeData = await treeRes.json();
|
|
236
|
+
|
|
237
|
+
// 3. Create a new commit pointing at the new tree
|
|
238
|
+
const paths = files.map((f) => `${f.collection}/${f.filename}`).join(', ');
|
|
239
|
+
const newCommitRes = await this.request(
|
|
240
|
+
'POST',
|
|
241
|
+
`/repos/${this.owner}/${this.repo}/git/commits`,
|
|
242
|
+
undefined,
|
|
243
|
+
{
|
|
244
|
+
message: `Update ${paths}`,
|
|
245
|
+
tree: treeData.sha,
|
|
246
|
+
parents: [baseCommitSHA],
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
if (!newCommitRes.ok)
|
|
250
|
+
throw new Error(`Failed to create commit: ${newCommitRes.status}`);
|
|
251
|
+
const newCommitData = await newCommitRes.json();
|
|
252
|
+
|
|
253
|
+
// 4. Advance the branch ref to the new commit
|
|
254
|
+
const updateRefRes = await this.request(
|
|
255
|
+
'PATCH',
|
|
256
|
+
`/repos/${this.owner}/${this.repo}/git/refs/heads/${this.defaultBranch}`,
|
|
257
|
+
undefined,
|
|
258
|
+
{ sha: newCommitData.sha },
|
|
259
|
+
);
|
|
260
|
+
if (!updateRefRes.ok)
|
|
261
|
+
throw new Error(`Failed to update ref: ${updateRefRes.status}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Makes an authenticated request to the GitHub API.
|
|
266
|
+
* @param {string} method - HTTP method
|
|
267
|
+
* @param {string} path - API path (appended to https://api.github.com)
|
|
268
|
+
* @param {Record<string, string>} [extraHeaders] - Additional headers to merge
|
|
269
|
+
* @param {unknown} [body] - JSON body to send
|
|
270
|
+
* @return {Promise<Response>} The fetch response
|
|
271
|
+
*/
|
|
272
|
+
private async request(
|
|
273
|
+
method: string,
|
|
274
|
+
path: string,
|
|
275
|
+
extraHeaders?: Record<string, string>,
|
|
276
|
+
body?: unknown,
|
|
277
|
+
): Promise<Response> {
|
|
278
|
+
const headers: Record<string, string> = {
|
|
279
|
+
Authorization: `Bearer ${this.token}`,
|
|
280
|
+
'X-GitHub-Api-Version': '2026-03-10',
|
|
281
|
+
// Default to JSON so the browser cache distinguishes from raw content requests
|
|
282
|
+
Accept: 'application/vnd.github+json',
|
|
283
|
+
...extraHeaders,
|
|
284
|
+
};
|
|
285
|
+
if (body) {
|
|
286
|
+
headers['Content-Type'] = 'application/json';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return fetch(`https://api.github.com${path}`, {
|
|
290
|
+
method,
|
|
291
|
+
headers,
|
|
292
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
293
|
+
// API responses must never be served from browser cache
|
|
294
|
+
cache: 'no-store',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|