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.
Files changed (241) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.mcp.json +12 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc.cjs +22 -0
  6. package/AGENTS.md +183 -0
  7. package/LICENSE +201 -0
  8. package/README.md +128 -0
  9. package/package.json +74 -0
  10. package/playground/.claude/settings.local.json +5 -0
  11. package/playground/astro.config.mjs +7 -0
  12. package/playground/node_modules/.bin/astro +21 -0
  13. package/playground/node_modules/.bin/rollup +21 -0
  14. package/playground/node_modules/.bin/tsc +21 -0
  15. package/playground/node_modules/.bin/tsserver +21 -0
  16. package/playground/node_modules/.bin/vite +21 -0
  17. package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
  18. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
  19. package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
  20. package/playground/node_modules/.vite/deps/_metadata.json +184 -0
  21. package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
  22. package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
  23. package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
  24. package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
  25. package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
  26. package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
  27. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
  28. package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
  29. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
  30. package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
  31. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
  32. package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
  33. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
  34. package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
  35. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
  36. package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
  37. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
  38. package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
  39. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
  40. package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
  41. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
  42. package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
  43. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
  44. package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
  45. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
  46. package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
  47. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
  48. package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
  49. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
  50. package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
  51. package/playground/node_modules/.vite/deps/package.json +3 -0
  52. package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
  53. package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
  54. package/playground/node_modules/.vite/deps/svelte.js +55 -0
  55. package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
  56. package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
  57. package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
  58. package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
  59. package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
  60. package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
  61. package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
  62. package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
  63. package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
  64. package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
  65. package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
  66. package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
  67. package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
  68. package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
  69. package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
  70. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
  71. package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
  72. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
  73. package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
  74. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
  75. package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
  76. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
  77. package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
  78. package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
  79. package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
  80. package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
  81. package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
  82. package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
  83. package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
  84. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
  85. package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
  86. package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
  87. package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
  88. package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
  89. package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
  90. package/playground/package.json +16 -0
  91. package/playground/pnpm-lock.yaml +3167 -0
  92. package/playground/src/content/authors/jane-doe.json +8 -0
  93. package/playground/src/content/config/build.toml +2 -0
  94. package/playground/src/content/courses/web-fundamentals.json +29 -0
  95. package/playground/src/content/docs/advanced.mdx +6 -0
  96. package/playground/src/content/docs/intro.md +6 -0
  97. package/playground/src/content/guides/getting-started.mdx +6 -0
  98. package/playground/src/content/posts/hello-world.md +7 -0
  99. package/playground/src/content/products/t-shirt.json +16 -0
  100. package/playground/src/content/recipes/pancakes.mdoc +8 -0
  101. package/playground/src/content/settings/site.yml +2 -0
  102. package/playground/src/content.config.ts +198 -0
  103. package/playground/src/env.d.ts +1 -0
  104. package/playground/src/pages/index.astro +11 -0
  105. package/playground/src/pages/nebula.astro +14 -0
  106. package/pnpm-workspace.yaml +2 -0
  107. package/scripts/subset-icons.mjs +178 -0
  108. package/src/astro/index.ts +295 -0
  109. package/src/client/Admin.svelte +283 -0
  110. package/src/client/components/BackendPicker.svelte +291 -0
  111. package/src/client/components/DraftChip.svelte +46 -0
  112. package/src/client/components/MetadataForm.svelte +56 -0
  113. package/src/client/components/ThemeToggle.svelte +18 -0
  114. package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
  115. package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
  116. package/src/client/components/editor/EditorPane.svelte +227 -0
  117. package/src/client/components/editor/EditorTabs.svelte +81 -0
  118. package/src/client/components/editor/EditorToolbar.svelte +131 -0
  119. package/src/client/components/editor/FormatSelector.svelte +66 -0
  120. package/src/client/components/editor/Toolbar.svelte +17 -0
  121. package/src/client/components/fields/ArrayField.svelte +339 -0
  122. package/src/client/components/fields/ArrayItem.svelte +325 -0
  123. package/src/client/components/fields/BooleanField.svelte +114 -0
  124. package/src/client/components/fields/DateField.svelte +82 -0
  125. package/src/client/components/fields/EnumField.svelte +74 -0
  126. package/src/client/components/fields/FieldWrapper.svelte +96 -0
  127. package/src/client/components/fields/NumberField.svelte +99 -0
  128. package/src/client/components/fields/ObjectField.svelte +121 -0
  129. package/src/client/components/fields/SchemaField.svelte +107 -0
  130. package/src/client/components/fields/StringField.svelte +104 -0
  131. package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
  132. package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
  133. package/src/client/css/a11y.css +14 -0
  134. package/src/client/css/btn.css +113 -0
  135. package/src/client/css/dialog.css +29 -0
  136. package/src/client/css/field-input.css +39 -0
  137. package/src/client/css/reset.css +59 -0
  138. package/src/client/css/theme.css +77 -0
  139. package/src/client/index.ts +1 -0
  140. package/src/client/js/drafts/merge.svelte.ts +121 -0
  141. package/src/client/js/drafts/ops.svelte.ts +227 -0
  142. package/src/client/js/drafts/storage.ts +108 -0
  143. package/src/client/js/drafts/workers/diff.ts +40 -0
  144. package/src/client/js/editor/editor.svelte.ts +343 -0
  145. package/src/client/js/editor/languages.ts +98 -0
  146. package/src/client/js/editor/link-wrap.ts +45 -0
  147. package/src/client/js/editor/markdown-shortcuts.ts +261 -0
  148. package/src/client/js/handlers/admin.ts +246 -0
  149. package/src/client/js/state/dialogs.svelte.ts +35 -0
  150. package/src/client/js/state/router.svelte.ts +156 -0
  151. package/src/client/js/state/schema.svelte.ts +140 -0
  152. package/src/client/js/state/state.svelte.ts +334 -0
  153. package/src/client/js/state/theme.svelte.ts +173 -0
  154. package/src/client/js/storage/adapter.ts +102 -0
  155. package/src/client/js/storage/client.ts +150 -0
  156. package/src/client/js/storage/db.ts +36 -0
  157. package/src/client/js/storage/fsa.ts +110 -0
  158. package/src/client/js/storage/github.ts +297 -0
  159. package/src/client/js/storage/storage.ts +83 -0
  160. package/src/client/js/storage/workers/frontmatter.ts +320 -0
  161. package/src/client/js/storage/workers/storage.ts +177 -0
  162. package/src/client/js/storage/workers/toml-parser.ts +106 -0
  163. package/src/client/js/storage/workers/yaml-parser.ts +132 -0
  164. package/src/client/js/utils/file-types.ts +192 -0
  165. package/src/client/js/utils/format.ts +16 -0
  166. package/src/client/js/utils/frontmatter.ts +38 -0
  167. package/src/client/js/utils/schema-utils.ts +295 -0
  168. package/src/client/js/utils/slug.ts +18 -0
  169. package/src/client/js/utils/sort.ts +84 -0
  170. package/src/client/js/utils/stable-stringify.ts +27 -0
  171. package/src/client/js/utils/url-utils.ts +38 -0
  172. package/src/types.ts +25 -0
  173. package/src/virtual.d.ts +22 -0
  174. package/svelte.config.js +4 -0
  175. package/tests/astro/build.test.ts +63 -0
  176. package/tests/astro/index.test.ts +689 -0
  177. package/tests/client/components/Admin.test.ts +446 -0
  178. package/tests/client/components/BackendPicker.test.ts +239 -0
  179. package/tests/client/components/DraftChip.test.ts +53 -0
  180. package/tests/client/components/MetadataForm.test.ts +164 -0
  181. package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
  182. package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
  183. package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
  184. package/tests/client/components/editor/EditorPane.test.ts +100 -0
  185. package/tests/client/components/editor/EditorTabs.test.ts +253 -0
  186. package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
  187. package/tests/client/components/editor/fixtures.ts +31 -0
  188. package/tests/client/components/fields/ArrayField.test.ts +197 -0
  189. package/tests/client/components/fields/BooleanField.test.ts +206 -0
  190. package/tests/client/components/fields/DateField.test.ts +210 -0
  191. package/tests/client/components/fields/EnumField.test.ts +246 -0
  192. package/tests/client/components/fields/NumberField.test.ts +240 -0
  193. package/tests/client/components/fields/ObjectField.test.ts +157 -0
  194. package/tests/client/components/fields/SchemaField.test.ts +190 -0
  195. package/tests/client/components/fields/StringField.test.ts +223 -0
  196. package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
  197. package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
  198. package/tests/client/components/sidebar/sort-mock.ts +23 -0
  199. package/tests/client/js/drafts/fixtures.ts +22 -0
  200. package/tests/client/js/drafts/merge.test.ts +282 -0
  201. package/tests/client/js/drafts/ops.test.ts +658 -0
  202. package/tests/client/js/drafts/storage.test.ts +200 -0
  203. package/tests/client/js/drafts/workers/diff.test.ts +165 -0
  204. package/tests/client/js/editor/editor.test.ts +616 -0
  205. package/tests/client/js/editor/link-wrap.test.ts +225 -0
  206. package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
  207. package/tests/client/js/handlers/admin.test.ts +467 -0
  208. package/tests/client/js/state/router.test.ts +619 -0
  209. package/tests/client/js/state/schema.test.ts +266 -0
  210. package/tests/client/js/state/state.test.ts +328 -0
  211. package/tests/client/js/storage/adapter.test.ts +115 -0
  212. package/tests/client/js/storage/client.test.ts +250 -0
  213. package/tests/client/js/storage/db.test.ts +59 -0
  214. package/tests/client/js/storage/fsa.test.ts +284 -0
  215. package/tests/client/js/storage/github.test.ts +349 -0
  216. package/tests/client/js/storage/mock-port.ts +95 -0
  217. package/tests/client/js/storage/storage.test.ts +77 -0
  218. package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
  219. package/tests/client/js/storage/workers/storage.test.ts +299 -0
  220. package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
  221. package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
  222. package/tests/client/js/utils/file-types.test.ts +268 -0
  223. package/tests/client/js/utils/frontmatter.test.ts +87 -0
  224. package/tests/client/js/utils/schema-utils.test.ts +318 -0
  225. package/tests/client/js/utils/slug.test.ts +58 -0
  226. package/tests/client/js/utils/sort.test.ts +276 -0
  227. package/tests/client/js/utils/stable-stringify.test.ts +68 -0
  228. package/tests/client/js/utils/url-utils.test.ts +70 -0
  229. package/tests/e2e/backend-connection.test.ts +301 -0
  230. package/tests/e2e/draft-lifecycle.test.ts +388 -0
  231. package/tests/e2e/editing.test.ts +355 -0
  232. package/tests/e2e/github-adapter.test.ts +330 -0
  233. package/tests/e2e/helpers/mock-adapter.ts +166 -0
  234. package/tests/e2e/helpers/test-app.ts +155 -0
  235. package/tests/e2e/navigation.test.ts +358 -0
  236. package/tests/e2e/publishing.test.ts +345 -0
  237. package/tests/e2e/unsaved-changes.test.ts +317 -0
  238. package/tests/setup.ts +2 -0
  239. package/tests/stubs/codemirror.ts +197 -0
  240. package/tsconfig.json +19 -0
  241. package/vitest.config.ts +178 -0
