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,29 @@
1
+ /*
2
+ * Shared dialog chrome styles for native <dialog> modals.
3
+ * Provides the surface, backdrop, title, and actions grid
4
+ * so individual dialog components only supply their own content.
5
+ */
6
+
7
+ .dialog {
8
+ background: var(--cms-surface);
9
+ color: var(--cms-fg);
10
+ border: 1px solid var(--cms-border);
11
+ border-radius: 0.5rem;
12
+ padding: 1.5rem;
13
+ }
14
+
15
+ .dialog::backdrop {
16
+ background: rgba(0, 0, 0, 0.6);
17
+ }
18
+
19
+ .dialog__title {
20
+ font-size: 1rem;
21
+ font-weight: 600;
22
+ margin-bottom: 1rem;
23
+ }
24
+
25
+ .dialog__actions {
26
+ display: grid;
27
+ grid-template-columns: 1fr 1fr;
28
+ gap: 0.75rem;
29
+ }
@@ -0,0 +1,39 @@
1
+ /*
2
+ * Shared form field input styles used by StringField, NumberField,
3
+ * DateField, and EnumField. Provides the base appearance, focus ring,
4
+ * and disabled/readonly dimming so each leaf field only adds its own
5
+ * type-specific overrides.
6
+ */
7
+
8
+ .field-input {
9
+ background: var(--cms-surface, #2a2a2e);
10
+ border: 1px solid var(--cms-border);
11
+ border-radius: 4px;
12
+ padding: 0.5rem;
13
+ font-size: 1rem;
14
+ color: var(--cms-fg);
15
+
16
+ &:focus {
17
+ outline: 2px solid var(--plum);
18
+ outline-offset: -1px;
19
+ }
20
+
21
+ &[readonly],
22
+ &:disabled {
23
+ opacity: 0.6;
24
+ cursor: default;
25
+ }
26
+ }
27
+
28
+ /* Intrinsic-width inputs (number, date, select) — prevents stretching to 100% */
29
+ .field-input--auto {
30
+ width: auto;
31
+ }
32
+
33
+ /* Native select dropdown with restored arrow and extra padding for the indicator */
34
+ .field-input--select {
35
+ appearance: auto;
36
+ width: auto;
37
+ padding-right: 2rem;
38
+ cursor: pointer;
39
+ }
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Global CSS reset scoped to the .admin root element.
3
+ * Normalizes browser defaults that leak through when the CMS is embedded
4
+ * in a host page without its own reset. Imported before icons and theme
5
+ * so that resets are in place before any styled content renders.
6
+ */
7
+
8
+ /* Remove default body margin so the admin fills its container edge-to-edge */
9
+
10
+ body {
11
+ margin: 0;
12
+ }
13
+
14
+ /* Prevent padding/border from inflating element sizes */
15
+
16
+ *,
17
+ *::before,
18
+ *::after {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ /* Form elements don't inherit font by default */
23
+
24
+ input,
25
+ select,
26
+ textarea,
27
+ button {
28
+ font-family: inherit;
29
+ }
30
+
31
+ /* Reset browser defaults that leak through without a host-page reset */
32
+
33
+ h1,
34
+ h2,
35
+ h3,
36
+ p,
37
+ ul,
38
+ ol,
39
+ dl {
40
+ margin: 0;
41
+ }
42
+
43
+ ul,
44
+ ol {
45
+ list-style: none;
46
+ padding: 0;
47
+ }
48
+
49
+ /* Fluid images and videos */
50
+ img,
51
+ video {
52
+ max-width: 100%;
53
+ height: auto;
54
+ }
55
+
56
+ /* Dialog doesn't inherit font family */
57
+ dialog {
58
+ font-family: var(--font-family);
59
+ }
@@ -0,0 +1,77 @@
1
+ /*
2
+ * Theme definitions for nebula-cms.
3
+ * Provides CSS custom properties for both light and dark modes.
4
+ * Variables are defined on :root so that top-layer elements (dialogs
5
+ * opened with showModal()) inherit them. The resolved theme is applied
6
+ * via a data-theme attribute on document.documentElement.
7
+ */
8
+
9
+ /* Shared tokens that don't change between themes */
10
+
11
+ :root {
12
+ --plum: #dd2270;
13
+ --light-plum: #e8508f;
14
+ --button-bg: linear-gradient(135deg, #d83333, var(--plum));
15
+ --button-hover-bg: linear-gradient(135deg, #e04848, #f565ff);
16
+ /*
17
+ * Always light text on the gradient — intentionally not --cms-fg,
18
+ * which inverts in light mode and would produce dark-on-gradient.
19
+ */
20
+ --button-color: #e8eaf0;
21
+ --spacing: 2rem;
22
+ --font-family: system-ui, -apple-system, sans-serif;
23
+ }
24
+
25
+ /* Dark mode — deep background with rose accent */
26
+
27
+ :root[data-theme='dark'] {
28
+ --cms-bg: rgb(13, 15, 20);
29
+ --cms-surface: #151720;
30
+ --cms-border: #252830;
31
+ --cms-muted: #8b90a0;
32
+ --cms-fg: #e8eaf0;
33
+ --gold: #fbbf24;
34
+ --light-red: #f87171;
35
+ --red: #dc2626;
36
+ --light-green: #34d399;
37
+ --green: #059669;
38
+ --light-orange: #fb923c;
39
+ --light-teal: #5eead4;
40
+ --light-purple: #e879f9;
41
+ --editor-caret: #e8eaf0;
42
+ --editor-active-line: rgba(224, 58, 128, 0.1);
43
+ color-scheme: dark;
44
+ }
45
+
46
+ /*
47
+ * Light mode — white background, same accent.
48
+ * Variable names are functional roles (--cms-bg = page background,
49
+ * --cms-fg = body text), not literal colors.
50
+ */
51
+
52
+ :root[data-theme='light'] {
53
+ --cms-bg: #ffffff;
54
+ --cms-surface: #f8f5f7;
55
+ --cms-border: #ddd5da;
56
+ --cms-muted: #6e6870;
57
+ --cms-fg: #1e1a1c;
58
+ --gold: #b45309;
59
+ --light-red: #dc2626;
60
+ --red: #b91c1c;
61
+ --light-green: #059669;
62
+ --green: #047857;
63
+ --light-orange: #c2410c;
64
+ --light-teal: #0f766e;
65
+ --light-purple: #a21caf;
66
+ --editor-caret: #1e1a1c;
67
+ --editor-active-line: rgba(224, 58, 128, 0.06);
68
+ color-scheme: light;
69
+ }
70
+
71
+ /* Base styles for the admin shell */
72
+
73
+ .admin {
74
+ font-family: var(--font-family);
75
+ background: var(--cms-bg);
76
+ color: var(--cms-fg);
77
+ }
@@ -0,0 +1 @@
1
+ export { default } from './Admin.svelte';
@@ -0,0 +1,121 @@
1
+ import { loadDrafts, type Draft } from './storage';
2
+ import { splitFrontmatter } from '../utils/frontmatter';
3
+ import { storageClient } from '../state/state.svelte';
4
+
5
+ // Drafts for the current collection
6
+ let draftList = $state<Draft[]>([]);
7
+ // Map of draftId → whether the live content has diverged from the draft's snapshot
8
+ let outdatedMap = $state<Record<string, boolean>>({});
9
+
10
+ export const drafts = {
11
+ // All drafts for the active collection.
12
+ get all(): Draft[] {
13
+ return draftList;
14
+ },
15
+ // Map of draft ID to whether live content has diverged.
16
+ get outdated(): Record<string, boolean> {
17
+ return outdatedMap;
18
+ },
19
+ };
20
+ // Worker for off-thread snapshot comparison
21
+ let diffWorker: Worker | null = null;
22
+
23
+ /**
24
+ * Initializes the diff worker singleton and wires up the result handler.
25
+ * @return {Worker} The existing or newly created diff worker
26
+ */
27
+ function ensureDiffWorker(): Worker {
28
+ if (diffWorker) return diffWorker;
29
+ // Uses .js extension because svelte-package does not rewrite URL string literals
30
+ diffWorker = new Worker(new URL('./workers/diff.js', import.meta.url), {
31
+ type: 'module',
32
+ });
33
+ diffWorker.addEventListener('message', (event) => {
34
+ const data = event.data;
35
+ if (data.type === 'diff-result') {
36
+ outdatedMap = data.results;
37
+ }
38
+ });
39
+ return diffWorker;
40
+ }
41
+
42
+ /**
43
+ * Loads drafts for a collection from IndexedDB and dispatches snapshot comparisons to the diff worker for any drafts linked to live files. Reads live file contents via the StorageClient.
44
+ * @param {string} collection - The collection to load drafts for
45
+ * @return {Promise<void>}
46
+ */
47
+ export async function mergeDrafts(collection: string): Promise<void> {
48
+ draftList = await loadDrafts(collection);
49
+
50
+ /*
51
+ * Filter to drafts that need outdated checking:
52
+ * must be linked to a live file (not new), have a snapshot, and have a filename.
53
+ */
54
+ const candidates = draftList.filter(
55
+ (d) => !d.isNew && d.snapshot && d.filename,
56
+ );
57
+ if (candidates.length === 0) {
58
+ outdatedMap = {};
59
+ return;
60
+ }
61
+
62
+ /*
63
+ * Read all candidate files in parallel instead of sequentially.
64
+ * js-yaml is dynamically imported because it's a transitive dep — hoist
65
+ * before the loop so the import is evaluated once, not per-candidate.
66
+ */
67
+ const { load } = await import('js-yaml');
68
+ const settled = await Promise.all(
69
+ candidates.map(async (d) => {
70
+ try {
71
+ const text = await storageClient.readFile(collection, d.filename!);
72
+ const { rawFrontmatter, body } = splitFrontmatter(text);
73
+ const liveFormData = (load(rawFrontmatter) ?? {}) as Record<
74
+ string,
75
+ unknown
76
+ >;
77
+ const liveBody = body.replace(/^\n+/, '').replace(/\n+$/, '');
78
+ return {
79
+ draftId: d.id,
80
+ snapshot: d.snapshot!,
81
+ liveFormData,
82
+ liveBody,
83
+ };
84
+ } catch {
85
+ // File not found or unreadable — skip
86
+ return null;
87
+ }
88
+ }),
89
+ );
90
+ const entries = settled.filter((e): e is NonNullable<typeof e> => e !== null);
91
+
92
+ if (entries.length === 0) {
93
+ outdatedMap = {};
94
+ return;
95
+ }
96
+
97
+ // Dispatch to the diff worker for off-thread comparison
98
+ const worker = ensureDiffWorker();
99
+ worker.postMessage({ type: 'diff', entries });
100
+ }
101
+
102
+ /**
103
+ * Re-reads drafts from IndexedDB for the given collection and updates the reactive drafts list.
104
+ * Used after saving/deleting a draft so the sidebar reflects changes immediately without a full collection reload.
105
+ * @param {string} collection - The collection to refresh drafts for
106
+ * @return {Promise<void>}
107
+ */
108
+ export async function refreshDrafts(collection: string): Promise<void> {
109
+ draftList = await loadDrafts(collection);
110
+ }
111
+
112
+ /**
113
+ * Resets draft-related state and terminates the diff worker. Called during disconnect.
114
+ * @return {void}
115
+ */
116
+ export function resetDraftMerge(): void {
117
+ diffWorker?.terminate();
118
+ diffWorker = null;
119
+ draftList = [];
120
+ outdatedMap = {};
121
+ }
@@ -0,0 +1,227 @@
1
+ import {
2
+ saveDraft as persistDraft,
3
+ loadDraft as fetchDraft,
4
+ deleteDraft as removeDraft,
5
+ type Draft,
6
+ } from './storage';
7
+ import { stableStringify } from '../utils/stable-stringify';
8
+ import {
9
+ applyEditorState,
10
+ _getDraftState,
11
+ _setDraftState,
12
+ } from '../editor/editor.svelte';
13
+ import { storageClient } from '../state/state.svelte';
14
+ import { getFileCategory, getDataFormat } from '../utils/file-types';
15
+
16
+ /**
17
+ * Loads a draft by ID from IndexedDB and populates the editor. Falls back to empty state if the draft is not found (safety fallback for the "Add" button flow).
18
+ * @param {string} id - The draft UUID to load
19
+ * @param {string} collection - The collection this draft belongs to
20
+ * @return {Promise<void>}
21
+ */
22
+ export async function loadDraftById(
23
+ id: string,
24
+ collection: string,
25
+ ): Promise<void> {
26
+ const draft = await fetchDraft(id);
27
+
28
+ if (!draft) {
29
+ applyEditorState(
30
+ {
31
+ body: '',
32
+ formData: {},
33
+ filename: '',
34
+ bodyLoaded: true,
35
+ draftId: id,
36
+ isNewDraft: true,
37
+ snapshot: null,
38
+ collection,
39
+ draftCreatedAt: new Date().toISOString(),
40
+ },
41
+ true,
42
+ );
43
+ return;
44
+ }
45
+
46
+ applyEditorState(
47
+ {
48
+ body: draft.body,
49
+ formData: draft.formData,
50
+ filename: draft.filename ?? '',
51
+ bodyLoaded: true,
52
+ draftId: draft.id,
53
+ isNewDraft: draft.isNew,
54
+ snapshot: draft.snapshot,
55
+ collection,
56
+ draftCreatedAt: draft.createdAt,
57
+ },
58
+ true,
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Saves the current editor content as a draft in IndexedDB. On first save, generates a UUID and createdAt timestamp. For live content edits, captures a snapshot of the original data.
64
+ * @return {Promise<void>}
65
+ */
66
+ export async function saveDraftToIDB(): Promise<void> {
67
+ const s = _getDraftState();
68
+ _setDraftState({ saving: true });
69
+
70
+ try {
71
+ let { draftId, draftCreatedAt, snapshot } = s;
72
+
73
+ // Generate draft ID and timestamp on first save
74
+ if (!draftId) {
75
+ draftId = crypto.randomUUID();
76
+ draftCreatedAt = new Date().toISOString();
77
+
78
+ // For live content edits, capture a snapshot of the original saved data
79
+ if (!s.isNewDraft) {
80
+ snapshot = stableStringify({
81
+ formData: JSON.parse(s.lastSavedFormData),
82
+ body: s.lastSavedBody,
83
+ });
84
+ }
85
+
86
+ _setDraftState({ draftId, draftCreatedAt, snapshot });
87
+ }
88
+
89
+ const draft: Draft = {
90
+ id: draftId,
91
+ collection: s.currentCollection,
92
+ filename: s.filename || null,
93
+ isNew: s.isNewDraft,
94
+ formData: $state.snapshot(s.formData) as Record<string, unknown>,
95
+ body: s.body,
96
+ snapshot,
97
+ createdAt: draftCreatedAt!,
98
+ };
99
+
100
+ await persistDraft(draft);
101
+ _setDraftState({
102
+ lastSavedBody: s.body,
103
+ lastSavedFormData: JSON.stringify(s.formData),
104
+ dirty: false,
105
+ });
106
+ } finally {
107
+ _setDraftState({ saving: false });
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Legacy alias for saveDraftToIDB — preserves existing test mock call sites.
113
+ * @return {Promise<void>}
114
+ */
115
+ export async function saveFile(): Promise<void> {
116
+ return saveDraftToIDB();
117
+ }
118
+
119
+ /**
120
+ * Serializes editor content based on the target file format. Data files produce pure JSON/YAML/TOML; frontmatter files produce the standard `---` delimited format.
121
+ * @param {string} filename - The target filename, used to determine format
122
+ * @param {Record<string, unknown>} formData - The structured data to serialize
123
+ * @param {string} body - The body content (only used for frontmatter files)
124
+ * @return {Promise<string>} The serialized file content
125
+ */
126
+ async function serializeContent(
127
+ filename: string,
128
+ formData: Record<string, unknown>,
129
+ body: string,
130
+ ): Promise<string> {
131
+ const category = getFileCategory(filename);
132
+
133
+ // Lazy-load js-yaml once for both data and frontmatter YAML paths
134
+ const loadYAML = () => import('js-yaml');
135
+
136
+ if (category === 'data') {
137
+ const format = getDataFormat(filename);
138
+ switch (format) {
139
+ case 'json':
140
+ return JSON.stringify(formData, null, 2) + '\n';
141
+ case 'yaml': {
142
+ const { dump } = await loadYAML();
143
+ return dump(formData, { lineWidth: -1 });
144
+ }
145
+ case 'toml': {
146
+ const { stringify } = await import('smol-toml');
147
+ return stringify(formData);
148
+ }
149
+ default:
150
+ throw new Error(`Unsupported data format: ${format}`);
151
+ }
152
+ }
153
+
154
+ // Frontmatter files: ---\nyaml\n---\n\nbody\n
155
+ const { dump } = await loadYAML();
156
+ // dump() adds a trailing newline, so the template omits a \n before ---
157
+ const yaml = dump(formData, { lineWidth: -1 });
158
+ return `---\n${yaml}---\n\n${body}\n`;
159
+ }
160
+
161
+ /**
162
+ * Writes editor content to the storage backend via StorageClient. Dispatches serialization by file format. If originalFilename is provided and differs from filename, deletes the old file (file type conversion). Deletes the associated draft from IndexedDB after a successful write.
163
+ * @param {string} collection - The collection the file belongs to
164
+ * @param {string} filename - The filename to write within the collection
165
+ * @param {string} [originalFilename] - The previous filename if the file was renamed/converted
166
+ * @return {Promise<void>}
167
+ */
168
+ export async function publishFile(
169
+ collection: string,
170
+ filename: string,
171
+ originalFilename?: string,
172
+ ): Promise<void> {
173
+ const s = _getDraftState();
174
+ _setDraftState({ saving: true });
175
+
176
+ try {
177
+ const content = await serializeContent(filename, s.formData, s.body);
178
+ await storageClient.writeFile(collection, filename, content);
179
+
180
+ // Remove the old file if the filename changed (file type conversion)
181
+ if (originalFilename && originalFilename !== filename) {
182
+ await storageClient.deleteFile(collection, originalFilename);
183
+ }
184
+
185
+ // Clean up the draft from IndexedDB after successful publish
186
+ if (s.draftId) {
187
+ await removeDraft(s.draftId);
188
+ _setDraftState({
189
+ draftId: null,
190
+ isNewDraft: false,
191
+ snapshot: null,
192
+ draftCreatedAt: null,
193
+ });
194
+ }
195
+
196
+ /*
197
+ * Update originalFilename so subsequent publishes know the current on-disk name.
198
+ * Without this, a second format change would not detect the rename because
199
+ * originalFilename would still point to the pre-first-publish name.
200
+ */
201
+ _setDraftState({
202
+ lastSavedBody: s.body,
203
+ lastSavedFormData: JSON.stringify(s.formData),
204
+ dirty: false,
205
+ originalFilename: filename,
206
+ });
207
+ } finally {
208
+ _setDraftState({ saving: false });
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Deletes the current draft from IndexedDB and resets draft-related state fields.
214
+ * @return {Promise<void>}
215
+ */
216
+ export async function deleteCurrentDraft(): Promise<void> {
217
+ const { draftId } = _getDraftState();
218
+ if (draftId) {
219
+ await removeDraft(draftId);
220
+ }
221
+ _setDraftState({
222
+ draftId: null,
223
+ isNewDraft: false,
224
+ snapshot: null,
225
+ draftCreatedAt: null,
226
+ });
227
+ }
@@ -0,0 +1,108 @@
1
+ /*
2
+ * Draft persistence layer backed by IndexedDB.
3
+ * CRUD operations for draft content entries, used by the editor and merge logic.
4
+ */
5
+
6
+ import { openDB } from '../storage/db';
7
+
8
+ /**
9
+ * A draft content entry persisted in IndexedDB.
10
+ */
11
+ export type Draft = {
12
+ // UUID primary key
13
+ id: string;
14
+ // Collection this draft belongs to
15
+ collection: string;
16
+ // Filename (null if not yet named)
17
+ filename: string | null;
18
+ // true = brand new content, false = draft of existing live file
19
+ isNew: boolean;
20
+ // Parsed frontmatter data
21
+ formData: Record<string, unknown>;
22
+ // Markdown body content
23
+ body: string;
24
+ // Stable-stringified snapshot of original live {formData, body} at draft creation — null for new content
25
+ snapshot: string | null;
26
+ // ISO date string of when the draft was first created
27
+ createdAt: string;
28
+ };
29
+
30
+ // Object store name for drafts
31
+ const STORE = 'drafts';
32
+
33
+ /**
34
+ * Saves or updates a draft in IndexedDB.
35
+ * @param {Draft} draft - The draft record to persist
36
+ * @return {Promise<void>}
37
+ */
38
+ export async function saveDraft(draft: Draft): Promise<void> {
39
+ const db = await openDB();
40
+ return new Promise((resolve, reject) => {
41
+ const tx = db.transaction(STORE, 'readwrite');
42
+ tx.objectStore(STORE).put(draft);
43
+ tx.oncomplete = () => resolve();
44
+ tx.onerror = () => reject(tx.error);
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Loads all drafts for a given collection.
50
+ * @param {string} collection - The collection name to filter by
51
+ * @return {Promise<Draft[]>} All drafts belonging to the collection
52
+ */
53
+ export async function loadDrafts(collection: string): Promise<Draft[]> {
54
+ const db = await openDB();
55
+ return new Promise((resolve, reject) => {
56
+ const tx = db.transaction(STORE, 'readonly');
57
+ const request = tx.objectStore(STORE).getAll();
58
+ request.onsuccess = () => {
59
+ const all = request.result as Draft[];
60
+ resolve(all.filter((d) => d.collection === collection));
61
+ };
62
+ request.onerror = () => reject(request.error);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Loads a single draft by ID.
68
+ * @param {string} id - The draft UUID
69
+ * @return {Promise<Draft | null>} The draft, or null if not found
70
+ */
71
+ export async function loadDraft(id: string): Promise<Draft | null> {
72
+ const db = await openDB();
73
+ return new Promise((resolve, reject) => {
74
+ const tx = db.transaction(STORE, 'readonly');
75
+ const request = tx.objectStore(STORE).get(id);
76
+ request.onsuccess = () => resolve(request.result ?? null);
77
+ request.onerror = () => reject(request.error);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Deletes a draft from IndexedDB by ID.
83
+ * @param {string} id - The draft UUID to delete
84
+ * @return {Promise<void>}
85
+ */
86
+ export async function deleteDraft(id: string): Promise<void> {
87
+ const db = await openDB();
88
+ return new Promise((resolve, reject) => {
89
+ const tx = db.transaction(STORE, 'readwrite');
90
+ tx.objectStore(STORE).delete(id);
91
+ tx.oncomplete = () => resolve();
92
+ tx.onerror = () => reject(tx.error);
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Finds a draft linked to a specific live file.
98
+ * @param {string} collection - The collection name
99
+ * @param {string} filename - The live file's filename
100
+ * @return {Promise<Draft | null>} The matching draft, or null if none exists
101
+ */
102
+ export async function getDraftByFile(
103
+ collection: string,
104
+ filename: string,
105
+ ): Promise<Draft | null> {
106
+ const drafts = await loadDrafts(collection);
107
+ return drafts.find((d) => !d.isNew && d.filename === filename) ?? null;
108
+ }
@@ -0,0 +1,40 @@
1
+ import { stableStringify } from '../../utils/stable-stringify';
2
+
3
+ /**
4
+ * Input entry for comparing a draft snapshot against live content.
5
+ */
6
+ type DiffEntry = {
7
+ // Draft UUID
8
+ draftId: string;
9
+ // The draft's stored snapshot string (from stableStringify at draft creation)
10
+ snapshot: string;
11
+ // Current live frontmatter data
12
+ liveFormData: Record<string, unknown>;
13
+ // Current live body content
14
+ liveBody: string;
15
+ };
16
+
17
+ /*
18
+ * Listens for diff requests, compares each draft's snapshot against current
19
+ * live content, and returns a map of draftId to isOutdated.
20
+ */
21
+ self.addEventListener('message', (event: MessageEvent) => {
22
+ const { type, entries } = event.data as {
23
+ type: string;
24
+ entries: DiffEntry[];
25
+ };
26
+ if (type !== 'diff') return;
27
+
28
+ const results: Record<string, boolean> = {};
29
+
30
+ for (const entry of entries) {
31
+ // Reconstruct the comparable string using the same format as snapshot creation
32
+ const liveString = stableStringify({
33
+ formData: entry.liveFormData,
34
+ body: entry.liveBody,
35
+ });
36
+ results[entry.draftId] = liveString !== entry.snapshot;
37
+ }
38
+
39
+ self.postMessage({ type: 'diff-result', results });
40
+ });