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,616 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+
3
+ //////////////////////////////
4
+ // Editor module test strategy
5
+ //
6
+ // editor.svelte.ts calls registerDirtyChecker() at the top level when the
7
+ // module is first evaluated, which requires the router to already be mocked.
8
+ // The module also holds all state at module scope — to get a clean starting
9
+ // state for each describe block that needs it we use vi.resetModules() +
10
+ // dynamic import. All dependency mocks are declared here before static
11
+ // imports so Vitest's vi.mock() hoisting guarantees they are in place.
12
+ //////////////////////////////
13
+
14
+ vi.mock('../../../../src/client/js/state/router.svelte', () => ({
15
+ registerDirtyChecker: vi.fn(),
16
+ adminPath: vi.fn((...segments) =>
17
+ segments.length === 0 ? '/admin' : '/admin/' + segments.join('/'),
18
+ ),
19
+ }));
20
+
21
+ vi.mock('../../../../src/client/js/utils/frontmatter', () => ({
22
+ splitFrontmatter: vi.fn((text: string) => ({
23
+ rawFrontmatter: '',
24
+ body: text,
25
+ })),
26
+ }));
27
+
28
+ vi.mock('../../../../src/client/js/utils/schema-utils', () => ({
29
+ setByPath: vi.fn(
30
+ (
31
+ obj: Record<string, unknown>,
32
+ path: (string | number)[],
33
+ value: unknown,
34
+ ) => {
35
+ // Minimal real implementation so updateFormField tests work correctly
36
+ let current = obj as Record<string | number, unknown>;
37
+ for (let i = 0; i < path.length - 1; i++) {
38
+ if (!current[path[i]]) current[path[i]] = {};
39
+ current = current[path[i]] as Record<string | number, unknown>;
40
+ }
41
+ current[path[path.length - 1]] = value;
42
+ },
43
+ ),
44
+ }));
45
+
46
+ vi.mock('../../../../src/client/js/drafts/storage', () => ({
47
+ getDraftByFile: vi.fn(async () => null),
48
+ }));
49
+
50
+ // storageClient is a direct object export (not a getter function), so the
51
+ // mock uses a hoisted ref that tests swap between null and a fake client.
52
+ const { mockStorageClientRef } = vi.hoisted(() => ({
53
+ mockStorageClientRef: { current: null as any },
54
+ }));
55
+ vi.mock('../../../../src/client/js/state/state.svelte', () => ({
56
+ get storageClient() {
57
+ return mockStorageClientRef.current;
58
+ },
59
+ }));
60
+
61
+ vi.mock(
62
+ import('../../../../src/client/js/utils/file-types'),
63
+ async (importOriginal) => ({
64
+ ...(await importOriginal()),
65
+ getFileCategory: vi.fn(() => null),
66
+ }),
67
+ );
68
+
69
+ // ops.svelte re-exports — editor.svelte.ts re-exports these; mock them so
70
+ // importing the editor does not pull in the real ops module's dependencies.
71
+ vi.mock('../../../../src/client/js/drafts/ops.svelte', () => ({
72
+ saveDraftToIDB: vi.fn(async () => undefined),
73
+ saveFile: vi.fn(async () => undefined),
74
+ publishFile: vi.fn(async () => undefined),
75
+ loadDraftById: vi.fn(async () => undefined),
76
+ deleteCurrentDraft: vi.fn(async () => undefined),
77
+ }));
78
+
79
+ import { getDraftByFile } from '../../../../src/client/js/drafts/storage';
80
+ // storageClient import removed — tests configure mockStorageClientRef.current directly
81
+ import { splitFrontmatter } from '../../../../src/client/js/utils/frontmatter';
82
+ import { getFileCategory } from '../../../../src/client/js/utils/file-types';
83
+
84
+ import type { Draft } from '../../../../src/client/js/drafts/storage';
85
+
86
+ /**
87
+ * Builds a minimal Draft fixture for editor tests.
88
+ * @param {Partial<Draft>} overrides - Optional field overrides
89
+ * @return {Draft} A complete Draft object
90
+ */
91
+ function makeDraft(overrides: Partial<Draft> = {}): Draft {
92
+ return {
93
+ id: 'ed-draft-01',
94
+ collection: 'posts',
95
+ filename: 'post.md',
96
+ isNew: false,
97
+ formData: { title: 'Draft Title' },
98
+ body: 'Draft body content',
99
+ snapshot: '{"body":"orig","formData":{"title":"Orig"}}',
100
+ createdAt: '2026-01-01T00:00:00.000Z',
101
+ ...overrides,
102
+ };
103
+ }
104
+
105
+ //////////////////////////////
106
+ // getEditorFile / formData — initial state
107
+ //////////////////////////////
108
+
109
+ describe('getEditorFile — initial state', () => {
110
+ afterEach(() => {
111
+ vi.resetModules();
112
+ });
113
+
114
+ it('returns null when no file has been opened', async () => {
115
+ vi.resetModules();
116
+ const { getEditorFile } =
117
+ await import('../../../../src/client/js/editor/editor.svelte');
118
+ expect(getEditorFile()).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe('formData — initial state', () => {
123
+ afterEach(() => {
124
+ vi.resetModules();
125
+ });
126
+
127
+ it('returns an empty object before any file is loaded', async () => {
128
+ vi.resetModules();
129
+ const { editor } =
130
+ await import('../../../../src/client/js/editor/editor.svelte');
131
+ expect(editor.data).toEqual({});
132
+ });
133
+ });
134
+
135
+ //////////////////////////////
136
+ // activeTab / setActiveTab
137
+ //////////////////////////////
138
+
139
+ describe('activeTab / setActiveTab', () => {
140
+ afterEach(() => {
141
+ vi.resetModules();
142
+ });
143
+
144
+ it('returns "metadata" as the default active tab', async () => {
145
+ vi.resetModules();
146
+ const { editor } =
147
+ await import('../../../../src/client/js/editor/editor.svelte');
148
+ expect(editor.tab).toBe('metadata');
149
+ });
150
+
151
+ it('updates the active tab via setActiveTab', async () => {
152
+ vi.resetModules();
153
+ const mod = await import('../../../../src/client/js/editor/editor.svelte');
154
+ mod.setActiveTab('content');
155
+ expect(mod.editor.tab).toBe('content');
156
+ });
157
+
158
+ it('can set any arbitrary tab string', async () => {
159
+ vi.resetModules();
160
+ const mod = await import('../../../../src/client/js/editor/editor.svelte');
161
+ mod.setActiveTab('seo');
162
+ expect(mod.editor.tab).toBe('seo');
163
+ });
164
+ });
165
+
166
+ //////////////////////////////
167
+ // applyEditorState
168
+ //////////////////////////////
169
+
170
+ describe('applyEditorState', () => {
171
+ afterEach(() => {
172
+ vi.resetModules();
173
+ });
174
+
175
+ it('opens the file and populates getEditorFile when open=true', async () => {
176
+ vi.resetModules();
177
+ const { applyEditorState, getEditorFile } =
178
+ await import('../../../../src/client/js/editor/editor.svelte');
179
+ applyEditorState(
180
+ {
181
+ body: 'Hello',
182
+ formData: { title: 'T' },
183
+ filename: 'test.md',
184
+ bodyLoaded: true,
185
+ draftId: null,
186
+ isNewDraft: false,
187
+ snapshot: null,
188
+ collection: 'posts',
189
+ draftCreatedAt: null,
190
+ },
191
+ true,
192
+ );
193
+ const file = getEditorFile();
194
+ expect(file).not.toBeNull();
195
+ expect(file?.body).toBe('Hello');
196
+ expect(file?.filename).toBe('test.md');
197
+ expect(file?.dirty).toBe(false);
198
+ });
199
+
200
+ it('resets activeTab to "metadata" on each apply', async () => {
201
+ vi.resetModules();
202
+ const mod = await import('../../../../src/client/js/editor/editor.svelte');
203
+ mod.setActiveTab('content');
204
+ mod.applyEditorState(
205
+ {
206
+ body: '',
207
+ formData: {},
208
+ filename: 'x.md',
209
+ bodyLoaded: false,
210
+ draftId: null,
211
+ isNewDraft: false,
212
+ snapshot: null,
213
+ collection: 'posts',
214
+ draftCreatedAt: null,
215
+ },
216
+ true,
217
+ );
218
+ expect(mod.editor.tab).toBe('metadata');
219
+ });
220
+
221
+ it('keeps getEditorFile null when open=false', async () => {
222
+ vi.resetModules();
223
+ const { applyEditorState, getEditorFile } =
224
+ await import('../../../../src/client/js/editor/editor.svelte');
225
+ applyEditorState(
226
+ {
227
+ body: '',
228
+ formData: {},
229
+ filename: '',
230
+ bodyLoaded: false,
231
+ draftId: null,
232
+ isNewDraft: false,
233
+ snapshot: null,
234
+ collection: '',
235
+ draftCreatedAt: null,
236
+ },
237
+ false,
238
+ );
239
+ expect(getEditorFile()).toBeNull();
240
+ });
241
+ });
242
+
243
+ //////////////////////////////
244
+ // updateFormField
245
+ //////////////////////////////
246
+
247
+ describe('updateFormField', () => {
248
+ afterEach(() => {
249
+ vi.resetModules();
250
+ });
251
+
252
+ it('updates a top-level formData field', async () => {
253
+ vi.resetModules();
254
+ const mod = await import('../../../../src/client/js/editor/editor.svelte');
255
+ mod.applyEditorState(
256
+ {
257
+ body: '',
258
+ formData: { title: 'Original' },
259
+ filename: 'f.md',
260
+ bodyLoaded: true,
261
+ draftId: null,
262
+ isNewDraft: false,
263
+ snapshot: null,
264
+ collection: 'posts',
265
+ draftCreatedAt: null,
266
+ },
267
+ true,
268
+ );
269
+ mod.updateFormField(['title'], 'Updated');
270
+ expect(mod.editor.data['title']).toBe('Updated');
271
+ });
272
+
273
+ it('marks the file dirty after a formData change', async () => {
274
+ vi.resetModules();
275
+ const { applyEditorState, updateFormField, getEditorFile } =
276
+ await import('../../../../src/client/js/editor/editor.svelte');
277
+ applyEditorState(
278
+ {
279
+ body: '',
280
+ formData: { title: 'Same' },
281
+ filename: 'f.md',
282
+ bodyLoaded: true,
283
+ draftId: null,
284
+ isNewDraft: false,
285
+ snapshot: null,
286
+ collection: 'posts',
287
+ draftCreatedAt: null,
288
+ },
289
+ true,
290
+ );
291
+ updateFormField(['title'], 'Different');
292
+ expect(getEditorFile()?.dirty).toBe(true);
293
+ });
294
+ });
295
+
296
+ //////////////////////////////
297
+ // updateBody
298
+ //////////////////////////////
299
+
300
+ describe('updateBody', () => {
301
+ afterEach(() => {
302
+ vi.resetModules();
303
+ });
304
+
305
+ it('updates the body content', async () => {
306
+ vi.resetModules();
307
+ const { applyEditorState, updateBody, getEditorFile } =
308
+ await import('../../../../src/client/js/editor/editor.svelte');
309
+ applyEditorState(
310
+ {
311
+ body: 'original',
312
+ formData: {},
313
+ filename: 'f.md',
314
+ bodyLoaded: true,
315
+ draftId: null,
316
+ isNewDraft: false,
317
+ snapshot: null,
318
+ collection: 'posts',
319
+ draftCreatedAt: null,
320
+ },
321
+ true,
322
+ );
323
+ updateBody('new content');
324
+ expect(getEditorFile()?.body).toBe('new content');
325
+ });
326
+
327
+ it('marks the file dirty when body differs from last saved', async () => {
328
+ vi.resetModules();
329
+ const { applyEditorState, updateBody, getEditorFile } =
330
+ await import('../../../../src/client/js/editor/editor.svelte');
331
+ applyEditorState(
332
+ {
333
+ body: 'saved',
334
+ formData: {},
335
+ filename: 'f.md',
336
+ bodyLoaded: true,
337
+ draftId: null,
338
+ isNewDraft: false,
339
+ snapshot: null,
340
+ collection: 'posts',
341
+ draftCreatedAt: null,
342
+ },
343
+ true,
344
+ );
345
+ updateBody('changed');
346
+ expect(getEditorFile()?.dirty).toBe(true);
347
+ });
348
+
349
+ it('clears dirty when body is restored to last saved value', async () => {
350
+ vi.resetModules();
351
+ const { applyEditorState, updateBody, getEditorFile } =
352
+ await import('../../../../src/client/js/editor/editor.svelte');
353
+ applyEditorState(
354
+ {
355
+ body: 'saved body',
356
+ formData: {},
357
+ filename: 'f.md',
358
+ bodyLoaded: true,
359
+ draftId: null,
360
+ isNewDraft: false,
361
+ snapshot: null,
362
+ collection: 'posts',
363
+ draftCreatedAt: null,
364
+ },
365
+ true,
366
+ );
367
+ updateBody('changed');
368
+ updateBody('saved body');
369
+ expect(getEditorFile()?.dirty).toBe(false);
370
+ });
371
+ });
372
+
373
+ //////////////////////////////
374
+ // clearEditor
375
+ //////////////////////////////
376
+
377
+ describe('clearEditor', () => {
378
+ afterEach(() => {
379
+ vi.resetModules();
380
+ });
381
+
382
+ it('returns null from getEditorFile after clearing', async () => {
383
+ vi.resetModules();
384
+ const { applyEditorState, clearEditor, getEditorFile } =
385
+ await import('../../../../src/client/js/editor/editor.svelte');
386
+ applyEditorState(
387
+ {
388
+ body: 'content',
389
+ formData: { title: 'T' },
390
+ filename: 'f.md',
391
+ bodyLoaded: true,
392
+ draftId: null,
393
+ isNewDraft: false,
394
+ snapshot: null,
395
+ collection: 'posts',
396
+ draftCreatedAt: null,
397
+ },
398
+ true,
399
+ );
400
+ clearEditor();
401
+ expect(getEditorFile()).toBeNull();
402
+ });
403
+
404
+ it('resets formData to empty object', async () => {
405
+ vi.resetModules();
406
+ const mod = await import('../../../../src/client/js/editor/editor.svelte');
407
+ mod.applyEditorState(
408
+ {
409
+ body: '',
410
+ formData: { title: 'X' },
411
+ filename: 'f.md',
412
+ bodyLoaded: true,
413
+ draftId: null,
414
+ isNewDraft: false,
415
+ snapshot: null,
416
+ collection: 'posts',
417
+ draftCreatedAt: null,
418
+ },
419
+ true,
420
+ );
421
+ mod.clearEditor();
422
+ expect(mod.editor.data).toEqual({});
423
+ });
424
+ });
425
+
426
+ //////////////////////////////
427
+ // preloadFile
428
+ //////////////////////////////
429
+
430
+ describe('preloadFile', () => {
431
+ afterEach(() => {
432
+ vi.resetModules();
433
+ vi.clearAllMocks();
434
+ });
435
+
436
+ it('loads draft data when a draft exists for the file', async () => {
437
+ vi.resetModules();
438
+ const draft = makeDraft();
439
+ vi.mocked(getDraftByFile).mockResolvedValue(draft);
440
+
441
+ const { preloadFile, getEditorFile } =
442
+ await import('../../../../src/client/js/editor/editor.svelte');
443
+ await preloadFile('posts', 'post.md', { title: 'Live' });
444
+
445
+ const file = getEditorFile();
446
+ expect(file?.body).toBe(draft.body);
447
+ expect(file?.formData).toEqual(draft.formData);
448
+ expect(file?.draftId).toBe(draft.id);
449
+ expect(file?.bodyLoaded).toBe(true);
450
+ });
451
+
452
+ it('loads live data when no draft exists', async () => {
453
+ vi.resetModules();
454
+ vi.mocked(getDraftByFile).mockResolvedValue(null);
455
+
456
+ const { preloadFile, getEditorFile } =
457
+ await import('../../../../src/client/js/editor/editor.svelte');
458
+ await preloadFile('posts', 'live-file.md', { title: 'Live Title' });
459
+
460
+ const file = getEditorFile();
461
+ expect(file?.body).toBe('');
462
+ expect(file?.formData).toEqual({ title: 'Live Title' });
463
+ expect(file?.draftId).toBeNull();
464
+ expect(file?.bodyLoaded).toBe(false);
465
+ });
466
+
467
+ it('is a no-op when the same file is already open', async () => {
468
+ vi.resetModules();
469
+ vi.mocked(getDraftByFile).mockResolvedValue(null);
470
+
471
+ const { preloadFile } =
472
+ await import('../../../../src/client/js/editor/editor.svelte');
473
+ await preloadFile('posts', 'same.md', { title: 'First' });
474
+
475
+ vi.clearAllMocks();
476
+ await preloadFile('posts', 'same.md', { title: 'Second' });
477
+
478
+ // getDraftByFile should not be called on the second preload of the same file
479
+ expect(getDraftByFile).not.toHaveBeenCalled();
480
+ });
481
+ });
482
+
483
+ //////////////////////////////
484
+ // loadFileBody
485
+ //////////////////////////////
486
+
487
+ describe('loadFileBody', () => {
488
+ afterEach(() => {
489
+ vi.resetModules();
490
+ vi.clearAllMocks();
491
+ });
492
+
493
+ it('does nothing when no storage client is connected', async () => {
494
+ vi.resetModules();
495
+ mockStorageClientRef.current = null;
496
+ vi.mocked(getDraftByFile).mockResolvedValue(null);
497
+
498
+ const { preloadFile, loadFileBody, getEditorFile } =
499
+ await import('../../../../src/client/js/editor/editor.svelte');
500
+ await preloadFile('posts', 'f.md', {});
501
+ await loadFileBody('posts', 'f.md');
502
+
503
+ // bodyLoaded should remain false since client was null
504
+ expect(getEditorFile()?.bodyLoaded).toBe(false);
505
+ });
506
+
507
+ it('reads the file and updates body + bodyLoaded flag', async () => {
508
+ vi.resetModules();
509
+ vi.mocked(getDraftByFile).mockResolvedValue(null);
510
+ vi.mocked(splitFrontmatter).mockReturnValue({
511
+ rawFrontmatter: 'title: T',
512
+ body: '\n\nThe markdown body\n\n',
513
+ });
514
+ const fakeClient = {
515
+ readFile: vi.fn(
516
+ async () => '---\ntitle: T\n---\n\nThe markdown body\n\n',
517
+ ),
518
+ };
519
+ mockStorageClientRef.current = fakeClient;
520
+
521
+ const { preloadFile, loadFileBody, getEditorFile } =
522
+ await import('../../../../src/client/js/editor/editor.svelte');
523
+ await preloadFile('posts', 'body-file.md', { title: 'T' });
524
+ await loadFileBody('posts', 'body-file.md');
525
+
526
+ const file = getEditorFile();
527
+ expect(file?.bodyLoaded).toBe(true);
528
+ // Leading/trailing newlines are stripped from the body
529
+ expect(file?.body).toBe('The markdown body');
530
+ });
531
+
532
+ it('skips disk read and sets bodyLoaded for data-only files', async () => {
533
+ vi.resetModules();
534
+ vi.mocked(getDraftByFile).mockResolvedValue(null);
535
+ // Simulate a data file — category is 'data', so no disk read should occur
536
+ vi.mocked(getFileCategory).mockReturnValue('data');
537
+ const fakeClient = {
538
+ readFile: vi.fn(async () => '{}'),
539
+ };
540
+ mockStorageClientRef.current = fakeClient;
541
+
542
+ const { preloadFile, loadFileBody, getEditorFile } =
543
+ await import('../../../../src/client/js/editor/editor.svelte');
544
+ await preloadFile('authors', 'jane.json', { name: 'Jane' });
545
+ await loadFileBody('authors', 'jane.json');
546
+
547
+ // readFile must not be called — data files have no body to read
548
+ expect(fakeClient.readFile).not.toHaveBeenCalled();
549
+ expect(getEditorFile()?.bodyLoaded).toBe(true);
550
+ });
551
+ });
552
+
553
+ //////////////////////////////
554
+ // _getDraftState / _setDraftState
555
+ //////////////////////////////
556
+
557
+ describe('_getDraftState / _setDraftState', () => {
558
+ afterEach(() => {
559
+ vi.resetModules();
560
+ });
561
+
562
+ it('_getDraftState returns current internal state snapshot', async () => {
563
+ vi.resetModules();
564
+ const { _getDraftState } =
565
+ await import('../../../../src/client/js/editor/editor.svelte');
566
+ const state = _getDraftState();
567
+ expect(state).toHaveProperty('saving');
568
+ expect(state).toHaveProperty('draftId');
569
+ expect(state).toHaveProperty('isNewDraft');
570
+ expect(state).toHaveProperty('snapshot');
571
+ expect(state).toHaveProperty('currentCollection');
572
+ expect(state).toHaveProperty('body');
573
+ expect(state).toHaveProperty('formData');
574
+ });
575
+
576
+ it('_setDraftState updates the saving flag', async () => {
577
+ vi.resetModules();
578
+ const { _setDraftState, _getDraftState } =
579
+ await import('../../../../src/client/js/editor/editor.svelte');
580
+ _setDraftState({ saving: true });
581
+ expect(_getDraftState().saving).toBe(true);
582
+ _setDraftState({ saving: false });
583
+ expect(_getDraftState().saving).toBe(false);
584
+ });
585
+
586
+ it('_setDraftState updates draftId', async () => {
587
+ vi.resetModules();
588
+ const { _setDraftState, _getDraftState } =
589
+ await import('../../../../src/client/js/editor/editor.svelte');
590
+ _setDraftState({ draftId: 'new-id' });
591
+ expect(_getDraftState().draftId).toBe('new-id');
592
+ });
593
+
594
+ it('_setDraftState only mutates the specified fields', async () => {
595
+ vi.resetModules();
596
+ const { applyEditorState, _setDraftState, _getDraftState } =
597
+ await import('../../../../src/client/js/editor/editor.svelte');
598
+ applyEditorState(
599
+ {
600
+ body: 'body',
601
+ formData: { title: 'T' },
602
+ filename: 'f.md',
603
+ bodyLoaded: true,
604
+ draftId: null,
605
+ isNewDraft: true,
606
+ snapshot: null,
607
+ collection: 'posts',
608
+ draftCreatedAt: null,
609
+ },
610
+ true,
611
+ );
612
+ _setDraftState({ saving: true });
613
+ // isNewDraft should be unchanged
614
+ expect(_getDraftState().isNewDraft).toBe(true);
615
+ });
616
+ });