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,345 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ import { render, cleanup, fireEvent } from '@testing-library/svelte';
3
+ import {
4
+ resetMocks,
5
+ configureFileOpen,
6
+ configureDraftOpen,
7
+ } from './helpers/test-app';
8
+
9
+ /*
10
+ //////////////////////////////
11
+ // Hoisted mocks
12
+ //////////////////////////////
13
+ */
14
+
15
+ const mocks = vi.hoisted(() => ({
16
+ mockBackendReady: vi.fn(() => false),
17
+ mockRoute: vi.fn(() => ({ view: 'home' as const })),
18
+ mockCollections: vi.fn(() => [] as string[]),
19
+ mockContentList: vi.fn(
20
+ () => [] as Array<{ filename: string; data: Record<string, unknown> }>,
21
+ ),
22
+ mockLoading: vi.fn(() => false),
23
+ mockError: vi.fn(() => null as string | null),
24
+ mockDrafts: vi.fn(() => []),
25
+ mockOutdatedMap: vi.fn(() => ({}) as Record<string, boolean>),
26
+ mockActiveTab: vi.fn(() => 'metadata'),
27
+ mockGetEditorFile: vi.fn(() => null),
28
+ mockSchema: vi.fn(() => null),
29
+ mockCollectionHasDates: vi.fn(() => false),
30
+ mockComputePublishDisabled: vi.fn(() => false),
31
+ }));
32
+
33
+ // Handler mocks for publish assertions
34
+ const handlers = vi.hoisted(() => ({
35
+ mockHandlePublish: vi.fn(async () => ({ status: 'ok' as const })),
36
+ }));
37
+
38
+ // Tracks which dialog is active so dialogs.active reflects it
39
+ const dialogState = vi.hoisted(() => ({
40
+ active: null as string | null,
41
+ }));
42
+
43
+ /*
44
+ //////////////////////////////
45
+ // Module mocks
46
+ //////////////////////////////
47
+ */
48
+
49
+ vi.mock('virtual:nebula/collections', () => ({
50
+ default: {
51
+ pages: '/collections/pages.schema.json',
52
+ posts: '/collections/posts.schema.json',
53
+ },
54
+ }));
55
+ vi.mock('virtual:nebula/config', () => ({
56
+ default: { basePath: '/admin', collectionsPath: '/collections' },
57
+ }));
58
+ vi.mock('../../src/client/js/state/state.svelte', () => ({
59
+ backend: {
60
+ get type() {
61
+ return null;
62
+ },
63
+ get ready() {
64
+ return mocks.mockBackendReady();
65
+ },
66
+ get permission() {
67
+ return 'denied';
68
+ },
69
+ },
70
+ content: {
71
+ get list() {
72
+ return mocks.mockContentList();
73
+ },
74
+ get loading() {
75
+ return mocks.mockLoading();
76
+ },
77
+ get error() {
78
+ return mocks.mockError();
79
+ },
80
+ },
81
+ get collections() {
82
+ return mocks.mockCollections();
83
+ },
84
+ storageClient: null,
85
+ drafts: {
86
+ get all() {
87
+ return mocks.mockDrafts();
88
+ },
89
+ get outdated() {
90
+ return mocks.mockOutdatedMap();
91
+ },
92
+ },
93
+ restoreBackend: vi.fn(async () => {}),
94
+ loadCollection: vi.fn(),
95
+ reloadCollection: vi.fn(),
96
+ disconnect: vi.fn(),
97
+ refreshDrafts: vi.fn(async () => {}),
98
+ updateContentItem: vi.fn(),
99
+ pickDirectory: vi.fn(),
100
+ requestPermission: vi.fn(),
101
+ connectGitHub: vi.fn(async () => {}),
102
+ }));
103
+ vi.mock('../../src/client/js/state/router.svelte', () => ({
104
+ initRouter: vi.fn(),
105
+ nav: {
106
+ get route() {
107
+ return mocks.mockRoute();
108
+ },
109
+ },
110
+ navigate: vi.fn(),
111
+ registerDirtyChecker: vi.fn(),
112
+ adminPath: vi.fn((...segments) =>
113
+ segments.length === 0 ? '/admin' : '/admin/' + segments.join('/'),
114
+ ),
115
+ }));
116
+ vi.mock('../../src/client/js/state/schema.svelte', () => ({
117
+ fetchSchema: vi.fn(async () => {}),
118
+ schema: {
119
+ get active() {
120
+ return mocks.mockSchema();
121
+ },
122
+ },
123
+ clearSchema: vi.fn(),
124
+ prefetchAllSchemas: vi.fn(async () => {}),
125
+ collectionHasDates: mocks.mockCollectionHasDates,
126
+ getCollectionTitle: vi.fn(() => null),
127
+ getCollectionDescription: vi.fn(() => null),
128
+ }));
129
+ vi.mock('../../src/client/js/editor/editor.svelte', () => ({
130
+ preloadFile: vi.fn(async () => {}),
131
+ loadFileBody: vi.fn(async () => {}),
132
+ clearEditor: vi.fn(),
133
+ editor: {
134
+ get tab() {
135
+ return mocks.mockActiveTab();
136
+ },
137
+ get data() {
138
+ return {};
139
+ },
140
+ get originalFilename() {
141
+ return '';
142
+ },
143
+ },
144
+ setActiveTab: vi.fn(),
145
+ getEditorFile: mocks.mockGetEditorFile,
146
+ loadDraftById: vi.fn(async () => {}),
147
+ setFilename: vi.fn(),
148
+ updateBody: vi.fn(),
149
+ updateFormField: vi.fn(),
150
+ saveDraftToIDB: vi.fn(async () => {}),
151
+ saveFile: vi.fn(async () => {}),
152
+ publishFile: vi.fn(async () => {}),
153
+ deleteCurrentDraft: vi.fn(async () => {}),
154
+ applyEditorState: vi.fn(),
155
+ _getDraftState: vi.fn(() => ({})),
156
+ _setDraftState: vi.fn(),
157
+ changeFileFormat: vi.fn(),
158
+ setDefaultFormat: vi.fn(),
159
+ }));
160
+ vi.mock('../../src/client/js/handlers/admin', async (importOriginal) => {
161
+ const actual =
162
+ await importOriginal<typeof import('../../src/client/js/handlers/admin')>();
163
+ return {
164
+ ...actual,
165
+ handleSave: vi.fn(async () => {}),
166
+ handlePublish: handlers.mockHandlePublish,
167
+ handleDeleteDraft: vi.fn(async () => {}),
168
+ handleFilenameConfirm: vi.fn(async () => {}),
169
+ computePublishDisabled: mocks.mockComputePublishDisabled,
170
+ // Override buildCollectionItems to read from mockCollections — the
171
+ // module-level getter for `collections` is not a live binding in
172
+ // vitest's browser mode, so the real function would see an empty array.
173
+ buildCollectionItems: () =>
174
+ mocks.mockCollections().map((name: string) => ({
175
+ label: name.charAt(0).toUpperCase() + name.slice(1),
176
+ href: '/admin/' + name,
177
+ })),
178
+ };
179
+ });
180
+ vi.mock('../../src/client/js/utils/sort', () => ({
181
+ toSortDate: vi.fn(() => undefined),
182
+ readSortMode: vi.fn(() => 'alpha'),
183
+ writeSortMode: vi.fn(),
184
+ createComparator: vi.fn(() => () => 0),
185
+ SORT_MODES: {
186
+ alpha: { icon: 'sort_by_alpha', label: 'Alphabetical' },
187
+ 'date-asc': { icon: 'hourglass_arrow_down', label: 'Oldest first' },
188
+ 'date-desc': { icon: 'hourglass_arrow_up', label: 'Newest first' },
189
+ },
190
+ SORT_ORDER: ['alpha', 'date-asc', 'date-desc'],
191
+ }));
192
+ vi.mock('../../src/client/js/drafts/storage', () => ({
193
+ saveDraft: vi.fn(async () => {}),
194
+ getDraftByFile: vi.fn(async () => null),
195
+ loadDrafts: vi.fn(async () => []),
196
+ loadDraft: vi.fn(async () => null),
197
+ deleteDraft: vi.fn(async () => {}),
198
+ }));
199
+ vi.mock('../../src/client/js/utils/schema-utils', () => ({
200
+ extractTabs: vi.fn(() => []),
201
+ getFieldsForTab: vi.fn(() => []),
202
+ resolveFieldType: vi.fn(() => ({ kind: 'string' })),
203
+ createDefaultValue: vi.fn(() => ''),
204
+ getByPath: vi.fn(),
205
+ setByPath: vi.fn(),
206
+ isReadOnly: vi.fn(() => false),
207
+ isNullable: vi.fn(() => false),
208
+ getProperties: vi.fn(
209
+ (schema: Record<string, unknown>) => schema['properties'],
210
+ ),
211
+ getRequiredFields: vi.fn((schema: Record<string, unknown>) =>
212
+ Array.isArray(schema['required']) ? schema['required'] : [],
213
+ ),
214
+ getLabel: vi.fn((schema: Record<string, unknown>, name: string) =>
215
+ typeof schema['title'] === 'string' ? schema['title'] : name,
216
+ ),
217
+ }));
218
+ vi.mock('../../src/client/js/drafts/merge.svelte', () => ({
219
+ drafts: {
220
+ get all() {
221
+ return mocks.mockDrafts();
222
+ },
223
+ get outdated() {
224
+ return mocks.mockOutdatedMap();
225
+ },
226
+ },
227
+ mergeDrafts: vi.fn(async () => {}),
228
+ refreshDrafts: vi.fn(async () => {}),
229
+ resetDraftMerge: vi.fn(),
230
+ }));
231
+
232
+ vi.mock('../../src/client/js/state/theme.svelte', () => ({
233
+ initTheme: vi.fn(() => () => {}),
234
+ cycleTheme: vi.fn(),
235
+ theme: { resolved: 'dark', icon: 'brightness_auto', label: 'Auto' },
236
+ }));
237
+ vi.mock('../../src/client/js/state/dialogs.svelte', () => ({
238
+ dialog: {
239
+ get active() {
240
+ return dialogState.active;
241
+ },
242
+ open: vi.fn((type: string) => {
243
+ dialogState.active = type;
244
+ }),
245
+ close: vi.fn(() => {
246
+ dialogState.active = null;
247
+ }),
248
+ },
249
+ }));
250
+
251
+ import Admin from '../../src/client/Admin.svelte';
252
+ import { dialog } from '../../src/client/js/state/dialogs.svelte';
253
+
254
+ afterEach(() => cleanup());
255
+ beforeEach(() => {
256
+ resetMocks(mocks);
257
+ handlers.mockHandlePublish.mockClear();
258
+ handlers.mockHandlePublish.mockResolvedValue({ status: 'ok' });
259
+ dialogState.active = null;
260
+ });
261
+
262
+ describe('Publishing', () => {
263
+ it('calls handlePublish when publish button is clicked', async () => {
264
+ configureFileOpen(mocks, 'posts', 'hello-world', {
265
+ filename: 'hello-world.md',
266
+ body: 'Content',
267
+ formData: { title: 'Hello World' },
268
+ });
269
+
270
+ const { container } = render(Admin);
271
+
272
+ const publishBtn = container.querySelector('.editor-area .btn--primary');
273
+ expect(publishBtn).not.toBeNull();
274
+
275
+ if (publishBtn) await fireEvent.click(publishBtn);
276
+ expect(handlers.mockHandlePublish).toHaveBeenCalled();
277
+ });
278
+
279
+ it('shows filename dialog when publish returns needs-filename', async () => {
280
+ configureDraftOpen(mocks, 'posts', 'draft-abc', {
281
+ body: 'New content',
282
+ formData: { title: 'New Post' },
283
+ });
284
+ handlers.mockHandlePublish.mockResolvedValue({ status: 'needs-filename' });
285
+
286
+ const { container } = render(Admin);
287
+
288
+ const publishBtn = container.querySelector('.editor-area .btn--primary');
289
+
290
+ if (publishBtn) await fireEvent.click(publishBtn);
291
+
292
+ // EditorToolbar calls showFilenameDialog when handlePublish returns 'needs-filename'
293
+ expect(dialog.open).toHaveBeenCalledWith('filename');
294
+ });
295
+
296
+ it('disables publish button when computePublishDisabled returns true', () => {
297
+ configureFileOpen(mocks, 'posts', 'hello-world', {
298
+ filename: 'hello-world.md',
299
+ body: 'Content',
300
+ formData: { title: 'Hello World' },
301
+ });
302
+ mocks.mockComputePublishDisabled.mockReturnValue(true);
303
+
304
+ const { container } = render(Admin);
305
+
306
+ const publishBtn = container.querySelector(
307
+ '.editor-area .btn--primary',
308
+ ) as HTMLButtonElement;
309
+ expect(publishBtn).not.toBeNull();
310
+ expect(publishBtn?.disabled).toBe(true);
311
+ });
312
+
313
+ it('enables publish button when computePublishDisabled returns false', () => {
314
+ configureFileOpen(mocks, 'posts', 'hello-world', {
315
+ filename: 'hello-world.md',
316
+ body: 'Content',
317
+ formData: { title: 'Hello World' },
318
+ });
319
+ mocks.mockComputePublishDisabled.mockReturnValue(false);
320
+
321
+ const { container } = render(Admin);
322
+
323
+ const publishBtn = container.querySelector(
324
+ '.editor-area .btn--primary',
325
+ ) as HTMLButtonElement;
326
+ expect(publishBtn?.disabled).toBe(false);
327
+ });
328
+
329
+ it('shows delete draft button when editing a draft', () => {
330
+ configureFileOpen(mocks, 'posts', 'hello-world', {
331
+ filename: 'hello-world.md',
332
+ body: 'Content',
333
+ formData: { title: 'Hello World' },
334
+ draftId: 'draft-1',
335
+ });
336
+
337
+ const { container } = render(Admin);
338
+
339
+ const deleteBtn = container.querySelector(
340
+ '.editor-area .btn--danger-outline',
341
+ );
342
+ expect(deleteBtn).not.toBeNull();
343
+ expect(deleteBtn?.textContent?.trim()).toContain('Delete Draft');
344
+ });
345
+ });
@@ -0,0 +1,317 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ import { render, cleanup } from '@testing-library/svelte';
3
+ import { resetMocks, configureFileOpen } from './helpers/test-app';
4
+
5
+ /*
6
+ //////////////////////////////
7
+ // Hoisted mocks
8
+ //////////////////////////////
9
+ */
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ mockBackendReady: vi.fn(() => false),
13
+ mockRoute: vi.fn(() => ({ view: 'home' as const })),
14
+ mockCollections: vi.fn(() => [] as string[]),
15
+ mockContentList: vi.fn(
16
+ () => [] as Array<{ filename: string; data: Record<string, unknown> }>,
17
+ ),
18
+ mockLoading: vi.fn(() => false),
19
+ mockError: vi.fn(() => null as string | null),
20
+ mockDrafts: vi.fn(() => []),
21
+ mockOutdatedMap: vi.fn(() => ({}) as Record<string, boolean>),
22
+ mockActiveTab: vi.fn(() => 'metadata'),
23
+ mockGetEditorFile: vi.fn(() => null),
24
+ mockSchema: vi.fn(() => null),
25
+ mockCollectionHasDates: vi.fn(() => false),
26
+ mockComputePublishDisabled: vi.fn(() => false),
27
+ }));
28
+
29
+ /*
30
+ //////////////////////////////
31
+ // Module mocks
32
+ //////////////////////////////
33
+ */
34
+
35
+ vi.mock('virtual:nebula/collections', () => ({
36
+ default: {
37
+ pages: '/collections/pages.schema.json',
38
+ posts: '/collections/posts.schema.json',
39
+ },
40
+ }));
41
+ vi.mock('virtual:nebula/config', () => ({
42
+ default: { basePath: '/admin', collectionsPath: '/collections' },
43
+ }));
44
+ vi.mock('../../src/client/js/state/state.svelte', () => ({
45
+ backend: {
46
+ get type() {
47
+ return null;
48
+ },
49
+ get ready() {
50
+ return mocks.mockBackendReady();
51
+ },
52
+ get permission() {
53
+ return 'denied';
54
+ },
55
+ },
56
+ content: {
57
+ get list() {
58
+ return mocks.mockContentList();
59
+ },
60
+ get loading() {
61
+ return mocks.mockLoading();
62
+ },
63
+ get error() {
64
+ return mocks.mockError();
65
+ },
66
+ },
67
+ get collections() {
68
+ return mocks.mockCollections();
69
+ },
70
+ storageClient: null,
71
+ drafts: {
72
+ get all() {
73
+ return mocks.mockDrafts();
74
+ },
75
+ get outdated() {
76
+ return mocks.mockOutdatedMap();
77
+ },
78
+ },
79
+ restoreBackend: vi.fn(async () => {}),
80
+ loadCollection: vi.fn(),
81
+ reloadCollection: vi.fn(),
82
+ disconnect: vi.fn(),
83
+ refreshDrafts: vi.fn(async () => {}),
84
+ updateContentItem: vi.fn(),
85
+ pickDirectory: vi.fn(),
86
+ requestPermission: vi.fn(),
87
+ connectGitHub: vi.fn(async () => {}),
88
+ }));
89
+ vi.mock('../../src/client/js/state/router.svelte', () => ({
90
+ initRouter: vi.fn(),
91
+ nav: {
92
+ get route() {
93
+ return mocks.mockRoute();
94
+ },
95
+ },
96
+ navigate: vi.fn(),
97
+ registerDirtyChecker: vi.fn(),
98
+ adminPath: vi.fn((...segments) =>
99
+ segments.length === 0 ? '/admin' : '/admin/' + segments.join('/'),
100
+ ),
101
+ }));
102
+ vi.mock('../../src/client/js/state/schema.svelte', () => ({
103
+ fetchSchema: vi.fn(async () => {}),
104
+ schema: {
105
+ get active() {
106
+ return mocks.mockSchema();
107
+ },
108
+ },
109
+ clearSchema: vi.fn(),
110
+ prefetchAllSchemas: vi.fn(async () => {}),
111
+ collectionHasDates: mocks.mockCollectionHasDates,
112
+ getCollectionTitle: vi.fn(() => null),
113
+ getCollectionDescription: vi.fn(() => null),
114
+ }));
115
+ vi.mock('../../src/client/js/editor/editor.svelte', () => ({
116
+ preloadFile: vi.fn(async () => {}),
117
+ loadFileBody: vi.fn(async () => {}),
118
+ clearEditor: vi.fn(),
119
+ editor: {
120
+ get tab() {
121
+ return mocks.mockActiveTab();
122
+ },
123
+ get data() {
124
+ return {};
125
+ },
126
+ get originalFilename() {
127
+ return '';
128
+ },
129
+ },
130
+ setActiveTab: vi.fn(),
131
+ getEditorFile: mocks.mockGetEditorFile,
132
+ loadDraftById: vi.fn(async () => {}),
133
+ setFilename: vi.fn(),
134
+ updateBody: vi.fn(),
135
+ updateFormField: vi.fn(),
136
+ saveDraftToIDB: vi.fn(async () => {}),
137
+ saveFile: vi.fn(async () => {}),
138
+ publishFile: vi.fn(async () => {}),
139
+ deleteCurrentDraft: vi.fn(async () => {}),
140
+ applyEditorState: vi.fn(),
141
+ _getDraftState: vi.fn(() => ({})),
142
+ _setDraftState: vi.fn(),
143
+ changeFileFormat: vi.fn(),
144
+ setDefaultFormat: vi.fn(),
145
+ }));
146
+ vi.mock('../../src/client/js/handlers/admin', async (importOriginal) => {
147
+ const actual =
148
+ await importOriginal<typeof import('../../src/client/js/handlers/admin')>();
149
+ return {
150
+ ...actual,
151
+ handleSave: vi.fn(async () => {}),
152
+ handlePublish: vi.fn(async () => ({ status: 'ok' })),
153
+ handleDeleteDraft: vi.fn(async () => {}),
154
+ handleFilenameConfirm: vi.fn(async () => {}),
155
+ computePublishDisabled: mocks.mockComputePublishDisabled,
156
+ // Override buildCollectionItems to read from mockCollections — the
157
+ // module-level getter for `collections` is not a live binding in
158
+ // vitest's browser mode, so the real function would see an empty array.
159
+ buildCollectionItems: () =>
160
+ mocks.mockCollections().map((name: string) => ({
161
+ label: name.charAt(0).toUpperCase() + name.slice(1),
162
+ href: '/admin/' + name,
163
+ })),
164
+ };
165
+ });
166
+ vi.mock('../../src/client/js/utils/sort', () => ({
167
+ toSortDate: vi.fn(() => undefined),
168
+ readSortMode: vi.fn(() => 'alpha'),
169
+ writeSortMode: vi.fn(),
170
+ createComparator: vi.fn(() => () => 0),
171
+ SORT_MODES: {
172
+ alpha: { icon: 'sort_by_alpha', label: 'Alphabetical' },
173
+ 'date-asc': { icon: 'hourglass_arrow_down', label: 'Oldest first' },
174
+ 'date-desc': { icon: 'hourglass_arrow_up', label: 'Newest first' },
175
+ },
176
+ SORT_ORDER: ['alpha', 'date-asc', 'date-desc'],
177
+ }));
178
+ vi.mock('../../src/client/js/drafts/storage', () => ({
179
+ saveDraft: vi.fn(async () => {}),
180
+ getDraftByFile: vi.fn(async () => null),
181
+ loadDrafts: vi.fn(async () => []),
182
+ loadDraft: vi.fn(async () => null),
183
+ deleteDraft: vi.fn(async () => {}),
184
+ }));
185
+ vi.mock('../../src/client/js/utils/schema-utils', () => ({
186
+ extractTabs: vi.fn(() => []),
187
+ getFieldsForTab: vi.fn(() => []),
188
+ resolveFieldType: vi.fn(() => ({ kind: 'string' })),
189
+ createDefaultValue: vi.fn(() => ''),
190
+ getByPath: vi.fn(),
191
+ setByPath: vi.fn(),
192
+ isReadOnly: vi.fn(() => false),
193
+ isNullable: vi.fn(() => false),
194
+ getProperties: vi.fn(
195
+ (schema: Record<string, unknown>) => schema['properties'],
196
+ ),
197
+ getRequiredFields: vi.fn((schema: Record<string, unknown>) =>
198
+ Array.isArray(schema['required']) ? schema['required'] : [],
199
+ ),
200
+ getLabel: vi.fn((schema: Record<string, unknown>, name: string) =>
201
+ typeof schema['title'] === 'string' ? schema['title'] : name,
202
+ ),
203
+ }));
204
+ vi.mock('../../src/client/js/drafts/merge.svelte', () => ({
205
+ drafts: {
206
+ get all() {
207
+ return mocks.mockDrafts();
208
+ },
209
+ get outdated() {
210
+ return mocks.mockOutdatedMap();
211
+ },
212
+ },
213
+ mergeDrafts: vi.fn(async () => {}),
214
+ refreshDrafts: vi.fn(async () => {}),
215
+ resetDraftMerge: vi.fn(),
216
+ }));
217
+
218
+ vi.mock('../../src/client/js/state/theme.svelte', () => ({
219
+ initTheme: vi.fn(() => () => {}),
220
+ cycleTheme: vi.fn(),
221
+ theme: { resolved: 'dark', icon: 'brightness_auto', label: 'Auto' },
222
+ }));
223
+ vi.mock('../../src/client/js/state/dialogs.svelte', () => ({
224
+ dialog: {
225
+ get active() {
226
+ return null;
227
+ },
228
+ open: vi.fn(),
229
+ close: vi.fn(),
230
+ },
231
+ }));
232
+
233
+ import Admin from '../../src/client/Admin.svelte';
234
+
235
+ afterEach(() => cleanup());
236
+ beforeEach(() => resetMocks(mocks));
237
+
238
+ describe('Unsaved Changes', () => {
239
+ it('shows dirty indicator for a file with unsaved changes', () => {
240
+ configureFileOpen(mocks, 'posts', 'hello-world', {
241
+ filename: 'hello-world.md',
242
+ body: 'Modified body',
243
+ formData: { title: 'Hello World' },
244
+ dirty: true,
245
+ });
246
+
247
+ const { container } = render(Admin);
248
+
249
+ // The bullet indicator gets the --visible class when dirty
250
+ const indicator = container.querySelector('.dirty-indicator--visible');
251
+ expect(indicator).not.toBeNull();
252
+ });
253
+
254
+ it('does not show dirty indicator for a clean file', () => {
255
+ configureFileOpen(mocks, 'posts', 'hello-world', {
256
+ filename: 'hello-world.md',
257
+ body: 'Original body',
258
+ formData: { title: 'Hello World' },
259
+ dirty: false,
260
+ });
261
+
262
+ const { container } = render(Admin);
263
+
264
+ // Indicator element exists but without --visible class
265
+ const indicator = container.querySelector('.dirty-indicator--visible');
266
+ expect(indicator).toBeNull();
267
+ });
268
+
269
+ it('shows dirty indicator for a dirty draft', () => {
270
+ configureFileOpen(mocks, 'posts', 'hello-world', {
271
+ filename: 'hello-world.md',
272
+ body: 'Changed',
273
+ formData: { title: 'Hello World' },
274
+ dirty: true,
275
+ draftId: 'draft-1',
276
+ });
277
+
278
+ const { container } = render(Admin);
279
+
280
+ const indicator = container.querySelector('.dirty-indicator--visible');
281
+ expect(indicator).not.toBeNull();
282
+ });
283
+
284
+ it('renders editor area even when dirty state is true', () => {
285
+ configureFileOpen(mocks, 'posts', 'hello-world', {
286
+ filename: 'hello-world.md',
287
+ body: 'Some content',
288
+ formData: { title: 'Hello World' },
289
+ dirty: true,
290
+ });
291
+
292
+ const { container } = render(Admin);
293
+
294
+ expect(container.querySelector('.editor-area')).not.toBeNull();
295
+ // Toolbar is inside editor-area with class .toolbar (not .editor-toolbar)
296
+ expect(
297
+ container.querySelector('.editor-area .toolbar__title'),
298
+ ).not.toBeNull();
299
+ });
300
+
301
+ it('maintains three-column layout when dirty and file open', () => {
302
+ configureFileOpen(mocks, 'posts', 'hello-world', {
303
+ filename: 'hello-world.md',
304
+ body: 'Content',
305
+ formData: { title: 'Hello World' },
306
+ dirty: true,
307
+ });
308
+
309
+ const { container } = render(Admin);
310
+
311
+ // Both sidebars + editor area = 3 columns
312
+ const sidebars = container.querySelectorAll('.sidebar');
313
+ expect(sidebars.length).toBe(2);
314
+ expect(container.querySelector('.editor-area')).not.toBeNull();
315
+ expect(container.querySelector('.admin--file-open')).not.toBeNull();
316
+ });
317
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Polyfills IndexedDB in Node.js for unit tests that exercise storage layer code.
2
+ import 'fake-indexeddb/auto';