@@ -0,0 +1,284 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ //////////////////////////////
4
+ // FsaAdapter tests
5
+ //
6
+ // The File System Access API is not available in Node.js, so we build a
7
+ // minimal in-memory mock of FileSystemDirectoryHandle / FileSystemFileHandle
8
+ // that mirrors the FSA contract expected by FsaAdapter.
9
+ //////////////////////////////
10
+
11
+ import { FsaAdapter } from '../../../../src/client/js/storage/fsa';
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Writable mock that stores text and returns it via text().
17
+ */
18
+ interface MockFile {
19
+ _content: string;
20
+ text(): Promise<string>;
21
+ }
22
+
23
+ /**
24
+ * Creates a minimal File mock.
25
+ * @param {string} content - The file content
26
+ * @return {MockFile} A mock File object
27
+ */
28
+ function makeFile(content: string): MockFile {
29
+ return {
30
+ _content: content,
31
+ text: async () => content,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Creates a mock FileSystemWritableFileStream.
37
+ * @param {MockFileHandle} owner - The handle that owns this stream
38
+ * @return {object} A mock writable stream
39
+ */
40
+ function makeWritable(owner: MockFileHandle) {
41
+ return {
42
+ write: vi.fn(async (data: string) => {
43
+ owner._content = data;
44
+ }),
45
+ close: vi.fn(async () => undefined),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Mock FileSystemFileHandle backed by an in-memory string.
51
+ */
52
+ class MockFileHandle {
53
+ kind = 'file' as const;
54
+ _content: string;
55
+
56
+ /**
57
+ * @param {string} content - Initial file content
58
+ */
59
+ constructor(content = '') {
60
+ this._content = content;
61
+ }
62
+
63
+ /**
64
+ * Returns the mock File for this handle.
65
+ * @return {Promise<MockFile>} The file object
66
+ */
67
+ async getFile(): Promise<MockFile> {
68
+ return makeFile(this._content);
69
+ }
70
+
71
+ /**
72
+ * Returns a writable stream for this handle.
73
+ * @return {Promise<ReturnType<typeof makeWritable>>} The writable stream
74
+ */
75
+ async createWritable() {
76
+ return makeWritable(this);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Builds a mock FileSystemDirectoryHandle that holds a flat map of name → entry.
82
+ * @param {Record<string, MockFileHandle | MockDirHandle>} children - The directory's children
83
+ * @return {MockDirHandle} A mock directory handle
84
+ */
85
+ class MockDirHandle {
86
+ kind = 'directory' as const;
87
+ private children: Map<string, MockFileHandle | MockDirHandle>;
88
+
89
+ /**
90
+ * @param {Record<string, MockFileHandle | MockDirHandle>} entries - Initial children map
91
+ */
92
+ constructor(entries: Record<string, MockFileHandle | MockDirHandle> = {}) {
93
+ this.children = new Map(Object.entries(entries));
94
+ }
95
+
96
+ /**
97
+ * Returns a child directory handle by name.
98
+ * @param {string} name - The directory name
99
+ * @return {Promise<MockDirHandle>} The directory handle
100
+ */
101
+ async getDirectoryHandle(name: string): Promise<MockDirHandle> {
102
+ const entry = this.children.get(name);
103
+ if (!entry || entry.kind !== 'directory') {
104
+ throw new DOMException(`${name} not found`, 'NotFoundError');
105
+ }
106
+ return entry as MockDirHandle;
107
+ }
108
+
109
+ /**
110
+ * Returns a child file handle, optionally creating it.
111
+ * @param {string} name - The file name
112
+ * @param {{ create?: boolean }} options - Options object
113
+ * @return {Promise<MockFileHandle>} The file handle
114
+ */
115
+ async getFileHandle(
116
+ name: string,
117
+ options?: { create?: boolean },
118
+ ): Promise<MockFileHandle> {
119
+ if (!this.children.has(name)) {
120
+ if (options?.create) {
121
+ const newHandle = new MockFileHandle('');
122
+ this.children.set(name, newHandle);
123
+ return newHandle;
124
+ }
125
+ throw new DOMException(`${name} not found`, 'NotFoundError');
126
+ }
127
+ return this.children.get(name) as MockFileHandle;
128
+ }
129
+
130
+ /**
131
+ * Removes a child entry by name.
132
+ * @param {string} name - The entry name to remove
133
+ * @return {Promise<void>}
134
+ */
135
+ async removeEntry(name: string): Promise<void> {
136
+ if (!this.children.has(name)) {
137
+ throw new DOMException(`${name} not found`, 'NotFoundError');
138
+ }
139
+ this.children.delete(name);
140
+ }
141
+
142
+ /**
143
+ * Async generator that yields [name, entry] pairs for all children.
144
+ * @return {AsyncIterable<[string, MockFileHandle | MockDirHandle]>}
145
+ */
146
+ async *entries(): AsyncIterable<[string, MockFileHandle | MockDirHandle]> {
147
+ for (const [name, entry] of this.children) {
148
+ yield [name, entry];
149
+ }
150
+ }
151
+ }
152
+
153
+ // ── Fixtures ─────────────────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Builds a root handle pre-populated with src/content/posts containing
157
+ * two .md files, one .mdx file, and a directory entry (to be skipped).
158
+ * @return {MockDirHandle} The mock root handle
159
+ */
160
+ function makeRoot(): MockDirHandle {
161
+ const postsDir = new MockDirHandle({
162
+ 'hello.md': new MockFileHandle('---\ntitle: Hello\n---\n'),
163
+ 'world.md': new MockFileHandle('---\ntitle: World\n---\n'),
164
+ 'page.mdx': new MockFileHandle('---\ntitle: Page\n---\n'),
165
+ subdir: new MockDirHandle(),
166
+ });
167
+ const contentDir = new MockDirHandle({ posts: postsDir });
168
+ const srcDir = new MockDirHandle({ content: contentDir });
169
+ return new MockDirHandle({ src: srcDir });
170
+ }
171
+
172
+ // ── Tests ─────────────────────────────────────────────────────────────────────
173
+
174
+ describe('FsaAdapter', () => {
175
+ let root: MockDirHandle;
176
+ let adapter: FsaAdapter;
177
+
178
+ beforeEach(() => {
179
+ root = makeRoot();
180
+ adapter = new FsaAdapter(root as unknown as FileSystemDirectoryHandle);
181
+ });
182
+
183
+ describe('listFiles', () => {
184
+ it('returns all .md and .mdx files with their content', async () => {
185
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
186
+ const names = files.map((f) => f.filename).sort();
187
+ expect(names).toEqual(['hello.md', 'page.mdx', 'world.md']);
188
+ });
189
+
190
+ it('returns the correct content for each file', async () => {
191
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
192
+ const hello = files.find((f) => f.filename === 'hello.md');
193
+ expect(hello?.content).toBe('---\ntitle: Hello\n---\n');
194
+ });
195
+
196
+ it('skips directory entries', async () => {
197
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
198
+ const names = files.map((f) => f.filename);
199
+ expect(names).not.toContain('subdir');
200
+ });
201
+
202
+ it('returns an empty array for an empty collection directory', async () => {
203
+ const emptyDir = new MockDirHandle();
204
+ const contentDir = new MockDirHandle({ empty: emptyDir });
205
+ const srcDir = new MockDirHandle({ content: contentDir });
206
+ const emptyRoot = new MockDirHandle({ src: srcDir });
207
+ const emptyAdapter = new FsaAdapter(
208
+ emptyRoot as unknown as FileSystemDirectoryHandle,
209
+ );
210
+ const files = await emptyAdapter.listFiles('empty', ['.md', '.mdx']);
211
+ expect(files).toEqual([]);
212
+ });
213
+
214
+ it('filters by the given extensions', async () => {
215
+ const mdOnly = await adapter.listFiles('posts', ['.md']);
216
+ const names = mdOnly.map((f) => f.filename).sort();
217
+ expect(names).toEqual(['hello.md', 'world.md']);
218
+ expect(names).not.toContain('page.mdx');
219
+ });
220
+
221
+ it('returns nothing when no files match the extensions', async () => {
222
+ const files = await adapter.listFiles('posts', ['.yaml']);
223
+ expect(files).toEqual([]);
224
+ });
225
+ });
226
+
227
+ describe('readFile', () => {
228
+ it('returns the file content for a known file', async () => {
229
+ const content = await adapter.readFile('posts', 'hello.md');
230
+ expect(content).toBe('---\ntitle: Hello\n---\n');
231
+ });
232
+
233
+ it('throws when the file does not exist', async () => {
234
+ await expect(adapter.readFile('posts', 'missing.md')).rejects.toThrow();
235
+ });
236
+ });
237
+
238
+ describe('writeFile', () => {
239
+ it('writes content to an existing file', async () => {
240
+ await adapter.writeFile('posts', 'hello.md', 'new content');
241
+ const content = await adapter.readFile('posts', 'hello.md');
242
+ expect(content).toBe('new content');
243
+ });
244
+
245
+ it('creates a new file when it does not exist', async () => {
246
+ await adapter.writeFile('posts', 'brand-new.md', 'fresh content');
247
+ const content = await adapter.readFile('posts', 'brand-new.md');
248
+ expect(content).toBe('fresh content');
249
+ });
250
+ });
251
+
252
+ describe('deleteFile', () => {
253
+ it('removes an existing file from the collection', async () => {
254
+ await adapter.deleteFile('posts', 'hello.md');
255
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
256
+ const names = files.map((f) => f.filename);
257
+ expect(names).not.toContain('hello.md');
258
+ });
259
+
260
+ it('throws when the file does not exist', async () => {
261
+ await expect(
262
+ adapter.deleteFile('posts', 'nonexistent.md'),
263
+ ).rejects.toThrow();
264
+ });
265
+ });
266
+
267
+ describe('writeFiles', () => {
268
+ it('writes all files sequentially', async () => {
269
+ await adapter.writeFiles([
270
+ { collection: 'posts', filename: 'hello.md', content: 'updated hello' },
271
+ { collection: 'posts', filename: 'world.md', content: 'updated world' },
272
+ ]);
273
+ const hello = await adapter.readFile('posts', 'hello.md');
274
+ const world = await adapter.readFile('posts', 'world.md');
275
+ expect(hello).toBe('updated hello');
276
+ expect(world).toBe('updated world');
277
+ });
278
+
279
+ it('is a no-op for an empty array', async () => {
280
+ // Should resolve without throwing
281
+ await expect(adapter.writeFiles([])).resolves.toBeUndefined();
282
+ });
283
+ });
284
+ });
@@ -0,0 +1,349 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ //////////////////////////////
4
+ // GitHubAdapter tests
5
+ //
6
+ // All network calls are intercepted by replacing globalThis.fetch with a
7
+ // vi.fn() that returns pre-built Response objects. Each test group
8
+ // configures fetch to respond to the specific API endpoints exercised
9
+ // by the method under test.
10
+ //////////////////////////////
11
+
12
+ import { GitHubAdapter } from '../../../../src/client/js/storage/github';
13
+
14
+ // ── Helpers ───────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Creates a minimal Response-like object for use in fetch mocks.
18
+ * @param {unknown} body - The response body (will be JSON-serialized if object)
19
+ * @param {number} status - HTTP status code
20
+ * @param {{ text?: boolean }} opts - When text is true, body is treated as a raw string
21
+ * @return {Response} A mock Response instance
22
+ */
23
+ function mockResponse(
24
+ body: unknown,
25
+ status = 200,
26
+ opts: { text?: boolean } = {},
27
+ ): Response {
28
+ const bodyStr = opts.text ? (body as string) : JSON.stringify(body);
29
+ return new Response(bodyStr, {
30
+ status,
31
+ headers: { 'Content-Type': opts.text ? 'text/plain' : 'application/json' },
32
+ });
33
+ }
34
+
35
+ // ── Setup ─────────────────────────────────────────────────────────────────────
36
+
37
+ let fetchMock: ReturnType<typeof vi.fn>;
38
+
39
+ beforeEach(() => {
40
+ fetchMock = vi.fn();
41
+ globalThis.fetch = fetchMock;
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ // ── Tests ─────────────────────────────────────────────────────────────────────
49
+
50
+ describe('GitHubAdapter', () => {
51
+ const TOKEN = 'ghp_test_token';
52
+ const REPO = 'owner/my-repo';
53
+
54
+ describe('validate', () => {
55
+ it('stores the default branch returned by the repo endpoint', async () => {
56
+ fetchMock.mockResolvedValueOnce(
57
+ mockResponse({ default_branch: 'develop' }),
58
+ );
59
+ const adapter = new GitHubAdapter(TOKEN, REPO);
60
+ await adapter.validate();
61
+ // Confirm the branch is used in subsequent calls by inspecting listFiles
62
+ fetchMock.mockResolvedValueOnce(mockResponse([]));
63
+ await adapter.listFiles('posts', ['.md', '.mdx']);
64
+ const listURL = fetchMock.mock.calls[1][0] as string;
65
+ expect(listURL).toContain('ref=develop');
66
+ });
67
+
68
+ it('throws for a 401 response', async () => {
69
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 401));
70
+ const adapter = new GitHubAdapter(TOKEN, REPO);
71
+ await expect(adapter.validate()).rejects.toThrow('Invalid or expired');
72
+ });
73
+
74
+ it('throws for a 403 response', async () => {
75
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 403));
76
+ const adapter = new GitHubAdapter(TOKEN, REPO);
77
+ await expect(adapter.validate()).rejects.toThrow('lacks repository');
78
+ });
79
+
80
+ it('throws for a 404 response', async () => {
81
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
82
+ const adapter = new GitHubAdapter(TOKEN, REPO);
83
+ await expect(adapter.validate()).rejects.toThrow('not found');
84
+ });
85
+
86
+ it('throws a generic error for other non-ok statuses', async () => {
87
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 500));
88
+ const adapter = new GitHubAdapter(TOKEN, REPO);
89
+ await expect(adapter.validate()).rejects.toThrow('GitHub API error: 500');
90
+ });
91
+ });
92
+
93
+ describe('listFiles', () => {
94
+ it('returns empty array when the collection path returns 404', async () => {
95
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
96
+ const adapter = new GitHubAdapter(TOKEN, REPO);
97
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
98
+ expect(files).toEqual([]);
99
+ });
100
+
101
+ it('returns only files matching the given extensions', async () => {
102
+ // Directory listing response
103
+ fetchMock.mockResolvedValueOnce(
104
+ mockResponse([
105
+ {
106
+ name: 'hello.md',
107
+ download_url: 'https://raw.github.com/hello.md',
108
+ },
109
+ {
110
+ name: 'world.mdx',
111
+ download_url: 'https://raw.github.com/world.mdx',
112
+ },
113
+ {
114
+ name: 'image.png',
115
+ download_url: 'https://raw.github.com/image.png',
116
+ },
117
+ ]),
118
+ );
119
+ // readFile calls for hello.md and world.mdx
120
+ fetchMock.mockResolvedValueOnce(
121
+ mockResponse('# Hello', 200, { text: true }),
122
+ );
123
+ fetchMock.mockResolvedValueOnce(
124
+ mockResponse('# World', 200, { text: true }),
125
+ );
126
+
127
+ const adapter = new GitHubAdapter(TOKEN, REPO);
128
+ const files = await adapter.listFiles('posts', ['.md', '.mdx']);
129
+ const names = files.map((f) => f.filename).sort();
130
+ expect(names).toEqual(['hello.md', 'world.mdx']);
131
+ });
132
+
133
+ it('filters to only the requested extensions', async () => {
134
+ fetchMock.mockResolvedValueOnce(
135
+ mockResponse([
136
+ {
137
+ name: 'hello.md',
138
+ download_url: 'https://raw.github.com/hello.md',
139
+ },
140
+ {
141
+ name: 'world.mdx',
142
+ download_url: 'https://raw.github.com/world.mdx',
143
+ },
144
+ {
145
+ name: 'data.yaml',
146
+ download_url: 'https://raw.github.com/data.yaml',
147
+ },
148
+ ]),
149
+ );
150
+ // Only .yaml file should be fetched
151
+ fetchMock.mockResolvedValueOnce(
152
+ mockResponse('key: value', 200, { text: true }),
153
+ );
154
+
155
+ const adapter = new GitHubAdapter(TOKEN, REPO);
156
+ const files = await adapter.listFiles('posts', ['.yaml']);
157
+ expect(files).toHaveLength(1);
158
+ expect(files[0].filename).toBe('data.yaml');
159
+ });
160
+
161
+ it('throws when the listing request fails with a non-404 error', async () => {
162
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 500));
163
+ const adapter = new GitHubAdapter(TOKEN, REPO);
164
+ await expect(adapter.listFiles('posts', ['.md', '.mdx'])).rejects.toThrow(
165
+ 'Failed to list files',
166
+ );
167
+ });
168
+ });
169
+
170
+ describe('deleteFile', () => {
171
+ it('sends a DELETE request with the current SHA', async () => {
172
+ // GET to retrieve the current SHA
173
+ fetchMock.mockResolvedValueOnce(
174
+ mockResponse({ sha: 'file-sha-123', name: 'old.md' }),
175
+ );
176
+ // DELETE succeeds
177
+ fetchMock.mockResolvedValueOnce(mockResponse({ commit: {} }));
178
+
179
+ const adapter = new GitHubAdapter(TOKEN, REPO);
180
+ await adapter.deleteFile('posts', 'old.md');
181
+
182
+ // Verify the DELETE call
183
+ const deleteCall = fetchMock.mock.calls[1];
184
+ expect(deleteCall[1].method).toBe('DELETE');
185
+ const deleteBody = JSON.parse(deleteCall[1].body as string);
186
+ expect(deleteBody.sha).toBe('file-sha-123');
187
+ expect(deleteBody.message).toContain('old.md');
188
+ });
189
+
190
+ it('throws when the file does not exist', async () => {
191
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
192
+ const adapter = new GitHubAdapter(TOKEN, REPO);
193
+ await expect(adapter.deleteFile('posts', 'missing.md')).rejects.toThrow(
194
+ 'File not found for deletion',
195
+ );
196
+ });
197
+
198
+ it('throws when the DELETE request fails', async () => {
199
+ fetchMock.mockResolvedValueOnce(
200
+ mockResponse({ sha: 'sha-abc', name: 'target.md' }),
201
+ );
202
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 422));
203
+ const adapter = new GitHubAdapter(TOKEN, REPO);
204
+ await expect(adapter.deleteFile('posts', 'target.md')).rejects.toThrow(
205
+ 'Failed to delete',
206
+ );
207
+ });
208
+ });
209
+
210
+ describe('readFile', () => {
211
+ it('returns the raw file content', async () => {
212
+ fetchMock.mockResolvedValueOnce(
213
+ mockResponse('---\ntitle: Test\n---\n', 200, { text: true }),
214
+ );
215
+ const adapter = new GitHubAdapter(TOKEN, REPO);
216
+ const content = await adapter.readFile('posts', 'test.md');
217
+ expect(content).toBe('---\ntitle: Test\n---\n');
218
+ });
219
+
220
+ it('sends the raw+json Accept header', async () => {
221
+ fetchMock.mockResolvedValueOnce(
222
+ mockResponse('content', 200, { text: true }),
223
+ );
224
+ const adapter = new GitHubAdapter(TOKEN, REPO);
225
+ await adapter.readFile('posts', 'test.md');
226
+ const headers = fetchMock.mock.calls[0][1].headers as Record<
227
+ string,
228
+ string
229
+ >;
230
+ expect(headers['Accept']).toBe('application/vnd.github.raw+json');
231
+ });
232
+
233
+ it('throws when the file is not found', async () => {
234
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
235
+ const adapter = new GitHubAdapter(TOKEN, REPO);
236
+ await expect(adapter.readFile('posts', 'missing.md')).rejects.toThrow(
237
+ 'Failed to read',
238
+ );
239
+ });
240
+ });
241
+
242
+ describe('writeFile', () => {
243
+ it('sends a PUT request without sha for a new file', async () => {
244
+ // GET returns 404 (file doesn't exist yet)
245
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
246
+ // PUT succeeds
247
+ fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
248
+
249
+ const adapter = new GitHubAdapter(TOKEN, REPO);
250
+ await adapter.writeFile('posts', 'new.md', '# New');
251
+
252
+ const putCall = fetchMock.mock.calls[1];
253
+ const putBody = JSON.parse(putCall[1].body as string);
254
+ expect(putBody.sha).toBeUndefined();
255
+ expect(putBody.message).toContain('new.md');
256
+ });
257
+
258
+ it('sends a PUT request with sha for an existing file', async () => {
259
+ // GET returns existing file with sha
260
+ fetchMock.mockResolvedValueOnce(
261
+ mockResponse({ sha: 'abc123', name: 'existing.md' }),
262
+ );
263
+ // PUT succeeds
264
+ fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
265
+
266
+ const adapter = new GitHubAdapter(TOKEN, REPO);
267
+ await adapter.writeFile('posts', 'existing.md', 'updated');
268
+
269
+ const putBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
270
+ expect(putBody.sha).toBe('abc123');
271
+ });
272
+
273
+ it('throws when the PUT request fails', async () => {
274
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
275
+ fetchMock.mockResolvedValueOnce(
276
+ mockResponse('error text', 422, { text: true }),
277
+ );
278
+
279
+ const adapter = new GitHubAdapter(TOKEN, REPO);
280
+ await expect(adapter.writeFile('posts', 'bad.md', 'x')).rejects.toThrow(
281
+ 'Failed to write',
282
+ );
283
+ });
284
+
285
+ it('base64-encodes the content', async () => {
286
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
287
+ fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
288
+
289
+ const adapter = new GitHubAdapter(TOKEN, REPO);
290
+ await adapter.writeFile('posts', 'check.md', 'hello');
291
+
292
+ const putBody = JSON.parse(fetchMock.mock.calls[1][1].body as string);
293
+ // 'hello' base64 encodes to 'aGVsbG8='
294
+ expect(putBody.content).toBe('aGVsbG8=');
295
+ });
296
+ });
297
+
298
+ describe('writeFiles', () => {
299
+ it('is a no-op for an empty array', async () => {
300
+ const adapter = new GitHubAdapter(TOKEN, REPO);
301
+ await expect(adapter.writeFiles([])).resolves.toBeUndefined();
302
+ expect(fetchMock).not.toHaveBeenCalled();
303
+ });
304
+
305
+ it('delegates to writeFile for a single-file array', async () => {
306
+ // GET (check sha) + PUT
307
+ fetchMock.mockResolvedValueOnce(mockResponse({}, 404));
308
+ fetchMock.mockResolvedValueOnce(mockResponse({ content: {} }));
309
+
310
+ const adapter = new GitHubAdapter(TOKEN, REPO);
311
+ await adapter.writeFiles([
312
+ { collection: 'posts', filename: 'one.md', content: 'body' },
313
+ ]);
314
+ // Two calls: GET for sha check, PUT for write
315
+ expect(fetchMock).toHaveBeenCalledTimes(2);
316
+ });
317
+
318
+ it('uses the Git Trees API for multiple files', async () => {
319
+ // GET /git/ref/heads/main
320
+ fetchMock.mockResolvedValueOnce(
321
+ mockResponse({ object: { sha: 'commit-sha' } }),
322
+ );
323
+ // GET /git/commits/commit-sha
324
+ fetchMock.mockResolvedValueOnce(
325
+ mockResponse({ tree: { sha: 'tree-sha' } }),
326
+ );
327
+ // POST /git/trees
328
+ fetchMock.mockResolvedValueOnce(mockResponse({ sha: 'new-tree-sha' }));
329
+ // POST /git/commits
330
+ fetchMock.mockResolvedValueOnce(mockResponse({ sha: 'new-commit-sha' }));
331
+ // PATCH /git/refs/heads/main
332
+ fetchMock.mockResolvedValueOnce(mockResponse({ ref: 'refs/heads/main' }));
333
+
334
+ const adapter = new GitHubAdapter(TOKEN, REPO);
335
+ await adapter.writeFiles([
336
+ { collection: 'posts', filename: 'a.md', content: 'A' },
337
+ { collection: 'posts', filename: 'b.md', content: 'B' },
338
+ ]);
339
+
340
+ // Should have made 5 API calls
341
+ expect(fetchMock).toHaveBeenCalledTimes(5);
342
+ // The tree POST should contain both file paths
343
+ const treeBody = JSON.parse(fetchMock.mock.calls[2][1].body as string);
344
+ const paths = treeBody.tree.map((t: { path: string }) => t.path);
345
+ expect(paths).toContain('src/content/posts/a.md');
346
+ expect(paths).toContain('src/content/posts/b.md');
347
+ });
348
+ });
349
+ });