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,166 @@
1
+ import { vi } from 'vitest';
2
+ import type {
3
+ FileEntry,
4
+ FileWrite,
5
+ StorageAdapter,
6
+ } from '../../../src/client/js/storage/adapter';
7
+
8
+ //////////////////////////////
9
+ // Sample content for pre-populated collections
10
+ //////////////////////////////
11
+
12
+ /** Sample posts used to pre-populate the mock adapter. */
13
+ const SAMPLE_POSTS: FileEntry[] = [
14
+ {
15
+ filename: 'hello-world.md',
16
+ content: [
17
+ '---',
18
+ 'title: Hello World',
19
+ 'published: 2024-01-15T10:00:00Z',
20
+ 'draft: false',
21
+ '---',
22
+ '',
23
+ 'Welcome to the blog. This is the first post.',
24
+ ].join('\n'),
25
+ },
26
+ {
27
+ filename: 'second-post.md',
28
+ content: [
29
+ '---',
30
+ 'title: Second Post',
31
+ 'published: 2024-02-20T14:30:00Z',
32
+ 'draft: false',
33
+ '---',
34
+ '',
35
+ 'This is the second post with more content.',
36
+ ].join('\n'),
37
+ },
38
+ {
39
+ filename: 'draft-ideas.md',
40
+ content: [
41
+ '---',
42
+ 'title: Draft Ideas',
43
+ 'published: 2024-03-01T09:00:00Z',
44
+ 'draft: true',
45
+ '---',
46
+ '',
47
+ 'Some ideas for future posts.',
48
+ ].join('\n'),
49
+ },
50
+ ];
51
+
52
+ /** Sample pages for a second collection. */
53
+ const SAMPLE_PAGES: FileEntry[] = [
54
+ {
55
+ filename: 'about.md',
56
+ content: ['---', 'title: About', '---', '', 'This is the about page.'].join(
57
+ '\n',
58
+ ),
59
+ },
60
+ ];
61
+
62
+ /**
63
+ * Creates an in-memory StorageAdapter backed by a Map. Pre-populated with
64
+ * sample "posts" and "pages" collections containing markdown files with
65
+ * YAML frontmatter.
66
+ * @return {{ adapter: StorageAdapter, store: Map<string, Map<string, string>> }} The adapter and its backing store for assertions
67
+ */
68
+ export function createMockAdapter(): {
69
+ adapter: StorageAdapter;
70
+ store: Map<string, Map<string, string>>;
71
+ } {
72
+ // Outer map: collection name -> inner map (filename -> content)
73
+ const store = new Map<string, Map<string, string>>();
74
+
75
+ // Pre-populate posts
76
+ const postsMap = new Map<string, string>();
77
+ for (const entry of SAMPLE_POSTS) {
78
+ postsMap.set(entry.filename, entry.content);
79
+ }
80
+ store.set('posts', postsMap);
81
+
82
+ // Pre-populate pages
83
+ const pagesMap = new Map<string, string>();
84
+ for (const entry of SAMPLE_PAGES) {
85
+ pagesMap.set(entry.filename, entry.content);
86
+ }
87
+ store.set('pages', pagesMap);
88
+
89
+ /**
90
+ * Gets or creates the inner map for a collection.
91
+ * @param {string} collection - The collection name
92
+ * @return {Map<string, string>} The file map for the collection
93
+ */
94
+ function getCollection(collection: string): Map<string, string> {
95
+ let col = store.get(collection);
96
+ if (!col) {
97
+ col = new Map<string, string>();
98
+ store.set(collection, col);
99
+ }
100
+ return col;
101
+ }
102
+
103
+ const adapter: StorageAdapter = {
104
+ listFiles: vi.fn(
105
+ async (
106
+ collection: string,
107
+ extensions: string[],
108
+ ): Promise<FileEntry[]> => {
109
+ const col = store.get(collection);
110
+ if (!col) return [];
111
+ return Array.from(col.entries())
112
+ .filter(([filename]) =>
113
+ extensions.some((ext) => filename.endsWith(ext)),
114
+ )
115
+ .map(([filename, content]) => ({
116
+ filename,
117
+ content,
118
+ }));
119
+ },
120
+ ),
121
+
122
+ readFile: vi.fn(
123
+ async (collection: string, filename: string): Promise<string> => {
124
+ const col = store.get(collection);
125
+ if (!col) throw new Error(`Collection "${collection}" not found`);
126
+ const content = col.get(filename);
127
+ if (content === undefined)
128
+ throw new Error(`File "${filename}" not found in "${collection}"`);
129
+ return content;
130
+ },
131
+ ),
132
+
133
+ writeFile: vi.fn(
134
+ async (
135
+ collection: string,
136
+ filename: string,
137
+ content: string,
138
+ ): Promise<void> => {
139
+ const col = getCollection(collection);
140
+ col.set(filename, content);
141
+ },
142
+ ),
143
+
144
+ writeFiles: vi.fn(async (files: FileWrite[]): Promise<void> => {
145
+ for (const file of files) {
146
+ const col = getCollection(file.collection);
147
+ col.set(file.filename, file.content);
148
+ }
149
+ }),
150
+
151
+ deleteFile: vi.fn(
152
+ async (collection: string, filename: string): Promise<void> => {
153
+ const col = store.get(collection);
154
+ if (!col) throw new Error(`Collection "${collection}" not found`);
155
+ if (!col.has(filename))
156
+ throw new Error(`File "${filename}" not found in "${collection}"`);
157
+ col.delete(filename);
158
+ },
159
+ ),
160
+ };
161
+
162
+ return { adapter, store };
163
+ }
164
+
165
+ /** Exported sample data for test assertions. */
166
+ export { SAMPLE_POSTS, SAMPLE_PAGES };
@@ -0,0 +1,155 @@
1
+ import type { Mock } from 'vitest';
2
+
3
+ //////////////////////////////
4
+ // Mock state type
5
+ //
6
+ // Each test file creates its own mocks via vi.hoisted(). This type
7
+ // describes the shape so helper functions can manipulate mock return
8
+ // values without being tightly coupled to any single test file.
9
+ // Mock names match the getter properties on the exported state objects
10
+ // (backend, content, nav, schema, drafts, editor).
11
+ //////////////////////////////
12
+
13
+ /**
14
+ * Shape of the mocks object created by vi.hoisted() in each test file.
15
+ * Each field is a Vitest Mock function controlling a reactive export.
16
+ */
17
+ export type E2EMocks = {
18
+ mockBackendReady: Mock<() => boolean>;
19
+ mockRoute: Mock;
20
+ mockCollections: Mock<() => string[]>;
21
+ mockContentList: Mock;
22
+ mockLoading: Mock<() => boolean>;
23
+ mockError: Mock<() => string | null>;
24
+ mockDrafts: Mock;
25
+ mockOutdatedMap: Mock<() => Record<string, boolean>>;
26
+ mockActiveTab: Mock<() => string>;
27
+ mockGetEditorFile: Mock;
28
+ mockSchema: Mock;
29
+ mockCollectionHasDates: Mock<() => boolean>;
30
+ mockComputePublishDisabled: Mock<() => boolean>;
31
+ };
32
+
33
+ /**
34
+ * Resets all mock return values to their disconnected defaults.
35
+ * Call in beforeEach() to avoid state leaking between tests.
36
+ * @param {E2EMocks} m - The mocks object to reset
37
+ * @return {void}
38
+ */
39
+ export function resetMocks(m: E2EMocks): void {
40
+ m.mockBackendReady.mockReturnValue(false);
41
+ m.mockRoute.mockReturnValue({ view: 'home' });
42
+ m.mockCollections.mockReturnValue([]);
43
+ m.mockContentList.mockReturnValue([]);
44
+ m.mockLoading.mockReturnValue(false);
45
+ m.mockError.mockReturnValue(null);
46
+ m.mockDrafts.mockReturnValue([]);
47
+ m.mockOutdatedMap.mockReturnValue({});
48
+ m.mockActiveTab.mockReturnValue('metadata');
49
+ m.mockGetEditorFile.mockReturnValue(null);
50
+ m.mockSchema.mockReturnValue(null);
51
+ m.mockCollectionHasDates.mockReturnValue(false);
52
+ m.mockComputePublishDisabled.mockReturnValue(false);
53
+ }
54
+
55
+ /**
56
+ * Configures mocks to show the backend as connected with collections visible.
57
+ * @param {E2EMocks} m - The mocks object
58
+ * @param {string[]} collections - Collection names to show
59
+ * @return {void}
60
+ */
61
+ export function configureConnected(
62
+ m: E2EMocks,
63
+ collections: string[] = ['pages', 'posts'],
64
+ ): void {
65
+ m.mockBackendReady.mockReturnValue(true);
66
+ m.mockCollections.mockReturnValue(collections);
67
+ }
68
+
69
+ /**
70
+ * Configures mocks to show a collection selected with content items.
71
+ * @param {E2EMocks} m - The mocks object
72
+ * @param {string} collection - The active collection name
73
+ * @param {Array<{ filename: string, data: Record<string, unknown> }>} items - Content items
74
+ * @return {void}
75
+ */
76
+ export function configureCollection(
77
+ m: E2EMocks,
78
+ collection: string,
79
+ items: Array<{ filename: string; data: Record<string, unknown> }> = [],
80
+ ): void {
81
+ configureConnected(m);
82
+ m.mockRoute.mockReturnValue({ view: 'collection', collection });
83
+ m.mockContentList.mockReturnValue(items);
84
+ }
85
+
86
+ /**
87
+ * Configures mocks to show a file open in the editor.
88
+ * @param {E2EMocks} m - The mocks object
89
+ * @param {string} collection - The active collection
90
+ * @param {string} slug - The file slug (filename without extension)
91
+ * @param {{ filename: string, body: string, formData: Record<string, unknown>, dirty?: boolean, draftId?: string | null, isNewDraft?: boolean }} file - Editor file state
92
+ * @return {void}
93
+ */
94
+ export function configureFileOpen(
95
+ m: E2EMocks,
96
+ collection: string,
97
+ slug: string,
98
+ file: {
99
+ filename: string;
100
+ body: string;
101
+ formData: Record<string, unknown>;
102
+ dirty?: boolean;
103
+ draftId?: string | null;
104
+ isNewDraft?: boolean;
105
+ },
106
+ ): void {
107
+ configureConnected(m);
108
+ m.mockRoute.mockReturnValue({ view: 'file', collection, slug });
109
+ m.mockContentList.mockReturnValue([
110
+ { filename: file.filename, data: file.formData },
111
+ ]);
112
+ m.mockGetEditorFile.mockReturnValue({
113
+ filename: file.filename,
114
+ body: file.body,
115
+ formData: file.formData,
116
+ dirty: file.dirty ?? false,
117
+ saving: false,
118
+ bodyLoaded: true,
119
+ draftId: file.draftId ?? null,
120
+ isNewDraft: file.isNewDraft ?? false,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Configures mocks to show a draft open in the editor.
126
+ * @param {E2EMocks} m - The mocks object
127
+ * @param {string} collection - The active collection
128
+ * @param {string} draftId - The draft UUID
129
+ * @param {{ body: string, formData: Record<string, unknown>, dirty?: boolean, filename?: string }} draft - Draft state
130
+ * @return {void}
131
+ */
132
+ export function configureDraftOpen(
133
+ m: E2EMocks,
134
+ collection: string,
135
+ draftId: string,
136
+ draft: {
137
+ body: string;
138
+ formData: Record<string, unknown>;
139
+ dirty?: boolean;
140
+ filename?: string;
141
+ },
142
+ ): void {
143
+ configureConnected(m);
144
+ m.mockRoute.mockReturnValue({ view: 'draft', collection, draftId });
145
+ m.mockGetEditorFile.mockReturnValue({
146
+ filename: draft.filename ?? '',
147
+ body: draft.body,
148
+ formData: draft.formData,
149
+ dirty: draft.dirty ?? false,
150
+ saving: false,
151
+ bodyLoaded: true,
152
+ draftId,
153
+ isNewDraft: true,
154
+ });
155
+ }
@@ -0,0 +1,358 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ import { render, cleanup } from '@testing-library/svelte';
3
+ import {
4
+ resetMocks,
5
+ configureConnected,
6
+ configureCollection,
7
+ configureFileOpen,
8
+ } from './helpers/test-app';
9
+
10
+ /*
11
+ //////////////////////////////
12
+ // Hoisted mocks
13
+ //////////////////////////////
14
+ */
15
+
16
+ const mocks = vi.hoisted(() => ({
17
+ mockBackendReady: vi.fn(() => false),
18
+ mockRoute: vi.fn(() => ({ view: 'home' as const })),
19
+ mockCollections: vi.fn(() => [] as string[]),
20
+ mockContentList: vi.fn(
21
+ () => [] as Array<{ filename: string; data: Record<string, unknown> }>,
22
+ ),
23
+ mockLoading: vi.fn(() => false),
24
+ mockError: vi.fn(() => null as string | null),
25
+ mockDrafts: vi.fn(() => []),
26
+ mockOutdatedMap: vi.fn(() => ({}) as Record<string, boolean>),
27
+ mockActiveTab: vi.fn(() => 'metadata'),
28
+ mockGetEditorFile: vi.fn(() => null),
29
+ mockSchema: vi.fn(() => null),
30
+ mockCollectionHasDates: vi.fn(() => false),
31
+ mockComputePublishDisabled: vi.fn(() => false),
32
+ }));
33
+
34
+ /*
35
+ //////////////////////////////
36
+ // Module mocks
37
+ //////////////////////////////
38
+ */
39
+
40
+ vi.mock('virtual:nebula/collections', () => ({
41
+ default: {
42
+ pages: '/collections/pages.schema.json',
43
+ posts: '/collections/posts.schema.json',
44
+ },
45
+ }));
46
+ vi.mock('virtual:nebula/config', () => ({
47
+ default: { basePath: '/admin', collectionsPath: '/collections' },
48
+ }));
49
+ vi.mock('../../src/client/js/state/state.svelte', () => ({
50
+ backend: {
51
+ get type() {
52
+ return null;
53
+ },
54
+ get ready() {
55
+ return mocks.mockBackendReady();
56
+ },
57
+ get permission() {
58
+ return 'denied';
59
+ },
60
+ },
61
+ content: {
62
+ get list() {
63
+ return mocks.mockContentList();
64
+ },
65
+ get loading() {
66
+ return mocks.mockLoading();
67
+ },
68
+ get error() {
69
+ return mocks.mockError();
70
+ },
71
+ },
72
+ get collections() {
73
+ return mocks.mockCollections();
74
+ },
75
+ storageClient: null,
76
+ drafts: {
77
+ get all() {
78
+ return mocks.mockDrafts();
79
+ },
80
+ get outdated() {
81
+ return mocks.mockOutdatedMap();
82
+ },
83
+ },
84
+ restoreBackend: vi.fn(async () => {}),
85
+ loadCollection: vi.fn(),
86
+ reloadCollection: vi.fn(),
87
+ disconnect: vi.fn(),
88
+ refreshDrafts: vi.fn(async () => {}),
89
+ updateContentItem: vi.fn(),
90
+ pickDirectory: vi.fn(),
91
+ requestPermission: vi.fn(),
92
+ connectGitHub: vi.fn(async () => {}),
93
+ }));
94
+ vi.mock('../../src/client/js/state/router.svelte', () => ({
95
+ initRouter: vi.fn(),
96
+ nav: {
97
+ get route() {
98
+ return mocks.mockRoute();
99
+ },
100
+ },
101
+ navigate: vi.fn(),
102
+ registerDirtyChecker: vi.fn(),
103
+ adminPath: vi.fn((...segments) =>
104
+ segments.length === 0 ? '/admin' : '/admin/' + segments.join('/'),
105
+ ),
106
+ }));
107
+ vi.mock('../../src/client/js/state/schema.svelte', () => ({
108
+ fetchSchema: vi.fn(async () => {}),
109
+ schema: {
110
+ get active() {
111
+ return mocks.mockSchema();
112
+ },
113
+ },
114
+ clearSchema: vi.fn(),
115
+ prefetchAllSchemas: vi.fn(async () => {}),
116
+ collectionHasDates: mocks.mockCollectionHasDates,
117
+ getCollectionTitle: vi.fn(() => null),
118
+ getCollectionDescription: vi.fn(() => null),
119
+ }));
120
+ vi.mock('../../src/client/js/editor/editor.svelte', () => ({
121
+ preloadFile: vi.fn(async () => {}),
122
+ loadFileBody: vi.fn(async () => {}),
123
+ clearEditor: vi.fn(),
124
+ editor: {
125
+ get tab() {
126
+ return mocks.mockActiveTab();
127
+ },
128
+ get data() {
129
+ return {};
130
+ },
131
+ get originalFilename() {
132
+ return '';
133
+ },
134
+ },
135
+ setActiveTab: vi.fn(),
136
+ getEditorFile: mocks.mockGetEditorFile,
137
+ loadDraftById: vi.fn(async () => {}),
138
+ setFilename: vi.fn(),
139
+ updateBody: vi.fn(),
140
+ updateFormField: vi.fn(),
141
+ saveDraftToIDB: vi.fn(async () => {}),
142
+ saveFile: vi.fn(async () => {}),
143
+ publishFile: vi.fn(async () => {}),
144
+ deleteCurrentDraft: vi.fn(async () => {}),
145
+ applyEditorState: vi.fn(),
146
+ _getDraftState: vi.fn(() => ({})),
147
+ _setDraftState: vi.fn(),
148
+ changeFileFormat: vi.fn(),
149
+ setDefaultFormat: vi.fn(),
150
+ }));
151
+ vi.mock('../../src/client/js/handlers/admin', async (importOriginal) => {
152
+ const actual =
153
+ await importOriginal<typeof import('../../src/client/js/handlers/admin')>();
154
+ return {
155
+ ...actual,
156
+ handleSave: vi.fn(async () => {}),
157
+ handlePublish: vi.fn(async () => ({ status: 'ok' })),
158
+ handleDeleteDraft: vi.fn(async () => {}),
159
+ handleFilenameConfirm: vi.fn(async () => {}),
160
+ computePublishDisabled: mocks.mockComputePublishDisabled,
161
+ // Override buildCollectionItems to read from mockCollections — the
162
+ // module-level getter for `collections` is not a live binding in
163
+ // vitest's browser mode, so the real function would see an empty array.
164
+ buildCollectionItems: () =>
165
+ mocks.mockCollections().map((name: string) => ({
166
+ label: name.charAt(0).toUpperCase() + name.slice(1),
167
+ href: '/admin/' + name,
168
+ })),
169
+ };
170
+ });
171
+ vi.mock('../../src/client/js/utils/sort', () => ({
172
+ toSortDate: vi.fn(() => undefined),
173
+ readSortMode: vi.fn(() => 'alpha'),
174
+ writeSortMode: vi.fn(),
175
+ createComparator: vi.fn(() => () => 0),
176
+ SORT_MODES: {
177
+ alpha: { icon: 'sort_by_alpha', label: 'Alphabetical' },
178
+ 'date-asc': { icon: 'hourglass_arrow_down', label: 'Oldest first' },
179
+ 'date-desc': { icon: 'hourglass_arrow_up', label: 'Newest first' },
180
+ },
181
+ SORT_ORDER: ['alpha', 'date-asc', 'date-desc'],
182
+ }));
183
+ vi.mock('../../src/client/js/drafts/storage', () => ({
184
+ saveDraft: vi.fn(async () => {}),
185
+ getDraftByFile: vi.fn(async () => null),
186
+ loadDrafts: vi.fn(async () => []),
187
+ loadDraft: vi.fn(async () => null),
188
+ deleteDraft: vi.fn(async () => {}),
189
+ }));
190
+ vi.mock('../../src/client/js/utils/schema-utils', () => ({
191
+ extractTabs: vi.fn(() => []),
192
+ getFieldsForTab: vi.fn(() => []),
193
+ resolveFieldType: vi.fn(() => ({ kind: 'string' })),
194
+ createDefaultValue: vi.fn(() => ''),
195
+ getByPath: vi.fn(),
196
+ setByPath: vi.fn(),
197
+ isReadOnly: vi.fn(() => false),
198
+ isNullable: vi.fn(() => false),
199
+ getProperties: vi.fn(
200
+ (schema: Record<string, unknown>) => schema['properties'],
201
+ ),
202
+ getRequiredFields: vi.fn((schema: Record<string, unknown>) =>
203
+ Array.isArray(schema['required']) ? schema['required'] : [],
204
+ ),
205
+ getLabel: vi.fn((schema: Record<string, unknown>, name: string) =>
206
+ typeof schema['title'] === 'string' ? schema['title'] : name,
207
+ ),
208
+ }));
209
+ vi.mock('../../src/client/js/drafts/merge.svelte', () => ({
210
+ drafts: {
211
+ get all() {
212
+ return mocks.mockDrafts();
213
+ },
214
+ get outdated() {
215
+ return mocks.mockOutdatedMap();
216
+ },
217
+ },
218
+ mergeDrafts: vi.fn(async () => {}),
219
+ refreshDrafts: vi.fn(async () => {}),
220
+ resetDraftMerge: vi.fn(),
221
+ }));
222
+
223
+ vi.mock('../../src/client/js/state/theme.svelte', () => ({
224
+ initTheme: vi.fn(() => () => {}),
225
+ cycleTheme: vi.fn(),
226
+ theme: { resolved: 'dark', icon: 'brightness_auto', label: 'Auto' },
227
+ }));
228
+ vi.mock('../../src/client/js/state/dialogs.svelte', () => ({
229
+ dialog: {
230
+ get active() {
231
+ return null;
232
+ },
233
+ open: vi.fn(),
234
+ close: vi.fn(),
235
+ },
236
+ }));
237
+
238
+ import Admin from '../../src/client/Admin.svelte';
239
+
240
+ afterEach(() => cleanup());
241
+ beforeEach(() => resetMocks(mocks));
242
+
243
+ describe('Navigation', () => {
244
+ it('shows both sidebars when a collection is selected', () => {
245
+ configureCollection(mocks, 'posts');
246
+
247
+ const { container } = render(Admin);
248
+
249
+ const sidebars = container.querySelectorAll('.sidebar');
250
+ expect(sidebars.length).toBe(2);
251
+ });
252
+
253
+ it('does not render editor area in collection view', () => {
254
+ configureCollection(mocks, 'posts');
255
+
256
+ const { container } = render(Admin);
257
+
258
+ expect(container.querySelector('.editor-area')).toBeNull();
259
+ });
260
+
261
+ it('shows content items in the collection sidebar', () => {
262
+ configureCollection(mocks, 'posts', [
263
+ { filename: 'hello-world.md', data: { title: 'Hello World' } },
264
+ { filename: 'second-post.md', data: { title: 'Second Post' } },
265
+ ]);
266
+
267
+ const { container } = render(Admin);
268
+
269
+ // Second sidebar has the content items
270
+ const sidebars = container.querySelectorAll('.sidebar');
271
+ const contentSidebar = sidebars[1];
272
+ const links = contentSidebar.querySelectorAll('.sidebar-link');
273
+ expect(links.length).toBe(2);
274
+ });
275
+
276
+ it('renders content item titles from frontmatter data', () => {
277
+ configureCollection(mocks, 'posts', [
278
+ { filename: 'hello.md', data: { title: 'Hello World' } },
279
+ { filename: 'bye.md', data: { title: 'Goodbye' } },
280
+ ]);
281
+
282
+ const { container } = render(Admin);
283
+
284
+ const sidebars = container.querySelectorAll('.sidebar');
285
+ const links = sidebars[1].querySelectorAll('.sidebar-link');
286
+ const labels = Array.from(links).map((el) =>
287
+ el.querySelector('.item-label-text')?.textContent?.trim(),
288
+ );
289
+ expect(labels).toContain('Hello World');
290
+ expect(labels).toContain('Goodbye');
291
+ });
292
+
293
+ it('falls back to filename when title is missing', () => {
294
+ configureCollection(mocks, 'posts', [
295
+ { filename: 'no-title.md', data: {} },
296
+ ]);
297
+
298
+ const { container } = render(Admin);
299
+
300
+ const sidebars = container.querySelectorAll('.sidebar');
301
+ const links = sidebars[1].querySelectorAll('.sidebar-link');
302
+ const label = links[0]
303
+ .querySelector('.item-label-text')
304
+ ?.textContent?.trim();
305
+ expect(label).toBe('no-title.md');
306
+ });
307
+
308
+ it('renders editor area when a file is open', () => {
309
+ configureFileOpen(mocks, 'posts', 'hello-world', {
310
+ filename: 'hello-world.md',
311
+ body: 'Hello content',
312
+ formData: { title: 'Hello World' },
313
+ });
314
+
315
+ const { container } = render(Admin);
316
+
317
+ expect(container.querySelector('.editor-area')).not.toBeNull();
318
+ });
319
+
320
+ it('applies admin--file-open class when editing a file', () => {
321
+ configureFileOpen(mocks, 'posts', 'hello-world', {
322
+ filename: 'hello-world.md',
323
+ body: 'Hello content',
324
+ formData: { title: 'Hello World' },
325
+ });
326
+
327
+ const { container } = render(Admin);
328
+
329
+ expect(container.querySelector('.admin--file-open')).not.toBeNull();
330
+ });
331
+
332
+ it('shows both sidebars and editor area in file view', () => {
333
+ configureFileOpen(mocks, 'posts', 'hello-world', {
334
+ filename: 'hello-world.md',
335
+ body: '',
336
+ formData: { title: 'Hello World' },
337
+ });
338
+
339
+ const { container } = render(Admin);
340
+
341
+ const sidebars = container.querySelectorAll('.sidebar');
342
+ expect(sidebars.length).toBe(2);
343
+ expect(container.querySelector('.editor-area')).not.toBeNull();
344
+ });
345
+
346
+ it('highlights active collection in the sidebar via aria-current', () => {
347
+ configureCollection(mocks, 'posts');
348
+ mocks.mockCollections.mockReturnValue(['pages', 'posts']);
349
+
350
+ const { container } = render(Admin);
351
+
352
+ // Active collection is marked with aria-current="page"
353
+ const activeLink = container.querySelector(
354
+ '.sidebar-link[aria-current="page"]',
355
+ );
356
+ expect(activeLink).not.toBeNull();
357
+ });
358
+ });