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,295 @@
1
+ /*
2
+ * Astro integration entry point for Nebula CMS.
3
+ * Exposes content collection JSON schemas and CMS configuration to
4
+ * client-side JavaScript via virtual modules, dev middleware, and
5
+ * build-time file copy.
6
+ */
7
+
8
+ import {
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ readdirSync,
14
+ } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import type { AstroIntegration, AstroIntegrationLogger } from 'astro';
18
+ import type { NebulaCMSConfig } from '../types.js';
19
+
20
+ // Vite virtual module IDs
21
+ const CONFIG_VIRTUAL_ID = 'virtual:nebula/config';
22
+ const CONFIG_RESOLVED_ID = '\0' + CONFIG_VIRTUAL_ID;
23
+ const COLLECTIONS_VIRTUAL_ID = 'virtual:nebula/collections';
24
+ const COLLECTIONS_RESOLVED_ID = '\0' + COLLECTIONS_VIRTUAL_ID;
25
+
26
+ /**
27
+ * Normalizes a path config value to an absolute path using the URL API.
28
+ * Handles leading-slash prepending, consecutive-slash collapsing,
29
+ * trailing-slash stripping, and rejects paths with characters that
30
+ * require percent-encoding (e.g. spaces, angle brackets).
31
+ * @param {string} label - Config field name for error messages (e.g. 'basePath')
32
+ * @param {string} value - The raw config value
33
+ * @return {string} The normalized absolute path
34
+ */
35
+ function normalizePath(label: string, value: string): string {
36
+ /*
37
+ * Reject protocol-relative inputs ('//admin') — the URL API interprets
38
+ * these as hostnames, silently producing pathname '/' instead of '/admin'.
39
+ */
40
+ if (value.startsWith('//')) {
41
+ throw new Error(
42
+ `Invalid ${label} "${value}". Path must not start with "//".`,
43
+ );
44
+ }
45
+ /*
46
+ * URL constructor with a dummy base handles both relative ('admin') and
47
+ * absolute ('/admin') inputs identically. Collapse consecutive slashes
48
+ * manually since the URL API preserves them in pathnames.
49
+ */
50
+ const collapsed = new URL(value, 'http://x').pathname.replace(/\/\/+/g, '/');
51
+ // Strip trailing slash unless root
52
+ const normalized =
53
+ collapsed.length > 1 && collapsed.endsWith('/')
54
+ ? collapsed.slice(0, -1)
55
+ : collapsed;
56
+ /*
57
+ * The URL API percent-encodes special characters rather than rejecting them
58
+ * (e.g. '/admin/<script>' → '/admin/%3Cscript%3E'). Comparing the decoded
59
+ * form to the normalized result catches any input that required encoding.
60
+ */
61
+ if (decodeURIComponent(normalized) !== normalized) {
62
+ throw new Error(
63
+ `Invalid ${label} "${value}". Path contains characters that require URL encoding.`,
64
+ );
65
+ }
66
+ return normalized;
67
+ }
68
+
69
+ /**
70
+ * Astro integration that exposes CMS configuration and content collection JSON schemas to client-side JavaScript via virtual modules, dev middleware, and build-time file copy.
71
+ * @param {NebulaCMSConfig} config - Optional configuration object
72
+ * @return {AstroIntegration} The configured Astro integration object
73
+ */
74
+ export default function NebulaCMS(
75
+ config: NebulaCMSConfig = {},
76
+ ): AstroIntegration {
77
+ if (config.basePath === '') {
78
+ throw new Error(
79
+ 'Invalid basePath "". Provide a path like "/admin" or "/".',
80
+ );
81
+ }
82
+
83
+ if (config.collectionsPath === '') {
84
+ throw new Error(
85
+ 'Invalid collectionsPath "". Provide a path like "/collections".',
86
+ );
87
+ }
88
+
89
+ const basePath = normalizePath('basePath', config.basePath ?? '/admin');
90
+ const collectionsPath = normalizePath(
91
+ 'collectionsPath',
92
+ config.collectionsPath ?? '/collections',
93
+ );
94
+
95
+ if (collectionsPath === '/') {
96
+ throw new Error(
97
+ 'Invalid collectionsPath "/". Collections require a path prefix.',
98
+ );
99
+ }
100
+
101
+ // Normalized config passed to the Vite plugin
102
+ const normalizedConfig = { basePath, collectionsPath };
103
+
104
+ return {
105
+ name: 'nebula-cms',
106
+ hooks: {
107
+ 'astro:config:setup': ({ updateConfig, logger }) => {
108
+ updateConfig({
109
+ vite: {
110
+ plugins: [
111
+ nebulaVitePlugin(logger, process.cwd(), normalizedConfig),
112
+ ],
113
+ /*
114
+ * Workers use dynamic imports (e.g. storage worker lazy-loads
115
+ * adapters), which require code splitting. The default 'iife'
116
+ * format does not support code splitting, so use ES modules.
117
+ */
118
+ worker: { format: 'es' },
119
+ /*
120
+ * smol-toml is only imported inside the TOML parser sub-worker,
121
+ * never on the main thread. Without this, Vite discovers it late
122
+ * and re-optimizes mid-session, causing the worker to request a
123
+ * stale dep hash (504 Outdated Optimize Dep).
124
+ */
125
+ optimizeDeps: {
126
+ include: ['smol-toml'],
127
+ },
128
+ },
129
+ });
130
+ },
131
+ // Copy schema files into the build output after Astro finishes.
132
+ 'astro:build:done': ({ dir, logger }) => {
133
+ const source = resolve(process.cwd(), '.astro/collections');
134
+ if (!existsSync(source)) {
135
+ logger.warn(
136
+ '`.astro/collections` not found — schema files will not be in the build output.',
137
+ );
138
+ return;
139
+ }
140
+ const outDir = fileURLToPath(dir);
141
+ // Strip leading slash for filesystem path resolution
142
+ const target = resolve(outDir, collectionsPath.slice(1));
143
+ mkdirSync(target, { recursive: true });
144
+ const files = readdirSync(source).filter((f) =>
145
+ f.endsWith('.schema.json'),
146
+ );
147
+ for (const f of files) {
148
+ copyFileSync(resolve(source, f), resolve(target, f));
149
+ }
150
+ },
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Vite plugin that serves collection schemas and CMS config via virtual modules.
157
+ * @internal Not part of the public API — exported for testing only
158
+ * @param {AstroIntegrationLogger} logger - Astro integration logger for warnings
159
+ * @param {string} root - Project root directory
160
+ * @param {Required<NebulaCMSConfig>} config - Normalized CMS configuration (both paths absolute, no trailing slash)
161
+ * @return {object} A Vite plugin object with configureServer, resolveId, and load hooks
162
+ */
163
+ export function nebulaVitePlugin(
164
+ logger: AstroIntegrationLogger,
165
+ root: string,
166
+ config: Required<NebulaCMSConfig>,
167
+ ) {
168
+ return {
169
+ name: 'vite-plugin-nebula-cms',
170
+
171
+ /**
172
+ * Registers dev middleware: serves schema files from .astro/collections/
173
+ * and rewrites SPA sub-routes under basePath to the basePath page.
174
+ * @param {{ middlewares: { use: Function } }} server - The Vite dev server
175
+ * @return {void}
176
+ */
177
+ configureServer(server: { middlewares: { use: Function } }) {
178
+ const prefix = config.collectionsPath + '/';
179
+ const collectionsDir = resolve(root, '.astro/collections');
180
+
181
+ // Serve collection schema JSON files
182
+ server.middlewares.use(
183
+ (
184
+ req: { url?: string },
185
+ res: { setHeader: Function; end: Function },
186
+ next: Function,
187
+ ) => {
188
+ // Extract pathname, stripping query strings and fragments
189
+ const url = new URL(req.url ?? '', 'http://x').pathname;
190
+ if (!url.startsWith(prefix) || !url.endsWith('.schema.json')) {
191
+ return next();
192
+ }
193
+ const filename = url.slice(prefix.length);
194
+ const filePath = resolve(collectionsDir, filename);
195
+ // Reject path traversal attempts (e.g. /../../../etc/passwd.schema.json)
196
+ if (!filePath.startsWith(collectionsDir + '/')) return next();
197
+
198
+ try {
199
+ res.setHeader('Content-Type', 'application/json');
200
+ res.end(readFileSync(filePath, 'utf-8'));
201
+ } catch (err: unknown) {
202
+ // File not found — fall through to Vite's default handler
203
+ if (
204
+ err instanceof Error &&
205
+ (err as NodeJS.ErrnoException).code === 'ENOENT'
206
+ ) {
207
+ return next();
208
+ }
209
+ // Re-throw permission errors, I/O errors, etc. so they surface
210
+ throw err;
211
+ }
212
+ },
213
+ );
214
+
215
+ // SPA fallback: rewrite HTML requests under basePath to basePath
216
+ server.middlewares.use(
217
+ (
218
+ req: { url?: string; headers?: Record<string, string | undefined> },
219
+ _res: unknown,
220
+ next: Function,
221
+ ) => {
222
+ const rawURL = req.url ?? '';
223
+ const accept = req.headers?.accept ?? '';
224
+
225
+ // Only rewrite document requests
226
+ if (!accept.includes('text/html')) return next();
227
+
228
+ const parsed = new URL(rawURL, 'http://x');
229
+
230
+ /*
231
+ * Check segment boundary: /admin/foo rewrites, /administrator does not.
232
+ * Root basePath '/' needs special handling — every path is a sub-path
233
+ * except '/' itself, mirroring router.svelte.ts isUnderBasePath.
234
+ */
235
+ const isSubPath =
236
+ config.basePath === '/'
237
+ ? parsed.pathname !== '/' && parsed.pathname.startsWith('/')
238
+ : parsed.pathname !== config.basePath &&
239
+ parsed.pathname.startsWith(config.basePath + '/');
240
+
241
+ if (isSubPath) {
242
+ // Preserve query string and hash for deep-linking and OAuth callbacks
243
+ req.url = config.basePath + parsed.search + parsed.hash;
244
+ }
245
+
246
+ return next();
247
+ },
248
+ );
249
+ },
250
+
251
+ /**
252
+ * Resolves virtual:nebula/* imports to Vite-internal IDs.
253
+ * @param {string} id - The module ID being resolved
254
+ * @return {string | undefined} The resolved internal ID, or undefined if not handled
255
+ */
256
+ resolveId(id: string) {
257
+ if (id === CONFIG_VIRTUAL_ID) return CONFIG_RESOLVED_ID;
258
+ if (id === COLLECTIONS_VIRTUAL_ID) return COLLECTIONS_RESOLVED_ID;
259
+ },
260
+
261
+ /**
262
+ * Generates virtual module source code for config and collections.
263
+ * @param {string} id - The resolved module ID to load
264
+ * @return {string | undefined} Generated module source code, or undefined if not handled
265
+ */
266
+ load(id: string) {
267
+ if (id === CONFIG_RESOLVED_ID) {
268
+ return `export default ${JSON.stringify(config)};`;
269
+ }
270
+
271
+ if (id !== COLLECTIONS_RESOLVED_ID) return;
272
+
273
+ const collectionsDir = resolve(root, '.astro/collections');
274
+
275
+ // Guard: return empty object if directory doesn't exist
276
+ if (!existsSync(collectionsDir)) {
277
+ logger.warn(
278
+ '`.astro/collections` not found — virtual:nebula/collections will be empty.',
279
+ );
280
+ return 'export default {};';
281
+ }
282
+
283
+ const files = readdirSync(collectionsDir).filter((f) =>
284
+ f.endsWith('.schema.json'),
285
+ );
286
+
287
+ const entries = files.map((f) => {
288
+ const name = f.replace('.schema.json', '');
289
+ return ` ${JSON.stringify(name)}: ${JSON.stringify(config.collectionsPath + '/' + f)}`;
290
+ });
291
+
292
+ return `export default {\n${entries.join(',\n')}\n};`;
293
+ },
294
+ };
295
+ }
@@ -0,0 +1,283 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { initRouter, nav, adminPath } from './js/state/router.svelte';
4
+ import {
5
+ backend,
6
+ content,
7
+ drafts,
8
+ restoreBackend,
9
+ loadCollection,
10
+ } from './js/state/state.svelte';
11
+ import {
12
+ preloadFile,
13
+ loadFileBody,
14
+ clearEditor,
15
+ editor,
16
+ getEditorFile,
17
+ setDefaultFormat,
18
+ } from './js/editor/editor.svelte';
19
+ import { loadDraftById } from './js/drafts/ops.svelte';
20
+ import {
21
+ fetchSchema,
22
+ schema,
23
+ clearSchema,
24
+ prefetchAllSchemas,
25
+ collectionHasDates,
26
+ getCollectionTitle,
27
+ } from './js/state/schema.svelte';
28
+ import {
29
+ handleDeleteDraft,
30
+ handleFilenameConfirm,
31
+ buildContentItems,
32
+ buildCollectionItems,
33
+ buildActiveFileHref,
34
+ } from './js/handlers/admin';
35
+ import { dialog } from './js/state/dialogs.svelte';
36
+ import { stripExtension } from './js/utils/file-types';
37
+ import { initTheme, theme } from './js/state/theme.svelte';
38
+ import './css/reset.css';
39
+ import './css/icons.css';
40
+ import './css/theme.css';
41
+ import './css/btn.css';
42
+ import './css/field-input.css';
43
+ import './css/dialog.css';
44
+ import './css/a11y.css';
45
+ import BackendPicker from './components/BackendPicker.svelte';
46
+ import AdminSidebar from './components/sidebar/AdminSidebar.svelte';
47
+ import EditorToolbar from './components/editor/EditorToolbar.svelte';
48
+ import EditorPane from './components/editor/EditorPane.svelte';
49
+ import EditorTabs from './components/editor/EditorTabs.svelte';
50
+ import MetadataForm from './components/MetadataForm.svelte';
51
+ import FilenameDialog from './components/dialogs/FilenameDialog.svelte';
52
+ import DeleteDraftDialog from './components/dialogs/DeleteDraftDialog.svelte';
53
+
54
+ // Whether a collection is currently selected (including draft view)
55
+ const hasCollection = $derived(nav.route.view !== 'home');
56
+
57
+ // The active collection name, if any
58
+ const activeCollection = $derived(
59
+ nav.route.view !== 'home' ? nav.route.collection : null,
60
+ );
61
+
62
+ // Whether a file or draft is currently open in the editor
63
+ const fileOpen = $derived(
64
+ nav.route.view === 'file' || nav.route.view === 'draft',
65
+ );
66
+
67
+ // The active file/draft href for highlighting in the content sidebar
68
+ const activeFileHref = $derived(buildActiveFileHref(nav.route));
69
+
70
+ // Collection names mapped to SidebarItems, using schema title/description when available
71
+ const collectionItems = $derived(buildCollectionItems());
72
+
73
+ // Content items merged with draft data (DRAFT/OUTDATED chips) plus new draft items
74
+ const contentItems = $derived(
75
+ buildContentItems(
76
+ content.list,
77
+ drafts.all,
78
+ drafts.outdated,
79
+ activeCollection,
80
+ ),
81
+ );
82
+
83
+ // Whether the active collection has date fields for sort controls
84
+ const contentHasDates = $derived(
85
+ activeCollection ? collectionHasDates(activeCollection) : false,
86
+ );
87
+
88
+ // Existing filenames for uniqueness validation in the filename dialog — includes both live files and drafts with filenames
89
+ const existingFilenames = $derived([
90
+ ...content.list.map((item) => item.filename),
91
+ ...drafts.all.filter((d) => d.filename).map((d) => d.filename!),
92
+ ]);
93
+
94
+ // Type identifiers from the schema's files array, used to show the format selector
95
+ const schemaFileTypes = $derived(
96
+ Array.isArray(schema.active?.['files'])
97
+ ? (schema.active['files'] as string[])
98
+ : [],
99
+ );
100
+
101
+ // Sync the resolved theme to :root so top-layer elements (dialogs) inherit the tokens
102
+ $effect(() => {
103
+ document.documentElement.dataset.theme = theme.resolved;
104
+ });
105
+
106
+ // Trigger collection loading when route changes to a collection, file, or draft view
107
+ $effect(() => {
108
+ if (backend.ready && nav.route.view !== 'home') {
109
+ loadCollection(nav.route.collection);
110
+ }
111
+ });
112
+
113
+ /*
114
+ * Loads content for the file or draft view. Both branches gate on `backend.ready`
115
+ * so they re-run when the directory handle is restored on page load.
116
+ */
117
+ $effect(() => {
118
+ if (backend.ready && nav.route.view === 'file' && content.list.length > 0) {
119
+ const item = content.list.find(
120
+ (i) => stripExtension(i.filename) === nav.route.slug,
121
+ );
122
+ if (!item) return;
123
+
124
+ // preloadFile is async — it checks IDB for a draft first
125
+ preloadFile(nav.route.collection, item.filename, item.data).then(() => {
126
+ // If preloadFile loaded a draft (body already present), skip disk read
127
+ const editorFile = getEditorFile();
128
+ if (editorFile?.draftId) return;
129
+
130
+ loadFileBody(nav.route.collection, item.filename);
131
+ });
132
+ } else if (backend.ready && nav.route.view === 'draft') {
133
+ loadDraftById(nav.route.draftId, nav.route.collection);
134
+ } else if (nav.route.view !== 'file' && nav.route.view !== 'draft') {
135
+ clearEditor();
136
+ }
137
+ });
138
+
139
+ /*
140
+ * Set the default format for new drafts once the schema is available.
141
+ * New drafts start with an empty filename, so setDefaultFormat assigns
142
+ * the collection's first file type extension (e.g. '.mdx' for guides).
143
+ */
144
+ $effect(() => {
145
+ const file = getEditorFile();
146
+ if (file?.isNewDraft && !file.filename && schemaFileTypes.length > 0) {
147
+ setDefaultFormat(schemaFileTypes);
148
+ }
149
+ });
150
+
151
+ // Fetch the JSON Schema when the active collection changes
152
+ $effect(() => {
153
+ if (backend.ready && nav.route.view !== 'home') {
154
+ fetchSchema(nav.route.collection);
155
+ } else {
156
+ clearSchema();
157
+ }
158
+ });
159
+
160
+ /**
161
+ * Handles filename dialog confirmation — hides the dialog and triggers publish with the chosen filename.
162
+ * @param {string} filename - The chosen filename
163
+ * @return {Promise<void>}
164
+ */
165
+ async function onFilenameConfirm(filename: string): Promise<void> {
166
+ dialog.close();
167
+ await handleFilenameConfirm(filename, activeCollection);
168
+ }
169
+
170
+ /**
171
+ * Handles delete draft confirmation — hides the dialog and deletes the current draft.
172
+ * @return {Promise<void>}
173
+ */
174
+ async function onDeleteConfirm(): Promise<void> {
175
+ dialog.close();
176
+ await handleDeleteDraft(activeCollection);
177
+ }
178
+
179
+ onMount(() => {
180
+ const cleanupTheme = initTheme();
181
+ initRouter();
182
+ restoreBackend();
183
+ prefetchAllSchemas();
184
+ return cleanupTheme;
185
+ });
186
+ </script>
187
+
188
+ <div
189
+ class="admin"
190
+ class:admin--connected={backend.ready}
191
+ class:admin--collection={backend.ready && hasCollection}
192
+ class:admin--file-open={backend.ready && fileOpen}
193
+ >
194
+ {#if !backend.ready}
195
+ <BackendPicker />
196
+ {:else}
197
+ <AdminSidebar
198
+ title="Collections"
199
+ items={collectionItems}
200
+ activeItem={activeCollection ? adminPath(activeCollection) : undefined}
201
+ showFooter={true}
202
+ />
203
+ {#if hasCollection && activeCollection}
204
+ <AdminSidebar
205
+ title={getCollectionTitle(activeCollection) ??
206
+ activeCollection.charAt(0).toUpperCase() + activeCollection.slice(1)}
207
+ items={contentItems}
208
+ activeItem={activeFileHref}
209
+ storageKey={activeCollection}
210
+ loading={content.loading}
211
+ error={content.error ?? undefined}
212
+ hasDates={contentHasDates}
213
+ collection={activeCollection}
214
+ showAdd={true}
215
+ />
216
+ {/if}
217
+ {#if fileOpen}
218
+ <div class="editor-area">
219
+ <EditorToolbar />
220
+ <EditorTabs schema={schema.active} />
221
+ <div class="editor-content">
222
+ {#if editor.tab === 'body'}
223
+ <EditorPane />
224
+ {:else if schema.active}
225
+ <MetadataForm
226
+ schema={schema.active}
227
+ tab={editor.tab === 'metadata' ? null : editor.tab}
228
+ />
229
+ {/if}
230
+ </div>
231
+ </div>
232
+ {/if}
233
+ {/if}
234
+ </div>
235
+
236
+ {#if dialog.active === 'filename'}
237
+ {@const file = getEditorFile()}
238
+ <FilenameDialog
239
+ title={typeof file?.formData.title === 'string' ? file.formData.title : ''}
240
+ {existingFilenames}
241
+ onConfirm={onFilenameConfirm}
242
+ onCancel={dialog.close}
243
+ />
244
+ {/if}
245
+
246
+ {#if dialog.active === 'delete'}
247
+ <DeleteDraftDialog onConfirm={onDeleteConfirm} onCancel={dialog.close} />
248
+ {/if}
249
+
250
+ <style>
251
+ .admin {
252
+ /* Lock to viewport height so the page never scrolls — all scrolling happens inside editor-content or sidebars */
253
+ height: 100dvh;
254
+ }
255
+
256
+ .admin--connected {
257
+ display: grid;
258
+ grid-template-columns: 15rem 1fr;
259
+ }
260
+
261
+ .admin--collection {
262
+ grid-template-columns: 15rem 15rem 1fr;
263
+ }
264
+
265
+ .admin--file-open {
266
+ grid-template-columns: 15rem 15rem 1fr;
267
+ }
268
+
269
+ .editor-area {
270
+ display: grid;
271
+ /* Toolbar + tabs above, scrollable content below */
272
+ grid-template-rows: auto auto 1fr;
273
+ overflow: hidden;
274
+ border-left: 1px solid var(--cms-border);
275
+ }
276
+
277
+ /* Scrollable content area; min-height: 0 allows the 1fr grid row to shrink */
278
+ .editor-content {
279
+ overflow-y: auto;
280
+ overflow-x: hidden;
281
+ min-height: 0;
282
+ }
283
+ </style>