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,227 @@
1
+ <script lang="ts">
2
+ import { EditorView, keymap } from '@codemirror/view';
3
+ import { Compartment, EditorState } from '@codemirror/state';
4
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
5
+ import { getEditorFile, updateBody } from '../../js/editor/editor.svelte';
6
+ import {
7
+ getTypeForFilename,
8
+ stripExtension,
9
+ } from '../../js/utils/file-types';
10
+ import { getLanguageExtension } from '../../js/editor/languages';
11
+ import { linkWrapPlugin } from '../../js/editor/link-wrap';
12
+ import {
13
+ markdownShortcutsKeymap,
14
+ markdownShortcutsExtensions,
15
+ } from '../../js/editor/markdown-shortcuts';
16
+ import Toolbar from './Toolbar.svelte';
17
+
18
+ // Container element for CodeMirror
19
+ let container: HTMLDivElement;
20
+ // The CodeMirror EditorView instance
21
+ let view: EditorView | undefined;
22
+ // Compartment isolating the language extension for runtime reconfiguration
23
+ const langCompartment = new Compartment();
24
+
25
+ // Base editor theme matching the admin color scheme
26
+ const editorTheme = EditorView.theme({
27
+ '&': {
28
+ fontSize: '1rem',
29
+ },
30
+ '.cm-content': {
31
+ fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
32
+ caretColor: 'var(--editor-caret)',
33
+ padding: '1rem',
34
+ },
35
+ '.cm-scroller': {
36
+ overflow: 'auto',
37
+ },
38
+ '&.cm-focused': {
39
+ outline: 'none',
40
+ },
41
+ '.cm-line': {
42
+ padding: '0 0.25rem',
43
+ },
44
+ '.cm-cursor': {
45
+ borderLeftColor: 'var(--editor-caret)',
46
+ },
47
+ '.cm-selectionBackground': {
48
+ background: 'var(--plum) !important',
49
+ },
50
+ '&.cm-focused .cm-selectionBackground': {
51
+ background: 'var(--plum) !important',
52
+ },
53
+ '.cm-activeLine': {
54
+ backgroundColor: 'var(--editor-active-line)',
55
+ },
56
+ '.cm-gutters': {
57
+ display: 'none',
58
+ },
59
+ });
60
+
61
+ /**
62
+ * Creates the full set of CodeMirror extensions. The language extension
63
+ * is wrapped in a Compartment so it can be swapped at runtime without
64
+ * rebuilding the entire editor state.
65
+ * @param {string} fileType - The file type identifier for language selection
66
+ * @return {import('@codemirror/state').Extension[]} The array of CodeMirror extensions
67
+ */
68
+ function createExtensions(fileType: string) {
69
+ return [
70
+ editorTheme,
71
+ history(),
72
+ keymap.of([
73
+ ...markdownShortcutsKeymap,
74
+ ...defaultKeymap,
75
+ ...historyKeymap,
76
+ ]),
77
+ langCompartment.of(getLanguageExtension(fileType)),
78
+ EditorView.lineWrapping,
79
+ linkWrapPlugin,
80
+ ...markdownShortcutsExtensions,
81
+ EditorView.updateListener.of((update) => {
82
+ if (update.docChanged) {
83
+ updateBody(update.state.doc.toString());
84
+ }
85
+ }),
86
+ EditorView.contentAttributes.of({ 'aria-label': 'Content editor' }),
87
+ ];
88
+ }
89
+
90
+ // Track the last loaded file identity to detect file changes
91
+ let lastFileKey = '';
92
+ // Track the last configured language type to avoid redundant compartment reconfigures
93
+ let lastLangType = '';
94
+
95
+ $effect(() => {
96
+ const file = getEditorFile();
97
+
98
+ if (!file) {
99
+ // No file open — destroy editor if it exists
100
+ if (view) {
101
+ view.destroy();
102
+ view = undefined;
103
+ lastFileKey = '';
104
+ }
105
+ return;
106
+ }
107
+
108
+ // Wait for body to load before creating/updating CodeMirror
109
+ if (!file.bodyLoaded) return;
110
+
111
+ // Use slug (without extension) so format changes don't trigger a rebuild
112
+ const fileKey = file.draftId ?? stripExtension(file.filename);
113
+
114
+ if (!view && container) {
115
+ // First mount — create the editor
116
+ const fileType = getTypeForFilename(file.filename) ?? 'md';
117
+ lastFileKey = fileKey;
118
+ lastLangType = fileType;
119
+ const state = EditorState.create({
120
+ doc: file.body,
121
+ extensions: createExtensions(fileType),
122
+ });
123
+ view = new EditorView({ state, parent: container });
124
+ } else if (view && fileKey !== lastFileKey) {
125
+ // Different file selected — replace document
126
+ const fileType = getTypeForFilename(file.filename) ?? 'md';
127
+ lastFileKey = fileKey;
128
+ lastLangType = fileType;
129
+ view.setState(
130
+ EditorState.create({
131
+ doc: file.body,
132
+ extensions: createExtensions(fileType),
133
+ }),
134
+ );
135
+ }
136
+ });
137
+
138
+ // Reconfigure the language compartment when the file type changes
139
+ $effect(() => {
140
+ if (!view) return;
141
+ const file = getEditorFile();
142
+ if (!file?.filename) return;
143
+ const fileType = getTypeForFilename(file.filename) ?? 'md';
144
+ // Skip if the language is already configured (avoids redundant reconfigure on initial mount)
145
+ if (fileType === lastLangType) return;
146
+ lastLangType = fileType;
147
+ view.dispatch({
148
+ effects: langCompartment.reconfigure(getLanguageExtension(fileType)),
149
+ });
150
+ });
151
+
152
+ // Cleanup on component destroy
153
+ $effect(() => {
154
+ return () => {
155
+ view?.destroy();
156
+ view = undefined;
157
+ };
158
+ });
159
+ </script>
160
+
161
+ <div class="editor-wrapper">
162
+ <div class="editor-box">
163
+ <Toolbar />
164
+ <div class="editor-pane" bind:this={container}></div>
165
+ </div>
166
+ </div>
167
+
168
+ <style>
169
+ .editor-wrapper {
170
+ padding: 1.5rem;
171
+ max-width: 80ch;
172
+ margin: 0 auto;
173
+ }
174
+
175
+ .editor-box {
176
+ display: grid;
177
+ grid-template-rows: auto 1fr;
178
+ border: 1px solid var(--cms-border);
179
+ border-radius: 4px;
180
+ overflow: hidden;
181
+ /* Subtract the toolbar, tabs, and wrapper padding from viewport height */
182
+ height: calc(100dvh - 9rem);
183
+ }
184
+
185
+ .editor-pane {
186
+ height: 100%;
187
+ overflow: auto;
188
+ }
189
+
190
+ /*
191
+ * Fill the entire editor box so clicking anywhere starts editing, even on empty documents.
192
+ * cm-editor uses display:flex column by default — min-height stretches the container,
193
+ * and flex-grow on cm-scroller makes the scrollable/clickable area fill it.
194
+ */
195
+ .editor-pane :global(.cm-editor) {
196
+ min-height: 100%;
197
+ }
198
+
199
+ .editor-pane :global(.cm-scroller) {
200
+ flex-grow: 1;
201
+ /*
202
+ * CodeMirror sets align-items: flex-start which prevents cm-content from stretching
203
+ * vertically. Override to stretch so the editable area fills the entire scroller.
204
+ */
205
+ align-items: stretch !important;
206
+ }
207
+
208
+ /*
209
+ * Forces .cm-content to shrink below its longest word so overflow-wrap can break long URLs.
210
+ * min-height fills the scroller so the entire editor area is clickable on empty documents.
211
+ * Both need !important to override CodeMirror's inline theme styles.
212
+ */
213
+ .editor-pane :global(.cm-content) {
214
+ min-width: 0 !important;
215
+ min-height: 100% !important;
216
+ }
217
+
218
+ /* Wraps long URLs at word boundaries where possible, breaking mid-word only when necessary */
219
+ .editor-pane :global(.cm-link-wrap) {
220
+ overflow-wrap: break-word;
221
+ word-break: break-all;
222
+ }
223
+
224
+ .editor-pane :global(.cm-link-wrap span:nth-of-type(2)) {
225
+ word-break: break-word;
226
+ }
227
+ </style>
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import type { SchemaNode } from '../../js/utils/schema-utils';
3
+ import { extractTabs } from '../../js/utils/schema-utils';
4
+ import { toTitleCase } from '../../js/utils/format';
5
+ import {
6
+ editor,
7
+ setActiveTab,
8
+ getEditorFile,
9
+ } from '../../js/editor/editor.svelte';
10
+ import { hasBodyEditor } from '../../js/utils/file-types';
11
+
12
+ // Props for the EditorTabs component, which renders the tab bar above the editor, including the default Metadata and Body tabs plus any custom schema-defined tabs.
13
+ interface Props {
14
+ // The JSON Schema for the current collection (null if not loaded yet)
15
+ schema: SchemaNode | null;
16
+ }
17
+
18
+ let { schema }: Props = $props();
19
+
20
+ // Custom tab names derived from schema, sorted alphabetically
21
+ const customTabs = $derived(schema ? extractTabs(schema) : []);
22
+
23
+ // Current open file — null when no file is loaded
24
+ const file = $derived(getEditorFile());
25
+
26
+ /*
27
+ * Show the Body tab for files that have a body editor (markdown, MDX, Markdoc).
28
+ * Defaults to true when no file is open to preserve the prior behavior.
29
+ */
30
+ const showBody = $derived(file ? hasBodyEditor(file.filename) : true);
31
+
32
+ // All tabs: Metadata, conditionally Body, then custom tabs
33
+ const allTabs = $derived([
34
+ 'metadata',
35
+ ...(showBody ? ['body'] : []),
36
+ ...customTabs,
37
+ ]);
38
+ </script>
39
+
40
+ <nav class="tabs" aria-label="Editor tabs">
41
+ {#each allTabs as tab}
42
+ <button
43
+ class="tabs__tab"
44
+ class:tabs__tab--active={editor.tab === tab}
45
+ type="button"
46
+ onclick={() => setActiveTab(tab)}
47
+ aria-selected={editor.tab === tab}
48
+ role="tab"
49
+ >
50
+ {toTitleCase(tab)}
51
+ </button>
52
+ {/each}
53
+ </nav>
54
+
55
+ <style>
56
+ /* Tab bar sits below the editor toolbar, separated by a border */
57
+ .tabs {
58
+ display: flex;
59
+ border-bottom: 1px solid var(--cms-border);
60
+ }
61
+
62
+ .tabs__tab {
63
+ padding: 0.5rem 1rem;
64
+ font-size: 0.875rem;
65
+ color: var(--cms-muted);
66
+ background: none;
67
+ border: none;
68
+ /* Bottom border reserves space to avoid layout shift on active state */
69
+ border-bottom: 2px solid transparent;
70
+ cursor: pointer;
71
+
72
+ &:hover {
73
+ color: var(--cms-fg);
74
+ }
75
+ }
76
+
77
+ .tabs__tab--active {
78
+ color: var(--cms-fg);
79
+ border-bottom-color: var(--plum);
80
+ }
81
+ </style>
@@ -0,0 +1,131 @@
1
+ <script lang="ts">
2
+ import { getEditorFile } from '../../js/editor/editor.svelte';
3
+ import { nav } from '../../js/state/router.svelte';
4
+ import { schema } from '../../js/state/schema.svelte';
5
+ import {
6
+ handleSave,
7
+ handlePublish,
8
+ computePublishDisabled,
9
+ } from '../../js/handlers/admin';
10
+ import { dialog } from '../../js/state/dialogs.svelte';
11
+
12
+ // Current editor file state
13
+ const file = $derived(getEditorFile());
14
+
15
+ // Active collection derived from route, needed by save/publish handlers
16
+ const activeCollection = $derived(
17
+ nav.route.view !== 'home' ? nav.route.collection : null,
18
+ );
19
+
20
+ // Whether publish is disabled due to missing required fields
21
+ const publishDisabled = $derived(
22
+ computePublishDisabled(schema.active, file?.formData ?? {}),
23
+ );
24
+
25
+ // Display title from formData, falling back to filename or "Untitled Draft"
26
+ const title = $derived(
27
+ file && typeof file.formData.title === 'string'
28
+ ? file.formData.title
29
+ : file?.filename || 'Untitled Draft',
30
+ );
31
+
32
+ /**
33
+ * Publishes the current file, showing the filename dialog if a filename is needed first.
34
+ * @return {Promise<void>}
35
+ */
36
+ async function onPublish(): Promise<void> {
37
+ const result = await handlePublish(activeCollection);
38
+ if (result.status === 'needs-filename') {
39
+ dialog.open('filename');
40
+ }
41
+ }
42
+ </script>
43
+
44
+ {#if file}
45
+ <header class="toolbar">
46
+ <div class="toolbar__info">
47
+ <h1 class="toolbar__title">
48
+ {title}
49
+ <span
50
+ class="dirty-indicator"
51
+ class:dirty-indicator--visible={file.dirty}
52
+ title={file.dirty ? 'Unsaved changes' : ''}>&bull;</span
53
+ >
54
+ </h1>
55
+ {#if file.filename}
56
+ <p class="toolbar__filename">{file.filename}</p>
57
+ {/if}
58
+ </div>
59
+ <div class="toolbar__actions">
60
+ {#if file.draftId}
61
+ <button
62
+ class="btn btn--danger-outline btn--compact"
63
+ type="button"
64
+ onclick={() => dialog.open('delete')}
65
+ >
66
+ Delete Draft
67
+ </button>
68
+ {/if}
69
+ <button
70
+ class="btn btn--save-outline btn--compact"
71
+ type="button"
72
+ disabled={!file.dirty || file.saving}
73
+ onclick={() => handleSave(activeCollection)}
74
+ >
75
+ {file.saving ? 'Saving...' : 'Save'}
76
+ </button>
77
+ <button
78
+ class="btn btn--primary btn--compact"
79
+ type="button"
80
+ disabled={publishDisabled || file.saving}
81
+ onclick={onPublish}
82
+ >
83
+ Publish
84
+ </button>
85
+ </div>
86
+ </header>
87
+ {/if}
88
+
89
+ <style>
90
+ .toolbar {
91
+ display: grid;
92
+ grid-template-columns: 1fr auto;
93
+ align-items: center;
94
+ padding: 0.5rem 1rem;
95
+ border-bottom: 1px solid var(--cms-border);
96
+ }
97
+
98
+ .toolbar__info {
99
+ display: grid;
100
+ }
101
+
102
+ .toolbar__title {
103
+ font-size: 1rem;
104
+ font-weight: normal;
105
+ color: var(--cms-fg);
106
+ }
107
+
108
+ .toolbar__filename {
109
+ font-size: 0.75rem;
110
+ color: var(--cms-muted);
111
+ }
112
+
113
+ /* Always rendered to reserve space and prevent layout shift when toggling */
114
+ .dirty-indicator {
115
+ color: transparent;
116
+ font-size: 1.25rem;
117
+ vertical-align: middle;
118
+ margin-left: 0.25rem;
119
+ }
120
+
121
+ .dirty-indicator--visible {
122
+ color: var(--gold);
123
+ }
124
+
125
+ .toolbar__actions {
126
+ display: grid;
127
+ grid-auto-flow: column;
128
+ align-items: center;
129
+ gap: 0.5rem;
130
+ }
131
+ </style>
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ import {
3
+ getDefaultExtension,
4
+ getTypeForFilename,
5
+ } from '../../js/utils/file-types';
6
+ import {
7
+ changeFileFormat,
8
+ getEditorFile,
9
+ } from '../../js/editor/editor.svelte';
10
+ import { schema } from '../../js/state/schema.svelte';
11
+
12
+ // Type identifiers from the schema's files array (e.g. ['md', 'mdx'])
13
+ const fileTypes = $derived(
14
+ Array.isArray(schema.active?.['files'])
15
+ ? (schema.active['files'] as string[])
16
+ : [],
17
+ );
18
+
19
+ // The type identifier of the currently open file (e.g. 'md', 'mdx')
20
+ const activeType = $derived.by(() => {
21
+ const file = getEditorFile();
22
+ if (!file?.filename) return fileTypes[0] ?? '';
23
+ return getTypeForFilename(file.filename) ?? fileTypes[0] ?? '';
24
+ });
25
+ </script>
26
+
27
+ {#if fileTypes.length > 1}
28
+ <div class="format-selector">
29
+ <span class="format-selector__label">Format</span>
30
+ <select
31
+ class="format-selector__select"
32
+ value={activeType}
33
+ onchange={(e) => changeFileFormat((e.target as HTMLSelectElement).value)}
34
+ >
35
+ {#each fileTypes as type}
36
+ <option value={type}>{getDefaultExtension(type) ?? type}</option>
37
+ {/each}
38
+ </select>
39
+ </div>
40
+ {/if}
41
+
42
+ <style>
43
+ /* Inline layout for label + select inside the editor body toolbar */
44
+ .format-selector {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 0.5rem;
48
+ }
49
+
50
+ .format-selector__label {
51
+ font-size: 0.875rem;
52
+ color: var(--cms-muted);
53
+ text-transform: uppercase;
54
+ letter-spacing: 0.05em;
55
+ }
56
+
57
+ .format-selector__select {
58
+ background: var(--cms-bg);
59
+ border: 1px solid var(--cms-border);
60
+ border-radius: 0.25rem;
61
+ color: var(--cms-fg);
62
+ font-size: 0.875rem;
63
+ padding: 0.25rem 0.5rem;
64
+ max-width: 10rem;
65
+ }
66
+ </style>
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import FormatSelector from './FormatSelector.svelte';
3
+ </script>
4
+
5
+ <div class="editor-body-toolbar">
6
+ <FormatSelector />
7
+ </div>
8
+
9
+ <style>
10
+ .editor-body-toolbar {
11
+ display: flex;
12
+ align-items: center;
13
+ padding: 0.25rem 0.5rem;
14
+ border-bottom: 1px solid var(--cms-border);
15
+ background: var(--cms-bg);
16
+ }
17
+ </style>