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,619 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ /*
4
+ //////////////////////////////
5
+ // Router module test strategy
6
+ //
7
+ // The router module reads location.pathname at import time to set the initial
8
+ // $state, and tracks an `initialized` flag at module scope. To test different
9
+ // initial routes and to reset that flag between test groups we must use
10
+ // vi.resetModules() + a dynamic import for each describe block that needs a
11
+ // fresh module instance. Globals (location, navigation, window) are stubbed
12
+ // before each dynamic import so the module picks them up on load.
13
+ //////////////////////////////
14
+ */
15
+
16
+ // Hoisted mock for the virtual config module — lets tests override basePath
17
+ const { mockBasePath } = vi.hoisted(() => ({
18
+ mockBasePath: vi.fn(() => '/admin'),
19
+ }));
20
+
21
+ vi.mock('virtual:nebula/config', () => ({
22
+ default: {
23
+ get basePath() {
24
+ return mockBasePath();
25
+ },
26
+ collectionsPath: '/collections',
27
+ },
28
+ }));
29
+
30
+ afterEach(() => {
31
+ mockBasePath.mockReturnValue('/admin');
32
+ });
33
+
34
+ // Minimal NavigateEvent shape used by the navigate listener.
35
+ interface FakeNavigateEvent {
36
+ destination: { url: string };
37
+ hashChange: boolean;
38
+ downloadRequest: null | string;
39
+ canIntercept: boolean;
40
+ intercepted: boolean;
41
+ interceptHandler: (() => void) | null;
42
+ preventDefault: ReturnType<typeof vi.fn>;
43
+ intercept: (opts: { handler: () => void }) => void;
44
+ }
45
+
46
+ /**
47
+ * Builds a fake NavigateEvent for use in navigate listener tests.
48
+ * @param {string} url - The destination URL string
49
+ * @param {Partial<FakeNavigateEvent>} overrides - Optional field overrides
50
+ * @return {FakeNavigateEvent} The constructed fake event
51
+ */
52
+ function makeFakeNavigateEvent(
53
+ url: string,
54
+ overrides: Partial<FakeNavigateEvent> = {},
55
+ ): FakeNavigateEvent {
56
+ const event: FakeNavigateEvent = {
57
+ destination: { url },
58
+ hashChange: false,
59
+ downloadRequest: null,
60
+ canIntercept: true,
61
+ intercepted: false,
62
+ interceptHandler: null,
63
+ preventDefault: vi.fn(),
64
+ intercept(opts) {
65
+ this.intercepted = true;
66
+ this.interceptHandler = opts.handler;
67
+ },
68
+ ...overrides,
69
+ };
70
+ return event;
71
+ }
72
+
73
+ /**
74
+ * Builds a minimal navigation global stub that captures 'navigate' listeners
75
+ * and exposes helpers to fire them.
76
+ * @return {{ addEventListener: ReturnType<typeof vi.fn>, navigate: ReturnType<typeof vi.fn>, fire: (e: FakeNavigateEvent) => void }} The stub and fire helper
77
+ */
78
+ function makeNavigationStub() {
79
+ const listeners: Array<(e: FakeNavigateEvent) => void> = [];
80
+ const stub = {
81
+ addEventListener: vi.fn(
82
+ (type: string, cb: (e: FakeNavigateEvent) => void) => {
83
+ if (type === 'navigate') listeners.push(cb);
84
+ },
85
+ ),
86
+ navigate: vi.fn(),
87
+ // Fires the navigate event on all registered listeners.
88
+ fire(event: FakeNavigateEvent) {
89
+ for (const l of listeners) l(event);
90
+ },
91
+ };
92
+ return stub;
93
+ }
94
+
95
+ /*
96
+ //////////////////////////////
97
+ // Route parsing via route export
98
+ //////////////////////////////
99
+ */
100
+
101
+ describe('route — initial route parsing', () => {
102
+ afterEach(() => {
103
+ vi.unstubAllGlobals();
104
+ vi.resetModules();
105
+ });
106
+
107
+ it('parses the home route for /admin', async () => {
108
+ vi.stubGlobal('location', { pathname: '/admin' });
109
+ vi.stubGlobal('navigation', makeNavigationStub());
110
+ const { nav } =
111
+ await import('../../../../src/client/js/state/router.svelte');
112
+ expect(nav.route).toEqual({ view: 'home' });
113
+ });
114
+
115
+ it('parses the home route for /admin/ (trailing slash)', async () => {
116
+ vi.stubGlobal('location', { pathname: '/admin/' });
117
+ vi.stubGlobal('navigation', makeNavigationStub());
118
+ const { nav } =
119
+ await import('../../../../src/client/js/state/router.svelte');
120
+ expect(nav.route).toEqual({ view: 'home' });
121
+ });
122
+
123
+ it('parses the collection route for /admin/posts', async () => {
124
+ vi.stubGlobal('location', { pathname: '/admin/posts' });
125
+ vi.stubGlobal('navigation', makeNavigationStub());
126
+ const { nav } =
127
+ await import('../../../../src/client/js/state/router.svelte');
128
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
129
+ });
130
+
131
+ it('parses the file route for /admin/posts/hello-world', async () => {
132
+ vi.stubGlobal('location', { pathname: '/admin/posts/hello-world' });
133
+ vi.stubGlobal('navigation', makeNavigationStub());
134
+ const { nav } =
135
+ await import('../../../../src/client/js/state/router.svelte');
136
+ expect(nav.route).toEqual({
137
+ view: 'file',
138
+ collection: 'posts',
139
+ slug: 'hello-world',
140
+ });
141
+ });
142
+
143
+ it('parses the draft route for /admin/posts/draft-abc123', async () => {
144
+ vi.stubGlobal('location', { pathname: '/admin/posts/draft-abc123' });
145
+ vi.stubGlobal('navigation', makeNavigationStub());
146
+ const { nav } =
147
+ await import('../../../../src/client/js/state/router.svelte');
148
+ expect(nav.route).toEqual({
149
+ view: 'draft',
150
+ collection: 'posts',
151
+ draftId: 'abc123',
152
+ });
153
+ });
154
+ });
155
+
156
+ /*
157
+ //////////////////////////////
158
+ // adminPath()
159
+ //////////////////////////////
160
+ */
161
+
162
+ describe('adminPath', () => {
163
+ afterEach(() => {
164
+ vi.unstubAllGlobals();
165
+ vi.resetModules();
166
+ });
167
+
168
+ it('returns basePath when called with no segments', async () => {
169
+ vi.stubGlobal('location', { pathname: '/admin' });
170
+ vi.stubGlobal('navigation', makeNavigationStub());
171
+ const { adminPath } =
172
+ await import('../../../../src/client/js/state/router.svelte');
173
+ expect(adminPath()).toBe('/admin');
174
+ });
175
+
176
+ it('joins segments under default /admin basePath', async () => {
177
+ vi.stubGlobal('location', { pathname: '/admin' });
178
+ vi.stubGlobal('navigation', makeNavigationStub());
179
+ const { adminPath } =
180
+ await import('../../../../src/client/js/state/router.svelte');
181
+ expect(adminPath('posts')).toBe('/admin/posts');
182
+ expect(adminPath('posts', 'hello-world')).toBe('/admin/posts/hello-world');
183
+ });
184
+
185
+ it('produces single-slash paths when basePath is /', async () => {
186
+ mockBasePath.mockReturnValue('/');
187
+ vi.stubGlobal('location', { pathname: '/' });
188
+ vi.stubGlobal('navigation', makeNavigationStub());
189
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
190
+ vi.resetModules();
191
+ const { adminPath } =
192
+ await import('../../../../src/client/js/state/router.svelte');
193
+ expect(adminPath('authors')).toBe('/authors');
194
+ expect(adminPath('posts', 'my-post')).toBe('/posts/my-post');
195
+ expect(adminPath('posts', 'draft-abc')).toBe('/posts/draft-abc');
196
+ });
197
+
198
+ it('joins segments under custom basePath', async () => {
199
+ mockBasePath.mockReturnValue('/cms');
200
+ vi.stubGlobal('location', { pathname: '/cms' });
201
+ vi.stubGlobal('navigation', makeNavigationStub());
202
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
203
+ vi.resetModules();
204
+ const { adminPath } =
205
+ await import('../../../../src/client/js/state/router.svelte');
206
+ expect(adminPath('posts')).toBe('/cms/posts');
207
+ expect(adminPath('posts', 'hello')).toBe('/cms/posts/hello');
208
+ });
209
+
210
+ it('joins segments under nested basePath', async () => {
211
+ mockBasePath.mockReturnValue('/app/dashboard');
212
+ vi.stubGlobal('location', { pathname: '/app/dashboard' });
213
+ vi.stubGlobal('navigation', makeNavigationStub());
214
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
215
+ vi.resetModules();
216
+ const { adminPath } =
217
+ await import('../../../../src/client/js/state/router.svelte');
218
+ expect(adminPath('posts')).toBe('/app/dashboard/posts');
219
+ });
220
+ });
221
+
222
+ /*
223
+ //////////////////////////////
224
+ // Root basePath (/) — interception and parsing
225
+ //////////////////////////////
226
+ */
227
+
228
+ describe('initRouter with root basePath (/)', () => {
229
+ afterEach(() => {
230
+ vi.unstubAllGlobals();
231
+ vi.resetModules();
232
+ });
233
+
234
+ it('parses home route for /', async () => {
235
+ mockBasePath.mockReturnValue('/');
236
+ vi.stubGlobal('location', { pathname: '/' });
237
+ vi.stubGlobal('navigation', makeNavigationStub());
238
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
239
+ vi.resetModules();
240
+ const { initRouter, nav } =
241
+ await import('../../../../src/client/js/state/router.svelte');
242
+ initRouter();
243
+ expect(nav.route).toEqual({ view: 'home' });
244
+ });
245
+
246
+ it('parses collection route for /authors', async () => {
247
+ mockBasePath.mockReturnValue('/');
248
+ vi.stubGlobal('location', { pathname: '/authors' });
249
+ vi.stubGlobal('navigation', makeNavigationStub());
250
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
251
+ vi.resetModules();
252
+ const { initRouter, nav } =
253
+ await import('../../../../src/client/js/state/router.svelte');
254
+ initRouter();
255
+ expect(nav.route).toEqual({ view: 'collection', collection: 'authors' });
256
+ });
257
+
258
+ it('intercepts navigations under root basePath', async () => {
259
+ mockBasePath.mockReturnValue('/');
260
+ const navStub = makeNavigationStub();
261
+ vi.stubGlobal('location', { pathname: '/' });
262
+ vi.stubGlobal('navigation', navStub);
263
+ vi.stubGlobal('window', {
264
+ addEventListener: vi.fn(),
265
+ confirm: vi.fn(() => true),
266
+ });
267
+ vi.resetModules();
268
+ const { initRouter, nav } =
269
+ await import('../../../../src/client/js/state/router.svelte');
270
+ initRouter();
271
+
272
+ const event = makeFakeNavigateEvent('http://localhost/authors');
273
+ navStub.fire(event);
274
+ expect(event.intercepted).toBe(true);
275
+ event.interceptHandler!();
276
+ expect(nav.route).toEqual({ view: 'collection', collection: 'authors' });
277
+ });
278
+
279
+ it('adminPath returns / when basePath is root and no segments given', async () => {
280
+ mockBasePath.mockReturnValue('/');
281
+ vi.stubGlobal('location', { pathname: '/' });
282
+ vi.stubGlobal('navigation', makeNavigationStub());
283
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
284
+ vi.resetModules();
285
+ const { adminPath } =
286
+ await import('../../../../src/client/js/state/router.svelte');
287
+ expect(adminPath()).toBe('/');
288
+ });
289
+ });
290
+
291
+ /*
292
+ //////////////////////////////
293
+ // Custom basePath route parsing
294
+ //////////////////////////////
295
+ */
296
+
297
+ describe('initRouter with custom basePath', () => {
298
+ afterEach(() => {
299
+ vi.unstubAllGlobals();
300
+ vi.resetModules();
301
+ });
302
+
303
+ it('parses home route with custom basePath', async () => {
304
+ mockBasePath.mockReturnValue('/cms');
305
+ vi.stubGlobal('location', { pathname: '/cms' });
306
+ vi.stubGlobal('navigation', makeNavigationStub());
307
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
308
+ vi.resetModules();
309
+ const { initRouter, nav } =
310
+ await import('../../../../src/client/js/state/router.svelte');
311
+ initRouter();
312
+ expect(nav.route).toEqual({ view: 'home' });
313
+ });
314
+
315
+ it('parses collection route with custom basePath', async () => {
316
+ mockBasePath.mockReturnValue('/cms');
317
+ vi.stubGlobal('location', { pathname: '/cms/posts' });
318
+ vi.stubGlobal('navigation', makeNavigationStub());
319
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
320
+ vi.resetModules();
321
+ const { initRouter, nav } =
322
+ await import('../../../../src/client/js/state/router.svelte');
323
+ initRouter();
324
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
325
+ });
326
+
327
+ it('parses file route with custom basePath', async () => {
328
+ mockBasePath.mockReturnValue('/cms');
329
+ vi.stubGlobal('location', { pathname: '/cms/posts/hello-world' });
330
+ vi.stubGlobal('navigation', makeNavigationStub());
331
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
332
+ vi.resetModules();
333
+ const { initRouter, nav } =
334
+ await import('../../../../src/client/js/state/router.svelte');
335
+ initRouter();
336
+ expect(nav.route).toEqual({
337
+ view: 'file',
338
+ collection: 'posts',
339
+ slug: 'hello-world',
340
+ });
341
+ });
342
+
343
+ it('parses draft route with custom basePath', async () => {
344
+ mockBasePath.mockReturnValue('/cms');
345
+ vi.stubGlobal('location', { pathname: '/cms/posts/draft-abc123' });
346
+ vi.stubGlobal('navigation', makeNavigationStub());
347
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
348
+ vi.resetModules();
349
+ const { initRouter, nav } =
350
+ await import('../../../../src/client/js/state/router.svelte');
351
+ initRouter();
352
+ expect(nav.route).toEqual({
353
+ view: 'draft',
354
+ collection: 'posts',
355
+ draftId: 'abc123',
356
+ });
357
+ });
358
+
359
+ it('intercepts navigations under custom basePath', async () => {
360
+ mockBasePath.mockReturnValue('/cms');
361
+ const navStub = makeNavigationStub();
362
+ vi.stubGlobal('location', { pathname: '/cms' });
363
+ vi.stubGlobal('navigation', navStub);
364
+ vi.stubGlobal('window', {
365
+ addEventListener: vi.fn(),
366
+ confirm: vi.fn(() => true),
367
+ });
368
+ vi.resetModules();
369
+ const { initRouter, nav } =
370
+ await import('../../../../src/client/js/state/router.svelte');
371
+ initRouter();
372
+
373
+ const event = makeFakeNavigateEvent('http://localhost/cms/posts');
374
+ navStub.fire(event);
375
+ expect(event.intercepted).toBe(true);
376
+ event.interceptHandler!();
377
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
378
+ });
379
+
380
+ it('does not intercept navigations outside custom basePath', async () => {
381
+ mockBasePath.mockReturnValue('/cms');
382
+ const navStub = makeNavigationStub();
383
+ vi.stubGlobal('location', { pathname: '/cms' });
384
+ vi.stubGlobal('navigation', navStub);
385
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
386
+ vi.resetModules();
387
+ const { initRouter, nav } =
388
+ await import('../../../../src/client/js/state/router.svelte');
389
+ initRouter();
390
+
391
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts');
392
+ navStub.fire(event);
393
+ expect(event.intercepted).toBe(false);
394
+ expect(nav.route).toEqual({ view: 'home' });
395
+ });
396
+
397
+ it('handles nested basePath like /app/dashboard', async () => {
398
+ mockBasePath.mockReturnValue('/app/dashboard');
399
+ vi.stubGlobal('location', { pathname: '/app/dashboard/posts' });
400
+ vi.stubGlobal('navigation', makeNavigationStub());
401
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
402
+ vi.resetModules();
403
+ const { initRouter, nav } =
404
+ await import('../../../../src/client/js/state/router.svelte');
405
+ initRouter();
406
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
407
+ });
408
+ });
409
+
410
+ /*
411
+ //////////////////////////////
412
+ // navigate()
413
+ //////////////////////////////
414
+ */
415
+
416
+ describe('navigate()', () => {
417
+ afterEach(() => {
418
+ vi.unstubAllGlobals();
419
+ vi.resetModules();
420
+ });
421
+
422
+ it('calls navigation.navigate with the given path', async () => {
423
+ const navStub = makeNavigationStub();
424
+ vi.stubGlobal('location', { pathname: '/admin' });
425
+ vi.stubGlobal('navigation', navStub);
426
+ const { navigate } =
427
+ await import('../../../../src/client/js/state/router.svelte');
428
+ navigate('/admin/posts');
429
+ expect(navStub.navigate).toHaveBeenCalledWith('/admin/posts');
430
+ });
431
+ });
432
+
433
+ /*
434
+ //////////////////////////////
435
+ // initRouter — Navigation API listener registration
436
+ //////////////////////////////
437
+ */
438
+
439
+ describe('initRouter — navigate listener', () => {
440
+ afterEach(() => {
441
+ vi.unstubAllGlobals();
442
+ vi.resetModules();
443
+ });
444
+
445
+ it('intercepts /admin navigations and updates reactive route state', async () => {
446
+ const navStub = makeNavigationStub();
447
+ vi.stubGlobal('location', { pathname: '/admin' });
448
+ vi.stubGlobal('navigation', navStub);
449
+ vi.stubGlobal('window', {
450
+ addEventListener: vi.fn(),
451
+ confirm: vi.fn(() => true),
452
+ });
453
+ const { initRouter, nav } =
454
+ await import('../../../../src/client/js/state/router.svelte');
455
+ initRouter();
456
+
457
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts');
458
+ navStub.fire(event);
459
+ // handler must have been called to update route
460
+ expect(event.intercepted).toBe(true);
461
+ event.interceptHandler!();
462
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
463
+ });
464
+
465
+ it('does not intercept navigations outside /admin', async () => {
466
+ const navStub = makeNavigationStub();
467
+ vi.stubGlobal('location', { pathname: '/admin' });
468
+ vi.stubGlobal('navigation', navStub);
469
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
470
+ const { initRouter, nav } =
471
+ await import('../../../../src/client/js/state/router.svelte');
472
+ initRouter();
473
+
474
+ const event = makeFakeNavigateEvent('http://localhost/other-page');
475
+ navStub.fire(event);
476
+ expect(event.intercepted).toBe(false);
477
+ // route should remain at home
478
+ expect(nav.route).toEqual({ view: 'home' });
479
+ });
480
+
481
+ it('does not intercept hash-change events', async () => {
482
+ const navStub = makeNavigationStub();
483
+ vi.stubGlobal('location', { pathname: '/admin' });
484
+ vi.stubGlobal('navigation', navStub);
485
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
486
+ const { initRouter } =
487
+ await import('../../../../src/client/js/state/router.svelte');
488
+ initRouter();
489
+
490
+ const event = makeFakeNavigateEvent('http://localhost/admin#section', {
491
+ hashChange: true,
492
+ });
493
+ navStub.fire(event);
494
+ expect(event.intercepted).toBe(false);
495
+ });
496
+
497
+ it('does not intercept when canIntercept is false', async () => {
498
+ const navStub = makeNavigationStub();
499
+ vi.stubGlobal('location', { pathname: '/admin' });
500
+ vi.stubGlobal('navigation', navStub);
501
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
502
+ const { initRouter } =
503
+ await import('../../../../src/client/js/state/router.svelte');
504
+ initRouter();
505
+
506
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts', {
507
+ canIntercept: false,
508
+ });
509
+ navStub.fire(event);
510
+ expect(event.intercepted).toBe(false);
511
+ });
512
+ });
513
+
514
+ /*
515
+ //////////////////////////////
516
+ // initRouter — idempotence
517
+ //////////////////////////////
518
+ */
519
+
520
+ describe('initRouter — idempotent registration', () => {
521
+ afterEach(() => {
522
+ vi.unstubAllGlobals();
523
+ vi.resetModules();
524
+ });
525
+
526
+ it('only registers one navigate listener even when called multiple times', async () => {
527
+ const navStub = makeNavigationStub();
528
+ vi.stubGlobal('location', { pathname: '/admin' });
529
+ vi.stubGlobal('navigation', navStub);
530
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
531
+ const { initRouter } =
532
+ await import('../../../../src/client/js/state/router.svelte');
533
+
534
+ initRouter();
535
+ initRouter();
536
+ initRouter();
537
+
538
+ // addEventListener('navigate', ...) must have been called exactly once
539
+ const navigateCalls = navStub.addEventListener.mock.calls.filter(
540
+ ([type]: [string]) => type === 'navigate',
541
+ );
542
+ expect(navigateCalls).toHaveLength(1);
543
+ });
544
+ });
545
+
546
+ /*
547
+ //////////////////////////////
548
+ // registerDirtyChecker — navigation interception
549
+ //////////////////////////////
550
+ */
551
+
552
+ describe('registerDirtyChecker', () => {
553
+ afterEach(() => {
554
+ vi.unstubAllGlobals();
555
+ vi.resetModules();
556
+ });
557
+
558
+ it('blocks navigation when dirty and user cancels confirm', async () => {
559
+ const navStub = makeNavigationStub();
560
+ const confirmSpy = vi.fn(() => false); // user clicks "Cancel"
561
+ vi.stubGlobal('location', { pathname: '/admin' });
562
+ vi.stubGlobal('navigation', navStub);
563
+ vi.stubGlobal('confirm', confirmSpy);
564
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
565
+ const { initRouter, registerDirtyChecker, nav } =
566
+ await import('../../../../src/client/js/state/router.svelte');
567
+ initRouter();
568
+ registerDirtyChecker(() => true);
569
+
570
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts');
571
+ navStub.fire(event);
572
+
573
+ expect(confirmSpy).toHaveBeenCalled();
574
+ expect(event.preventDefault).toHaveBeenCalled();
575
+ expect(event.intercepted).toBe(false);
576
+ // Route must NOT have changed
577
+ expect(nav.route).toEqual({ view: 'home' });
578
+ });
579
+
580
+ it('allows navigation when dirty but user confirms', async () => {
581
+ const navStub = makeNavigationStub();
582
+ const confirmSpy = vi.fn(() => true); // user clicks "OK"
583
+ vi.stubGlobal('location', { pathname: '/admin' });
584
+ vi.stubGlobal('navigation', navStub);
585
+ vi.stubGlobal('confirm', confirmSpy);
586
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
587
+ const { initRouter, registerDirtyChecker, nav } =
588
+ await import('../../../../src/client/js/state/router.svelte');
589
+ initRouter();
590
+ registerDirtyChecker(() => true);
591
+
592
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts');
593
+ navStub.fire(event);
594
+ event.interceptHandler!();
595
+
596
+ expect(event.intercepted).toBe(true);
597
+ expect(event.preventDefault).not.toHaveBeenCalled();
598
+ expect(nav.route).toEqual({ view: 'collection', collection: 'posts' });
599
+ });
600
+
601
+ it('skips the confirm when the editor is not dirty', async () => {
602
+ const navStub = makeNavigationStub();
603
+ const confirmSpy = vi.fn(() => false);
604
+ vi.stubGlobal('location', { pathname: '/admin' });
605
+ vi.stubGlobal('navigation', navStub);
606
+ vi.stubGlobal('confirm', confirmSpy);
607
+ vi.stubGlobal('window', { addEventListener: vi.fn() });
608
+ const { initRouter, registerDirtyChecker } =
609
+ await import('../../../../src/client/js/state/router.svelte');
610
+ initRouter();
611
+ registerDirtyChecker(() => false);
612
+
613
+ const event = makeFakeNavigateEvent('http://localhost/admin/posts');
614
+ navStub.fire(event);
615
+
616
+ expect(confirmSpy).not.toHaveBeenCalled();
617
+ expect(event.intercepted).toBe(true);
618
+ });
619
+ });