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,355 @@
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('Editing', () => {
239
+ it('renders the editor toolbar with file title', () => {
240
+ configureFileOpen(mocks, 'posts', 'hello-world', {
241
+ filename: 'hello-world.md',
242
+ body: 'Some content',
243
+ formData: { title: 'Hello World' },
244
+ });
245
+
246
+ const { container } = render(Admin);
247
+
248
+ // EditorToolbar renders a .toolbar__title inside .editor-area
249
+ const title = container.querySelector('.editor-area .toolbar__title');
250
+ expect(title).not.toBeNull();
251
+ expect(title?.textContent).toContain('Hello World');
252
+ });
253
+
254
+ it('shows save and publish buttons in the editor toolbar', () => {
255
+ configureFileOpen(mocks, 'posts', 'hello-world', {
256
+ filename: 'hello-world.md',
257
+ body: 'Content here',
258
+ formData: { title: 'Hello World' },
259
+ });
260
+
261
+ const { container } = render(Admin);
262
+
263
+ const saveBtn = container.querySelector('.editor-area .btn--save-outline');
264
+ const publishBtn = container.querySelector('.editor-area .btn--primary');
265
+ expect(saveBtn).not.toBeNull();
266
+ expect(publishBtn).not.toBeNull();
267
+ });
268
+
269
+ it('shows dirty indicator when file has unsaved changes', () => {
270
+ configureFileOpen(mocks, 'posts', 'hello-world', {
271
+ filename: 'hello-world.md',
272
+ body: 'Modified content',
273
+ formData: { title: 'Hello World' },
274
+ dirty: true,
275
+ });
276
+
277
+ const { container } = render(Admin);
278
+
279
+ // The bullet indicator gets the --visible class when dirty
280
+ const indicator = container.querySelector('.dirty-indicator--visible');
281
+ expect(indicator).not.toBeNull();
282
+ });
283
+
284
+ it('does not show dirty indicator for clean files', () => {
285
+ configureFileOpen(mocks, 'posts', 'hello-world', {
286
+ filename: 'hello-world.md',
287
+ body: 'Content here',
288
+ formData: { title: 'Hello World' },
289
+ dirty: false,
290
+ });
291
+
292
+ const { container } = render(Admin);
293
+
294
+ // The indicator element exists but without --visible class
295
+ const indicator = container.querySelector('.dirty-indicator--visible');
296
+ expect(indicator).toBeNull();
297
+ });
298
+
299
+ it('renders editor tabs including metadata and body', () => {
300
+ configureFileOpen(mocks, 'posts', 'hello-world', {
301
+ filename: 'hello-world.md',
302
+ body: 'Content',
303
+ formData: { title: 'Hello World' },
304
+ });
305
+
306
+ const { container } = render(Admin);
307
+
308
+ const tabs = container.querySelectorAll('.tabs__tab');
309
+ const tabLabels = Array.from(tabs).map((t) => t.textContent?.trim());
310
+ expect(tabLabels).toContain('Metadata');
311
+ expect(tabLabels).toContain('Body');
312
+ });
313
+
314
+ it('shows metadata tab as active by default', () => {
315
+ configureFileOpen(mocks, 'posts', 'hello-world', {
316
+ filename: 'hello-world.md',
317
+ body: 'Content',
318
+ formData: { title: 'Hello World' },
319
+ });
320
+
321
+ const { container } = render(Admin);
322
+
323
+ const activeTab = container.querySelector('.tabs__tab--active');
324
+ expect(activeTab?.textContent?.trim()).toBe('Metadata');
325
+ });
326
+
327
+ it('renders editor pane when body tab is active', () => {
328
+ configureFileOpen(mocks, 'posts', 'hello-world', {
329
+ filename: 'hello-world.md',
330
+ body: 'Content',
331
+ formData: { title: 'Hello World' },
332
+ });
333
+ mocks.mockActiveTab.mockReturnValue('body');
334
+
335
+ const { container } = render(Admin);
336
+
337
+ expect(container.querySelector('.editor-wrapper')).not.toBeNull();
338
+ });
339
+
340
+ it('falls back to "Untitled Draft" when title is missing', () => {
341
+ configureFileOpen(mocks, 'posts', 'new-draft', {
342
+ filename: '',
343
+ body: '',
344
+ formData: {},
345
+ isNewDraft: true,
346
+ draftId: 'abc-123',
347
+ });
348
+
349
+ const { container } = render(Admin);
350
+
351
+ const title = container.querySelector('.editor-area .toolbar__title');
352
+ expect(title).not.toBeNull();
353
+ expect(title?.textContent).toContain('Untitled Draft');
354
+ });
355
+ });
@@ -0,0 +1,330 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
2
+ import { render, cleanup, fireEvent } from '@testing-library/svelte';
3
+ import {
4
+ resetMocks,
5
+ configureConnected,
6
+ configureCollection,
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
+ // State module handler mocks for GitHub-specific flows
34
+ const stateMocks = vi.hoisted(() => ({
35
+ mockConnectGitHub: vi.fn(async () => {}),
36
+ mockBackendType: vi.fn(() => null as string | null),
37
+ }));
38
+
39
+ /*
40
+ //////////////////////////////
41
+ // Module mocks
42
+ //////////////////////////////
43
+ */
44
+
45
+ vi.mock('virtual:nebula/collections', () => ({
46
+ default: {
47
+ pages: '/collections/pages.schema.json',
48
+ posts: '/collections/posts.schema.json',
49
+ },
50
+ }));
51
+ vi.mock('virtual:nebula/config', () => ({
52
+ default: { basePath: '/admin', collectionsPath: '/collections' },
53
+ }));
54
+ vi.mock('../../src/client/js/state/state.svelte', () => ({
55
+ backend: {
56
+ get type() {
57
+ return stateMocks.mockBackendType();
58
+ },
59
+ get ready() {
60
+ return mocks.mockBackendReady();
61
+ },
62
+ get permission() {
63
+ return 'denied';
64
+ },
65
+ },
66
+ content: {
67
+ get list() {
68
+ return mocks.mockContentList();
69
+ },
70
+ get loading() {
71
+ return mocks.mockLoading();
72
+ },
73
+ get error() {
74
+ return mocks.mockError();
75
+ },
76
+ },
77
+ get collections() {
78
+ return mocks.mockCollections();
79
+ },
80
+ storageClient: null,
81
+ drafts: {
82
+ get all() {
83
+ return mocks.mockDrafts();
84
+ },
85
+ get outdated() {
86
+ return mocks.mockOutdatedMap();
87
+ },
88
+ },
89
+ restoreBackend: vi.fn(async () => {}),
90
+ loadCollection: vi.fn(),
91
+ reloadCollection: vi.fn(),
92
+ disconnect: vi.fn(),
93
+ refreshDrafts: vi.fn(async () => {}),
94
+ updateContentItem: vi.fn(),
95
+ pickDirectory: vi.fn(),
96
+ requestPermission: vi.fn(),
97
+ connectGitHub: stateMocks.mockConnectGitHub,
98
+ }));
99
+ vi.mock('../../src/client/js/state/router.svelte', () => ({
100
+ initRouter: vi.fn(),
101
+ nav: {
102
+ get route() {
103
+ return mocks.mockRoute();
104
+ },
105
+ },
106
+ navigate: vi.fn(),
107
+ registerDirtyChecker: vi.fn(),
108
+ adminPath: vi.fn((...segments) =>
109
+ segments.length === 0 ? '/admin' : '/admin/' + segments.join('/'),
110
+ ),
111
+ }));
112
+ vi.mock('../../src/client/js/state/schema.svelte', () => ({
113
+ fetchSchema: vi.fn(async () => {}),
114
+ schema: {
115
+ get active() {
116
+ return mocks.mockSchema();
117
+ },
118
+ },
119
+ clearSchema: vi.fn(),
120
+ prefetchAllSchemas: vi.fn(async () => {}),
121
+ collectionHasDates: mocks.mockCollectionHasDates,
122
+ getCollectionTitle: vi.fn(() => null),
123
+ getCollectionDescription: vi.fn(() => null),
124
+ }));
125
+ vi.mock('../../src/client/js/editor/editor.svelte', () => ({
126
+ preloadFile: vi.fn(async () => {}),
127
+ loadFileBody: vi.fn(async () => {}),
128
+ clearEditor: vi.fn(),
129
+ editor: {
130
+ get tab() {
131
+ return mocks.mockActiveTab();
132
+ },
133
+ get data() {
134
+ return {};
135
+ },
136
+ get originalFilename() {
137
+ return '';
138
+ },
139
+ },
140
+ setActiveTab: vi.fn(),
141
+ getEditorFile: mocks.mockGetEditorFile,
142
+ loadDraftById: vi.fn(async () => {}),
143
+ setFilename: vi.fn(),
144
+ updateBody: vi.fn(),
145
+ updateFormField: vi.fn(),
146
+ saveDraftToIDB: vi.fn(async () => {}),
147
+ saveFile: vi.fn(async () => {}),
148
+ publishFile: vi.fn(async () => {}),
149
+ deleteCurrentDraft: vi.fn(async () => {}),
150
+ applyEditorState: vi.fn(),
151
+ _getDraftState: vi.fn(() => ({})),
152
+ _setDraftState: vi.fn(),
153
+ changeFileFormat: vi.fn(),
154
+ setDefaultFormat: vi.fn(),
155
+ }));
156
+ vi.mock('../../src/client/js/handlers/admin', async (importOriginal) => {
157
+ const actual =
158
+ await importOriginal<typeof import('../../src/client/js/handlers/admin')>();
159
+ return {
160
+ ...actual,
161
+ handleSave: vi.fn(async () => {}),
162
+ handlePublish: vi.fn(async () => ({ status: 'ok' })),
163
+ handleDeleteDraft: vi.fn(async () => {}),
164
+ handleFilenameConfirm: vi.fn(async () => {}),
165
+ computePublishDisabled: mocks.mockComputePublishDisabled,
166
+ // Override buildCollectionItems to read from mockCollections — the
167
+ // module-level getter for `collections` is not a live binding in
168
+ // vitest's browser mode, so the real function would see an empty array.
169
+ buildCollectionItems: () =>
170
+ mocks.mockCollections().map((name: string) => ({
171
+ label: name.charAt(0).toUpperCase() + name.slice(1),
172
+ href: '/admin/' + name,
173
+ })),
174
+ };
175
+ });
176
+ vi.mock('../../src/client/js/utils/sort', () => ({
177
+ toSortDate: vi.fn(() => undefined),
178
+ readSortMode: vi.fn(() => 'alpha'),
179
+ writeSortMode: vi.fn(),
180
+ createComparator: vi.fn(() => () => 0),
181
+ SORT_MODES: {
182
+ alpha: { icon: 'sort_by_alpha', label: 'Alphabetical' },
183
+ 'date-asc': { icon: 'hourglass_arrow_down', label: 'Oldest first' },
184
+ 'date-desc': { icon: 'hourglass_arrow_up', label: 'Newest first' },
185
+ },
186
+ SORT_ORDER: ['alpha', 'date-asc', 'date-desc'],
187
+ }));
188
+ vi.mock('../../src/client/js/drafts/storage', () => ({
189
+ saveDraft: vi.fn(async () => {}),
190
+ getDraftByFile: vi.fn(async () => null),
191
+ loadDrafts: vi.fn(async () => []),
192
+ loadDraft: vi.fn(async () => null),
193
+ deleteDraft: vi.fn(async () => {}),
194
+ }));
195
+ vi.mock('../../src/client/js/utils/schema-utils', () => ({
196
+ extractTabs: vi.fn(() => []),
197
+ getFieldsForTab: vi.fn(() => []),
198
+ resolveFieldType: vi.fn(() => ({ kind: 'string' })),
199
+ createDefaultValue: vi.fn(() => ''),
200
+ getByPath: vi.fn(),
201
+ setByPath: vi.fn(),
202
+ isReadOnly: vi.fn(() => false),
203
+ isNullable: vi.fn(() => false),
204
+ getProperties: vi.fn(
205
+ (schema: Record<string, unknown>) => schema['properties'],
206
+ ),
207
+ getRequiredFields: vi.fn((schema: Record<string, unknown>) =>
208
+ Array.isArray(schema['required']) ? schema['required'] : [],
209
+ ),
210
+ getLabel: vi.fn((schema: Record<string, unknown>, name: string) =>
211
+ typeof schema['title'] === 'string' ? schema['title'] : name,
212
+ ),
213
+ }));
214
+ vi.mock('../../src/client/js/drafts/merge.svelte', () => ({
215
+ drafts: {
216
+ get all() {
217
+ return mocks.mockDrafts();
218
+ },
219
+ get outdated() {
220
+ return mocks.mockOutdatedMap();
221
+ },
222
+ },
223
+ mergeDrafts: vi.fn(async () => {}),
224
+ refreshDrafts: vi.fn(async () => {}),
225
+ resetDraftMerge: vi.fn(),
226
+ }));
227
+
228
+ vi.mock('../../src/client/js/state/theme.svelte', () => ({
229
+ initTheme: vi.fn(() => () => {}),
230
+ cycleTheme: vi.fn(),
231
+ theme: { resolved: 'dark', icon: 'brightness_auto', label: 'Auto' },
232
+ }));
233
+ vi.mock('../../src/client/js/state/dialogs.svelte', () => ({
234
+ dialog: {
235
+ get active() {
236
+ return null;
237
+ },
238
+ open: vi.fn(),
239
+ close: vi.fn(),
240
+ },
241
+ }));
242
+
243
+ import Admin from '../../src/client/Admin.svelte';
244
+
245
+ afterEach(() => cleanup());
246
+ beforeEach(() => {
247
+ resetMocks(mocks);
248
+ stateMocks.mockConnectGitHub.mockClear();
249
+ stateMocks.mockBackendType.mockReturnValue(null);
250
+ });
251
+
252
+ describe('GitHub Adapter', () => {
253
+ it('renders GitHub connection form in BackendPicker', () => {
254
+ const { container } = render(Admin);
255
+
256
+ const options = container.querySelectorAll('.picker-option');
257
+ const githubOption = options[1];
258
+
259
+ expect(githubOption.querySelector('h3')?.textContent).toBe(
260
+ 'GitHub Repository',
261
+ );
262
+ expect(githubOption.querySelector('input[type="password"]')).not.toBeNull();
263
+ expect(githubOption.querySelector('input[type="text"]')).not.toBeNull();
264
+ });
265
+
266
+ it('disables connect button when token and repo are empty', () => {
267
+ const { container } = render(Admin);
268
+
269
+ const submitBtn = container.querySelector('button[type="submit"]');
270
+ expect(submitBtn).not.toBeNull();
271
+ expect((submitBtn as HTMLButtonElement)?.disabled).toBe(true);
272
+ });
273
+
274
+ it('renders collections after GitHub backend connects', () => {
275
+ configureConnected(mocks, ['posts', 'pages']);
276
+ stateMocks.mockBackendType.mockReturnValue('github');
277
+ mocks.mockRoute.mockReturnValue({ view: 'home' });
278
+
279
+ const { container } = render(Admin);
280
+
281
+ // Should show collections sidebar, not BackendPicker
282
+ expect(container.querySelector('.picker')).toBeNull();
283
+ const links = container.querySelectorAll('.sidebar-link');
284
+ expect(links.length).toBe(2);
285
+ });
286
+
287
+ it('shows content list from GitHub adapter after navigating to collection', () => {
288
+ stateMocks.mockBackendType.mockReturnValue('github');
289
+ configureCollection(mocks, 'posts', [
290
+ { filename: 'post-1.md', data: { title: 'From GitHub' } },
291
+ { filename: 'post-2.md', data: { title: 'Also From GitHub' } },
292
+ ]);
293
+
294
+ const { container } = render(Admin);
295
+
296
+ const sidebars = container.querySelectorAll('.sidebar');
297
+ const contentSidebar = sidebars[1];
298
+ const links = contentSidebar.querySelectorAll('.sidebar-link');
299
+ expect(links.length).toBe(2);
300
+ });
301
+
302
+ it('shows error message when GitHub connection fails', () => {
303
+ mocks.mockError.mockReturnValue('Authentication failed');
304
+ stateMocks.mockBackendType.mockReturnValue(null);
305
+
306
+ const { container } = render(Admin);
307
+
308
+ const error = container.querySelector('.error');
309
+ expect(error).not.toBeNull();
310
+ expect(error?.textContent).toContain('Authentication failed');
311
+ });
312
+
313
+ it('shows loading state in content sidebar', () => {
314
+ stateMocks.mockBackendType.mockReturnValue('github');
315
+ configureConnected(mocks, ['posts']);
316
+ mocks.mockRoute.mockReturnValue({
317
+ view: 'collection',
318
+ collection: 'posts',
319
+ });
320
+ mocks.mockLoading.mockReturnValue(true);
321
+ mocks.mockContentList.mockReturnValue([]);
322
+
323
+ const { container } = render(Admin);
324
+
325
+ const sidebars = container.querySelectorAll('.sidebar');
326
+ const contentSidebar = sidebars[1];
327
+ // Loading state is handled by the sidebar component
328
+ expect(contentSidebar).not.toBeNull();
329
+ });
330
+ });