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,140 @@
1
+ /*
2
+ * Reactive schema state for the admin SPA.
3
+ * Fetches, caches, and exposes JSON Schemas for content collections.
4
+ */
5
+
6
+ import schemas from 'virtual:nebula/collections';
7
+ import { getExtensionsForSchema } from '../utils/file-types';
8
+
9
+ // JSON Schema object type.
10
+ type JsonSchema = Record<string, unknown>;
11
+
12
+ // Cache of fetched schemas keyed by collection name
13
+ const cache = new Map<string, JsonSchema>();
14
+
15
+ // Currently active schema for the selected collection
16
+ let activeSchema = $state<JsonSchema | null>(null);
17
+
18
+ export const schema = {
19
+ // The loaded JSON Schema for the active collection, or null.
20
+ get active(): JsonSchema | null {
21
+ return activeSchema;
22
+ },
23
+ };
24
+
25
+ /**
26
+ * Fetches all collection schemas in parallel and caches them.
27
+ * Call once on app startup so schema-derived state is available before the first collection renders.
28
+ * @return {Promise<void>}
29
+ */
30
+ export async function prefetchAllSchemas(): Promise<void> {
31
+ const entries = Object.entries(schemas);
32
+ const results = await Promise.all(
33
+ entries.map(async ([name, url]) => {
34
+ const response = await fetch(url);
35
+ const data = (await response.json()) as JsonSchema;
36
+ return [name, data] as const;
37
+ }),
38
+ );
39
+ for (const [name, data] of results) {
40
+ cache.set(name, data);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Fetches and caches the JSON Schema for a collection, then sets the reactive schema state.
46
+ * @param {string} collection - The collection name to fetch the schema for
47
+ * @return {Promise<void>}
48
+ */
49
+ export async function fetchSchema(collection: string): Promise<void> {
50
+ const cached = cache.get(collection);
51
+ if (cached) {
52
+ activeSchema = cached;
53
+ return;
54
+ }
55
+
56
+ const url = schemas[collection];
57
+ if (!url) return;
58
+
59
+ const response = await fetch(url);
60
+ const data = (await response.json()) as JsonSchema;
61
+ cache.set(collection, data);
62
+ activeSchema = data;
63
+ }
64
+
65
+ /**
66
+ * Returns true if the collection's schema has a date-time property, indicating it supports date-based sorting.
67
+ * Requires prefetchAllSchemas to have been called; returns false if the schema isn't cached yet.
68
+ * @param {string} collection - The collection name to check
69
+ * @return {boolean} True if any property in the schema uses the date-time format
70
+ */
71
+ export function collectionHasDates(collection: string): boolean {
72
+ const s = cache.get(collection);
73
+ if (!s) return false;
74
+ const props = s['properties'] as Record<string, JsonSchema> | undefined;
75
+ if (!props) return false;
76
+ return Object.values(props).some((p) => p['format'] === 'date-time');
77
+ }
78
+
79
+ /**
80
+ * Reads a top-level string field from a collection's cached schema.
81
+ * Returns null if the schema isn't cached or the field is absent/non-string.
82
+ * @param {string} collection - The collection name
83
+ * @param {string} field - The schema field to read (e.g. 'title', 'description')
84
+ * @return {string | null} The field value, or null
85
+ */
86
+ function getStringField(collection: string, field: string): string | null {
87
+ const s = cache.get(collection);
88
+ if (!s) return null;
89
+ const value = s[field];
90
+ return typeof value === 'string' ? value : null;
91
+ }
92
+
93
+ /**
94
+ * Returns the display title for a collection from its cached schema.
95
+ * Returns null if the schema hasn't been fetched or has no title.
96
+ * @param {string} collection - The collection name
97
+ * @return {string | null} The schema title, or null
98
+ */
99
+ export function getCollectionTitle(collection: string): string | null {
100
+ return getStringField(collection, 'title');
101
+ }
102
+
103
+ /**
104
+ * Returns the description for a collection from its cached schema.
105
+ * Returns null if the schema hasn't been fetched or has no description.
106
+ * @param {string} collection - The collection name
107
+ * @return {string | null} The schema description, or null
108
+ */
109
+ export function getCollectionDescription(collection: string): string | null {
110
+ return getStringField(collection, 'description');
111
+ }
112
+
113
+ /*
114
+ * Known race condition: if a caller invokes getSchemaExtensions before
115
+ * prefetchAllSchemas() has completed, the schema won't be cached yet and the
116
+ * fallback is returned. The caller (dispatchWorker in state.svelte.ts)
117
+ * mitigates this by awaiting initPromise, which includes prefetchAllSchemas().
118
+ * If the ordering changes, callers should await prefetchAllSchemas() first.
119
+ */
120
+
121
+ /**
122
+ * Returns the resolved file extensions for a collection from its cached schema.
123
+ * Falls back to ['.md', '.mdx'] if the schema hasn't been fetched or declares no files.
124
+ * @param {string} collection - The collection name
125
+ * @return {string[]} Array of file extensions
126
+ */
127
+ export function getSchemaExtensions(collection: string): string[] {
128
+ const s = cache.get(collection);
129
+ if (!s) return ['.md', '.mdx'];
130
+ const extensions = getExtensionsForSchema(s);
131
+ return extensions.length > 0 ? extensions : ['.md', '.mdx'];
132
+ }
133
+
134
+ /**
135
+ * Clears the active schema.
136
+ * @return {void}
137
+ */
138
+ export function clearSchema(): void {
139
+ activeSchema = null;
140
+ }
@@ -0,0 +1,334 @@
1
+ /*
2
+ * Global application state for the admin SPA.
3
+ * Manages backend connection, schema loading, collections, and navigation state.
4
+ */
5
+
6
+ import schemas from 'virtual:nebula/collections';
7
+ import {
8
+ loadBackend,
9
+ saveBackend,
10
+ clearBackend,
11
+ type BackendConfig,
12
+ } from '../storage/storage';
13
+ import { StorageClient } from '../storage/client';
14
+ import { nav, navigate, adminPath } from './router.svelte';
15
+ import {
16
+ drafts,
17
+ mergeDrafts,
18
+ refreshDrafts,
19
+ resetDraftMerge,
20
+ } from '../drafts/merge.svelte';
21
+ import { getSchemaExtensions } from './schema.svelte';
22
+
23
+ export { drafts, refreshDrafts };
24
+
25
+ // Content item with full frontmatter data returned by the worker.
26
+ export type ContentItem = {
27
+ filename: string;
28
+ data: Record<string, unknown>;
29
+ };
30
+
31
+ // Permission state for the stored FSA directory handle.
32
+ type PermissionState = 'granted' | 'prompt' | 'denied';
33
+ // Backend type discriminator.
34
+ type BackendType = 'fsa' | 'github' | null;
35
+ // Sorted collection names derived from virtual:nebula/collections.
36
+ export const collections = Object.keys(schemas).sort();
37
+ /*
38
+ * Uses .js extension because svelte-package does not rewrite URL string literals;
39
+ * the dist output must reference the compiled .js file, not the source .ts file.
40
+ */
41
+ const sharedWorker = new SharedWorker(
42
+ new URL('../storage/workers/storage.js', import.meta.url),
43
+ { type: 'module', name: 'cms-storage' },
44
+ );
45
+ // Main-thread StorageClient for editor and draft-merge I/O.
46
+ export const storageClient = new StorageClient(sharedWorker.port);
47
+ let backendType = $state<BackendType>(null);
48
+ let backendReady = $state(false);
49
+ let permissionState = $state<PermissionState>('denied');
50
+ let contentList = $state<ContentItem[]>([]);
51
+ let error = $state<string | null>(null);
52
+ let loading = $state(false);
53
+
54
+ export const backend = {
55
+ // Active backend type, or null if not connected.
56
+ get type(): BackendType {
57
+ return backendType;
58
+ },
59
+ // True if a backend is initialized and ready.
60
+ get ready(): boolean {
61
+ return backendReady;
62
+ },
63
+ // FSA permission state for the re-auth flow in BackendPicker.
64
+ get permission(): PermissionState {
65
+ return permissionState;
66
+ },
67
+ };
68
+
69
+ export const content = {
70
+ // Parsed content items for the selected collection.
71
+ get list(): ContentItem[] {
72
+ return contentList;
73
+ },
74
+ // True if the frontmatter worker is actively parsing.
75
+ get loading(): boolean {
76
+ return loading;
77
+ },
78
+ // Error message for display in the sidebar.
79
+ get error(): string | null {
80
+ return error;
81
+ },
82
+ };
83
+ let worker: Worker | null = null;
84
+ let loadedCollection = '';
85
+ let initPromise: Promise<void> | null = null;
86
+ const contentCache = new Map<string, ContentItem[]>();
87
+
88
+ /**
89
+ * Initializes the frontmatter worker and bridges a port to the SharedWorker.
90
+ * @return {Worker}
91
+ */
92
+ function ensureWorker(): Worker {
93
+ if (worker) return worker;
94
+ // Uses .js extension because svelte-package does not rewrite URL string literals
95
+ worker = new Worker(
96
+ new URL('../storage/workers/frontmatter.js', import.meta.url),
97
+ { type: 'module' },
98
+ );
99
+ worker.addEventListener('message', (event) => {
100
+ const data = event.data;
101
+ if (data.type === 'result') {
102
+ const forCollection = data.collection as string;
103
+ // Always cache under the correct collection
104
+ contentCache.set(forCollection, data.items);
105
+ // Only update the visible list if this result is for the current collection
106
+ if (forCollection === loadedCollection) {
107
+ contentList = data.items;
108
+ loading = false;
109
+ error = null;
110
+ if (backendReady) mergeDrafts(forCollection);
111
+ }
112
+ } else if (data.type === 'error') {
113
+ error = data.message;
114
+ loading = false;
115
+ contentList = [];
116
+ }
117
+ });
118
+
119
+ // Bridge a port so the frontmatter worker can talk to the storage SharedWorker
120
+ const channel = new MessageChannel();
121
+ worker.postMessage({ type: 'port' }, [channel.port1]);
122
+ sharedWorker.port.postMessage({ type: 'connect-port' }, [channel.port2]);
123
+
124
+ return worker;
125
+ }
126
+
127
+ /**
128
+ * Sends a parse request to the frontmatter worker. Refresh mode keeps current sidebar visible.
129
+ * @param {string} collection - The collection name to parse
130
+ * @param {boolean} refresh - If true, keep current contentList and skip loading state
131
+ * @return {Promise<void>}
132
+ */
133
+ async function dispatchWorker(
134
+ collection: string,
135
+ refresh = false,
136
+ ): Promise<void> {
137
+ if (!backendReady) return;
138
+ if (!refresh) {
139
+ loading = true;
140
+ contentList = [];
141
+ }
142
+ error = null;
143
+ // Wait for the SharedWorker adapter to be ready before dispatching
144
+ if (initPromise) await initPromise;
145
+ const w = ensureWorker();
146
+ const extensions = getSchemaExtensions(collection);
147
+ w.postMessage({ type: 'parse', collection, extensions });
148
+ }
149
+
150
+ /**
151
+ * Restores a stored backend config from IndexedDB and initializes the SharedWorker.
152
+ * @return {Promise<void>}
153
+ */
154
+ export async function restoreBackend(): Promise<void> {
155
+ try {
156
+ const config = await loadBackend();
157
+ if (!config) {
158
+ backendType = null;
159
+ backendReady = false;
160
+ return;
161
+ }
162
+
163
+ if (config.type === 'fsa') {
164
+ // Check FSA permission state
165
+ const perm = await config.handle.queryPermission({ mode: 'readwrite' });
166
+ permissionState = perm;
167
+ backendType = 'fsa';
168
+
169
+ if (perm === 'granted') {
170
+ await storageClient.init({ type: 'init', backend: config });
171
+ backendReady = true;
172
+ navigateToFirstCollectionIfHome();
173
+ }
174
+ // If perm is 'prompt', BackendPicker shows re-auth button
175
+ } else {
176
+ // GitHub — show UI optimistically, validate in background
177
+ backendType = 'github';
178
+ backendReady = true;
179
+ navigateToFirstCollectionIfHome();
180
+ initPromise = storageClient
181
+ .init({ type: 'init', backend: config })
182
+ .catch(async () => {
183
+ await clearBackend();
184
+ backendType = null;
185
+ backendReady = false;
186
+ contentList = [];
187
+ contentCache.clear();
188
+ loadedCollection = '';
189
+ navigate(adminPath());
190
+ })
191
+ .finally(() => {
192
+ initPromise = null;
193
+ });
194
+ }
195
+ } catch {
196
+ backendType = null;
197
+ backendReady = false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Re-requests FSA permission. Must be called from a user gesture.
203
+ * @return {Promise<void>}
204
+ */
205
+ export async function requestPermission(): Promise<void> {
206
+ const config = await loadBackend();
207
+ if (!config || config.type !== 'fsa') return;
208
+ try {
209
+ const perm = await config.handle.requestPermission({ mode: 'readwrite' });
210
+ permissionState = perm;
211
+ if (perm === 'granted') {
212
+ await storageClient.init({ type: 'init', backend: config });
213
+ backendReady = true;
214
+ navigateToFirstCollectionIfHome();
215
+ }
216
+ } catch {
217
+ permissionState = 'denied';
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Opens the directory picker and initializes the FSA backend. Must be called from a user gesture.
223
+ * @return {Promise<void>}
224
+ */
225
+ export async function pickDirectory(): Promise<void> {
226
+ try {
227
+ const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
228
+ const config: BackendConfig = { type: 'fsa', handle };
229
+ await storageClient.init({ type: 'init', backend: config });
230
+ await saveBackend(config);
231
+ backendType = 'fsa';
232
+ permissionState = 'granted';
233
+ backendReady = true;
234
+ navigateToFirstCollectionIfHome();
235
+ } catch (err) {
236
+ if (err instanceof DOMException && err.name === 'AbortError') return;
237
+ error = err instanceof Error ? err.message : String(err);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Connects to a GitHub repository via PAT and persists the config.
243
+ * @param {string} token - GitHub Personal Access Token
244
+ * @param {string} repo - Repository in "owner/repo" format
245
+ * @return {Promise<void>}
246
+ */
247
+ export async function connectGitHub(
248
+ token: string,
249
+ repo: string,
250
+ ): Promise<void> {
251
+ const config: BackendConfig = { type: 'github', token, repo };
252
+ await storageClient.init({ type: 'init', backend: config });
253
+ await saveBackend(config);
254
+ backendType = 'github';
255
+ backendReady = true;
256
+ navigateToFirstCollectionIfHome();
257
+ }
258
+
259
+ /**
260
+ * Disconnects the backend, clears credentials, and resets all state.
261
+ * @return {Promise<void>}
262
+ */
263
+ export async function disconnect(): Promise<void> {
264
+ worker?.terminate();
265
+ worker = null;
266
+ resetDraftMerge();
267
+ await storageClient.teardown();
268
+ await clearBackend();
269
+ backendType = null;
270
+ backendReady = false;
271
+ permissionState = 'denied';
272
+ contentList = [];
273
+ contentCache.clear();
274
+ loadedCollection = '';
275
+ error = null;
276
+ loading = false;
277
+ navigate(adminPath());
278
+ }
279
+
280
+ /**
281
+ * Navigates to the first collection if currently on the home view.
282
+ * @return {void}
283
+ */
284
+ function navigateToFirstCollectionIfHome(): void {
285
+ if (nav.route.view === 'home' && collections.length > 0) {
286
+ navigate(adminPath(collections[0]));
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Loads a collection. Serves from cache instantly if available, then background refreshes.
292
+ * @param {string} collection - The collection name to load
293
+ * @return {void}
294
+ */
295
+ export function loadCollection(collection: string): void {
296
+ if (collection === loadedCollection) return;
297
+ loadedCollection = collection;
298
+ const cached = contentCache.get(collection);
299
+ if (cached) {
300
+ // Serve cached items instantly, then refresh in background
301
+ contentList = cached;
302
+ refreshDrafts(collection);
303
+ dispatchWorker(collection, true);
304
+ } else {
305
+ dispatchWorker(collection);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Forces a background reload, keeping the sidebar visible.
311
+ * @param {string} collection - The collection to reload
312
+ * @return {void}
313
+ */
314
+ export function reloadCollection(collection: string): void {
315
+ loadedCollection = collection;
316
+ dispatchWorker(collection, true);
317
+ }
318
+
319
+ /**
320
+ * Optimistically updates a single item's frontmatter in the content list and cache.
321
+ * @param {string} filename - The filename to update
322
+ * @param {Record<string, unknown>} data - The new frontmatter data
323
+ * @return {void}
324
+ */
325
+ export function updateContentItem(
326
+ filename: string,
327
+ data: Record<string, unknown>,
328
+ ): void {
329
+ const updated = contentList.map((item) =>
330
+ item.filename === filename ? { ...item, data } : item,
331
+ );
332
+ contentList = updated;
333
+ if (loadedCollection) contentCache.set(loadedCollection, updated);
334
+ }
@@ -0,0 +1,173 @@
1
+ /*
2
+ * Reactive theme state with localStorage persistence and system preference detection.
3
+ * Manages a three-way preference (light / dark / auto) and resolves the effective
4
+ * theme by combining user preference with the OS-level color scheme.
5
+ */
6
+
7
+ // Tri-state preference: 'light', 'dark', or 'auto' (follow system)
8
+ type ThemePreference = 'light' | 'dark' | 'auto';
9
+
10
+ // Resolved theme applied to the DOM — always 'light' or 'dark'
11
+ type ResolvedTheme = 'light' | 'dark';
12
+
13
+ // Material Symbols icon name for each preference
14
+ type ThemeIcon = 'light_mode' | 'dark_mode' | 'brightness_auto';
15
+
16
+ // Human-readable label for each preference
17
+ type ThemeLabel = 'Light' | 'Dark' | 'Auto';
18
+
19
+ // localStorage key for persisting the user's preference
20
+ const STORAGE_KEY = 'nebula-theme';
21
+
22
+ /*
23
+ //////////////////////////////
24
+ // Preference → UI mapping
25
+ //////////////////////////////
26
+ */
27
+
28
+ /*
29
+ * Single source of truth for all preference-derived UI values.
30
+ * Adding a new preference requires a new entry here — TypeScript
31
+ * enforces exhaustiveness via the Record<ThemePreference, ...> type.
32
+ */
33
+ const THEME_MAP: Record<
34
+ ThemePreference,
35
+ { icon: ThemeIcon; label: ThemeLabel }
36
+ > = {
37
+ light: { icon: 'light_mode', label: 'Light' },
38
+ dark: { icon: 'dark_mode', label: 'Dark' },
39
+ auto: { icon: 'brightness_auto', label: 'Auto' },
40
+ };
41
+
42
+ /*
43
+ //////////////////////////////
44
+ // Module initialization
45
+ //////////////////////////////
46
+ */
47
+
48
+ /*
49
+ * Read preference from localStorage at module init to avoid a flash of wrong theme.
50
+ * Safe because this module is only loaded client-side (client:only="svelte").
51
+ */
52
+ let stored: string | null = null;
53
+ try {
54
+ if (typeof localStorage !== 'undefined') {
55
+ stored = localStorage.getItem(STORAGE_KEY);
56
+ }
57
+ } catch {
58
+ // Storage access blocked (SecurityError, private browsing, etc.)
59
+ }
60
+
61
+ /*
62
+ * Read system preference at module init so the first render has the correct
63
+ * resolved theme — deferring this to onMount would cause a flash for auto+dark users.
64
+ */
65
+ const mq =
66
+ typeof window !== 'undefined'
67
+ ? window.matchMedia('(prefers-color-scheme: dark)')
68
+ : null;
69
+
70
+ // The user's explicit preference
71
+ let preference = $state<ThemePreference>(
72
+ stored === 'light' || stored === 'dark' || stored === 'auto'
73
+ ? stored
74
+ : 'auto',
75
+ );
76
+
77
+ // Whether the OS prefers dark mode — updated via matchMedia listener
78
+ let systemPrefersDark = $state(mq?.matches ?? false);
79
+
80
+ /*
81
+ //////////////////////////////
82
+ // Derived state
83
+ //////////////////////////////
84
+ */
85
+
86
+ /*
87
+ * $derived can only be a variable declaration initializer, not an object
88
+ * property — Svelte compiler restriction. These private derivations feed
89
+ * the exported theme object via getters.
90
+ */
91
+ const resolved: ResolvedTheme = $derived(
92
+ preference === 'auto' ? (systemPrefersDark ? 'dark' : 'light') : preference,
93
+ );
94
+ const ui = $derived(THEME_MAP[preference]);
95
+
96
+ /*
97
+ * Reactive theme state. Consumers read properties directly: theme.resolved,
98
+ * theme.icon, theme.label. The getters forward Svelte's reactive tracking.
99
+ */
100
+ export const theme = {
101
+ get resolved(): ResolvedTheme {
102
+ return resolved;
103
+ },
104
+ get icon(): ThemeIcon {
105
+ return ui.icon;
106
+ },
107
+ get label(): ThemeLabel {
108
+ return ui.label;
109
+ },
110
+ };
111
+
112
+ /*
113
+ //////////////////////////////
114
+ // System preference tracking
115
+ //////////////////////////////
116
+ */
117
+
118
+ // Guard against duplicate listener registration (e.g. HMR remount)
119
+ let initialized = false;
120
+
121
+ /**
122
+ * Starts listening to OS color scheme changes via matchMedia.
123
+ * Preference and initial system state are already read at module init,
124
+ * so this only needs to register the change listener. Call once on mount.
125
+ * Safe to call multiple times — registers the listener only once.
126
+ * @return {() => void} Cleanup function that removes the listener
127
+ */
128
+ export function initTheme(): () => void {
129
+ if (initialized) return () => {};
130
+ initialized = true;
131
+
132
+ /**
133
+ * Updates the systemPrefersDark flag when the OS color scheme changes.
134
+ * @param {MediaQueryListEvent} e - The change event from matchMedia
135
+ * @return {void}
136
+ */
137
+ const handler = (e: MediaQueryListEvent): void => {
138
+ systemPrefersDark = e.matches;
139
+ };
140
+
141
+ mq?.addEventListener('change', handler);
142
+
143
+ return () => {
144
+ mq?.removeEventListener('change', handler);
145
+ initialized = false;
146
+ };
147
+ }
148
+
149
+ /*
150
+ //////////////////////////////
151
+ // Actions
152
+ //////////////////////////////
153
+ */
154
+
155
+ /**
156
+ * Cycles the preference through auto -> light -> dark -> auto.
157
+ * Persists the new value to localStorage.
158
+ * @return {void}
159
+ */
160
+ export function cycleTheme(): void {
161
+ const next: ThemePreference =
162
+ preference === 'auto' ? 'light' : preference === 'light' ? 'dark' : 'auto';
163
+ preference = next;
164
+ try {
165
+ localStorage.setItem(STORAGE_KEY, next);
166
+ } catch {
167
+ /*
168
+ * Persistence failed (quota exceeded, storage disabled, etc.).
169
+ * The in-memory preference is already updated, so the current
170
+ * session works correctly — it just won't survive a page reload.
171
+ */
172
+ }
173
+ }