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.
- package/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +178 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reactive editor state for the file editing view.
|
|
3
|
+
* Manages loading, saving, and dirty-checking of content files and drafts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { registerDirtyChecker } from '../state/router.svelte';
|
|
7
|
+
import { splitFrontmatter } from '../utils/frontmatter';
|
|
8
|
+
import { setByPath, type PathSegment } from '../utils/schema-utils';
|
|
9
|
+
import { getDraftByFile } from '../drafts/storage';
|
|
10
|
+
import { storageClient } from '../state/state.svelte';
|
|
11
|
+
import {
|
|
12
|
+
getFileCategory,
|
|
13
|
+
stripExtension,
|
|
14
|
+
getDefaultExtension,
|
|
15
|
+
FILE_TYPES,
|
|
16
|
+
} from '../utils/file-types';
|
|
17
|
+
|
|
18
|
+
// Editor file state exposed via getEditorFile().
|
|
19
|
+
export type EditorFile = {
|
|
20
|
+
body: string;
|
|
21
|
+
formData: Record<string, unknown>;
|
|
22
|
+
dirty: boolean;
|
|
23
|
+
saving: boolean;
|
|
24
|
+
filename: string;
|
|
25
|
+
bodyLoaded: boolean;
|
|
26
|
+
draftId: string | null;
|
|
27
|
+
isNewDraft: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Shape for bulk-setting all editor state via applyEditorState.
|
|
31
|
+
export type EditorStateConfig = {
|
|
32
|
+
body: string;
|
|
33
|
+
formData: Record<string, unknown>;
|
|
34
|
+
filename: string;
|
|
35
|
+
bodyLoaded: boolean;
|
|
36
|
+
draftId: string | null;
|
|
37
|
+
isNewDraft: boolean;
|
|
38
|
+
snapshot: string | null;
|
|
39
|
+
collection: string;
|
|
40
|
+
draftCreatedAt: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let body = $state('');
|
|
44
|
+
let formData = $state<Record<string, unknown>>({});
|
|
45
|
+
let dirty = $state(false);
|
|
46
|
+
let saving = $state(false);
|
|
47
|
+
let lastSavedBody = '';
|
|
48
|
+
let lastSavedFormData = '{}';
|
|
49
|
+
let filename = $state('');
|
|
50
|
+
let fileOpen = $state(false);
|
|
51
|
+
let activeTab = $state('metadata');
|
|
52
|
+
let bodyLoaded = $state(false);
|
|
53
|
+
let originalFilename = $state(''); // filename at load time — publish uses this to detect renames
|
|
54
|
+
// Draft-specific state
|
|
55
|
+
let draftId = $state<string | null>(null);
|
|
56
|
+
let isNewDraft = $state(false);
|
|
57
|
+
let snapshot = $state<string | null>(null);
|
|
58
|
+
let currentCollection = $state('');
|
|
59
|
+
let draftCreatedAt = $state<string | null>(null);
|
|
60
|
+
registerDirtyChecker(() => dirty);
|
|
61
|
+
|
|
62
|
+
export const editor = {
|
|
63
|
+
// Current structured form data for the open file.
|
|
64
|
+
get data(): Record<string, unknown> {
|
|
65
|
+
return formData;
|
|
66
|
+
},
|
|
67
|
+
// Currently active editor tab identifier.
|
|
68
|
+
get tab(): string {
|
|
69
|
+
return activeTab;
|
|
70
|
+
},
|
|
71
|
+
// Filename at load time — publish uses this to detect renames.
|
|
72
|
+
get originalFilename(): string {
|
|
73
|
+
return originalFilename;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Applies a full set of editor state values, resetting dirty/saving flags and updating save baselines.
|
|
79
|
+
* @param {EditorStateConfig} c - All editor state fields to apply
|
|
80
|
+
* @param {boolean} open - Whether to mark the file as open
|
|
81
|
+
* @return {void}
|
|
82
|
+
*/
|
|
83
|
+
export function applyEditorState(c: EditorStateConfig, open: boolean): void {
|
|
84
|
+
formData = c.formData;
|
|
85
|
+
lastSavedFormData = JSON.stringify(c.formData);
|
|
86
|
+
body = c.body;
|
|
87
|
+
lastSavedBody = c.body;
|
|
88
|
+
dirty = false;
|
|
89
|
+
formDataDirty = false;
|
|
90
|
+
saving = false;
|
|
91
|
+
filename = c.filename;
|
|
92
|
+
originalFilename = c.filename;
|
|
93
|
+
bodyLoaded = c.bodyLoaded;
|
|
94
|
+
activeTab = 'metadata';
|
|
95
|
+
fileOpen = open;
|
|
96
|
+
draftId = c.draftId;
|
|
97
|
+
isNewDraft = c.isNewDraft;
|
|
98
|
+
snapshot = c.snapshot;
|
|
99
|
+
currentCollection = c.collection;
|
|
100
|
+
draftCreatedAt = c.draftCreatedAt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* Tracks whether formData has diverged from its saved snapshot.
|
|
105
|
+
* Updated only by updateFormField to avoid re-serializing on every body keystroke.
|
|
106
|
+
*/
|
|
107
|
+
let formDataDirty = false;
|
|
108
|
+
/**
|
|
109
|
+
* Recomputes dirty state from body comparison and the cached formData flag.
|
|
110
|
+
* @return {void}
|
|
111
|
+
*/
|
|
112
|
+
function recomputeDirty(): void {
|
|
113
|
+
dirty = body !== lastSavedBody || formDataDirty;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Returns a snapshot of draft-related internal state for use by editor-draft-ops. Exposes private module state without leaking $state reactivity.
|
|
118
|
+
* @return {object} Snapshot of all draft-related editor state
|
|
119
|
+
*/
|
|
120
|
+
export function _getDraftState() {
|
|
121
|
+
return {
|
|
122
|
+
saving,
|
|
123
|
+
draftId,
|
|
124
|
+
isNewDraft,
|
|
125
|
+
snapshot,
|
|
126
|
+
currentCollection,
|
|
127
|
+
draftCreatedAt,
|
|
128
|
+
lastSavedFormData,
|
|
129
|
+
lastSavedBody,
|
|
130
|
+
formData,
|
|
131
|
+
body,
|
|
132
|
+
filename,
|
|
133
|
+
originalFilename,
|
|
134
|
+
dirty,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Applies draft-related state mutations from editor-draft-ops back into the reactive module state.
|
|
140
|
+
* @param {Partial<ReturnType<typeof _getDraftState>>} updates - Fields to update
|
|
141
|
+
* @return {void}
|
|
142
|
+
*/
|
|
143
|
+
export function _setDraftState(
|
|
144
|
+
updates: Partial<ReturnType<typeof _getDraftState>>,
|
|
145
|
+
): void {
|
|
146
|
+
if ('saving' in updates) saving = updates.saving!;
|
|
147
|
+
if ('draftId' in updates) draftId = updates.draftId!;
|
|
148
|
+
if ('isNewDraft' in updates) isNewDraft = updates.isNewDraft!;
|
|
149
|
+
if ('snapshot' in updates) snapshot = updates.snapshot!;
|
|
150
|
+
if ('draftCreatedAt' in updates) draftCreatedAt = updates.draftCreatedAt!;
|
|
151
|
+
if ('lastSavedFormData' in updates)
|
|
152
|
+
lastSavedFormData = updates.lastSavedFormData!;
|
|
153
|
+
if ('lastSavedBody' in updates) lastSavedBody = updates.lastSavedBody!;
|
|
154
|
+
if ('dirty' in updates) dirty = updates.dirty!;
|
|
155
|
+
if ('originalFilename' in updates)
|
|
156
|
+
originalFilename = updates.originalFilename!;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Returns the current editor file state, or null if no file is open.
|
|
160
|
+
* @return {EditorFile | null} The current editor file state, or null
|
|
161
|
+
*/
|
|
162
|
+
export function getEditorFile(): EditorFile | null {
|
|
163
|
+
if (!fileOpen) return null;
|
|
164
|
+
return {
|
|
165
|
+
body,
|
|
166
|
+
formData,
|
|
167
|
+
dirty,
|
|
168
|
+
saving,
|
|
169
|
+
filename,
|
|
170
|
+
bodyLoaded,
|
|
171
|
+
draftId,
|
|
172
|
+
isNewDraft,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Sets the active editor tab.
|
|
177
|
+
* @param {string} tab - The tab identifier to activate
|
|
178
|
+
* @return {void}
|
|
179
|
+
*/
|
|
180
|
+
export function setActiveTab(tab: string): void {
|
|
181
|
+
activeTab = tab;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Updates a single field within formData by path and recomputes dirty state.
|
|
185
|
+
* @param {PathSegment[]} path - Ordered path segments addressing the field
|
|
186
|
+
* @param {unknown} value - The new value to assign at the given path
|
|
187
|
+
* @return {void}
|
|
188
|
+
*/
|
|
189
|
+
export function updateFormField(path: PathSegment[], value: unknown): void {
|
|
190
|
+
setByPath(formData, path, value);
|
|
191
|
+
formDataDirty = JSON.stringify(formData) !== lastSavedFormData;
|
|
192
|
+
recomputeDirty();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Populates the editor with metadata so the UI renders without waiting for the async file read. Checks IndexedDB for an existing draft first — if found, loads draft data instead.
|
|
197
|
+
* @param {string} collection - The collection this file belongs to
|
|
198
|
+
* @param {string} itemFilename - The content file's name
|
|
199
|
+
* @param {Record<string, unknown>} data - Pre-parsed frontmatter data
|
|
200
|
+
* @return {Promise<void>}
|
|
201
|
+
*/
|
|
202
|
+
export async function preloadFile(
|
|
203
|
+
collection: string,
|
|
204
|
+
itemFilename: string,
|
|
205
|
+
data: Record<string, unknown>,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
// Compare slugs so a format change (e.g. .md → .mdx) doesn't trigger a full reload
|
|
208
|
+
if (stripExtension(filename) === stripExtension(itemFilename) && fileOpen)
|
|
209
|
+
return;
|
|
210
|
+
|
|
211
|
+
// Check IndexedDB for an existing draft of this live file
|
|
212
|
+
const d = await getDraftByFile(collection, itemFilename);
|
|
213
|
+
if (d) {
|
|
214
|
+
// Draft already contains body content, no disk read needed
|
|
215
|
+
applyEditorState(
|
|
216
|
+
{
|
|
217
|
+
body: d.body,
|
|
218
|
+
formData: d.formData,
|
|
219
|
+
filename: itemFilename,
|
|
220
|
+
bodyLoaded: true,
|
|
221
|
+
draftId: d.id,
|
|
222
|
+
isNewDraft: d.isNew,
|
|
223
|
+
snapshot: d.snapshot,
|
|
224
|
+
collection,
|
|
225
|
+
draftCreatedAt: d.createdAt,
|
|
226
|
+
},
|
|
227
|
+
true,
|
|
228
|
+
);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// No draft — load live data; $state.snapshot strips Svelte reactive proxies
|
|
232
|
+
applyEditorState(
|
|
233
|
+
{
|
|
234
|
+
body: '',
|
|
235
|
+
formData: $state.snapshot(data) as Record<string, unknown>,
|
|
236
|
+
filename: itemFilename,
|
|
237
|
+
bodyLoaded: false,
|
|
238
|
+
draftId: null,
|
|
239
|
+
isNewDraft: false,
|
|
240
|
+
snapshot: null,
|
|
241
|
+
collection,
|
|
242
|
+
draftCreatedAt: null,
|
|
243
|
+
},
|
|
244
|
+
true,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Sets the filename for the current editor file. Used by the filename dialog.
|
|
250
|
+
* @param {string} newFilename - The new filename to set
|
|
251
|
+
* @return {void}
|
|
252
|
+
*/
|
|
253
|
+
export function setFilename(newFilename: string): void {
|
|
254
|
+
filename = newFilename;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Changes the file format by swapping the filename extension to the new type's default. Preserves the slug (base filename without extension) and leaves originalFilename untouched so publishFile can detect the rename and delete the old file on disk.
|
|
259
|
+
* @param {string} newType - The type identifier to switch to (e.g. 'md', 'json')
|
|
260
|
+
* @return {void}
|
|
261
|
+
*/
|
|
262
|
+
export function changeFileFormat(newType: string): void {
|
|
263
|
+
const ext = getDefaultExtension(newType);
|
|
264
|
+
if (!ext) return;
|
|
265
|
+
const slug = filename ? stripExtension(filename) : '';
|
|
266
|
+
filename = slug ? slug + ext : '';
|
|
267
|
+
// Switch to metadata tab if the new format has no body editor
|
|
268
|
+
const config = FILE_TYPES[newType];
|
|
269
|
+
if (config && !config.hasBody && activeTab === 'body') activeTab = 'metadata';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Sets a default file format for a new draft based on the collection's supported file types. Only applies when the editor has no filename yet (new draft). Sets the activeTab to 'body' if the format supports body editing.
|
|
274
|
+
* @param {string[]} fileTypes - Type identifiers from the schema's files array
|
|
275
|
+
* @return {void}
|
|
276
|
+
*/
|
|
277
|
+
export function setDefaultFormat(fileTypes: string[]): void {
|
|
278
|
+
if (filename || fileTypes.length === 0) return;
|
|
279
|
+
const defaultType = fileTypes[0];
|
|
280
|
+
const ext = getDefaultExtension(defaultType);
|
|
281
|
+
if (!ext) return;
|
|
282
|
+
// Set just the extension so EditorTabs and FormatSelector derive the correct type
|
|
283
|
+
filename = ext;
|
|
284
|
+
// Activate body tab for content types that support it
|
|
285
|
+
if (FILE_TYPES[defaultType]?.hasBody) activeTab = 'body';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Loads body content via StorageClient for an already-preloaded file, completing the two-phase load.
|
|
290
|
+
* @param {string} collection - The collection the file belongs to
|
|
291
|
+
* @param {string} filename - The filename to read within the collection
|
|
292
|
+
* @return {Promise<void>}
|
|
293
|
+
*/
|
|
294
|
+
export async function loadFileBody(
|
|
295
|
+
collection: string,
|
|
296
|
+
filename: string,
|
|
297
|
+
): Promise<void> {
|
|
298
|
+
const category = getFileCategory(filename);
|
|
299
|
+
if (category === 'data') {
|
|
300
|
+
// Data files have no body — all content was parsed as formData during preload
|
|
301
|
+
bodyLoaded = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!storageClient) return;
|
|
305
|
+
const text = await storageClient.readFile(collection, filename);
|
|
306
|
+
const split = splitFrontmatter(text);
|
|
307
|
+
// Strip leading/trailing newlines from body; added back on save when reconstituting the file
|
|
308
|
+
body = lastSavedBody = split.body.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
309
|
+
bodyLoaded = true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Updates the editor body content and recomputes dirty state.
|
|
314
|
+
* Only compares body against its saved snapshot — avoids serializing
|
|
315
|
+
* formData to JSON on every keystroke from CodeMirror's update listener.
|
|
316
|
+
* @param {string} content - The new body content
|
|
317
|
+
* @return {void}
|
|
318
|
+
*/
|
|
319
|
+
export function updateBody(content: string): void {
|
|
320
|
+
body = content;
|
|
321
|
+
recomputeDirty();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Resets all editor state including draft-specific fields.
|
|
326
|
+
* @return {void}
|
|
327
|
+
*/
|
|
328
|
+
export function clearEditor(): void {
|
|
329
|
+
applyEditorState(
|
|
330
|
+
{
|
|
331
|
+
body: '',
|
|
332
|
+
formData: {},
|
|
333
|
+
filename: '',
|
|
334
|
+
bodyLoaded: false,
|
|
335
|
+
draftId: null,
|
|
336
|
+
isNewDraft: false,
|
|
337
|
+
snapshot: null,
|
|
338
|
+
collection: '',
|
|
339
|
+
draftCreatedAt: null,
|
|
340
|
+
},
|
|
341
|
+
false,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Language extension registry for the CodeMirror editor.
|
|
3
|
+
* Provides lazy-loaded language extensions keyed by file type identifier.
|
|
4
|
+
* Today all body formats use the same markdown parser — the registry
|
|
5
|
+
* exists so MDX/Markdoc-specific parsers can drop in later without
|
|
6
|
+
* changing EditorPane.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
10
|
+
import { languages } from '@codemirror/language-data';
|
|
11
|
+
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
|
|
12
|
+
import { tags as t } from '@lezer/highlight';
|
|
13
|
+
import type { Extension } from '@codemirror/state';
|
|
14
|
+
|
|
15
|
+
// Highlight style for the editor — headings are sized, syntax markers are dimmed.
|
|
16
|
+
const editorHighlight = HighlightStyle.define([
|
|
17
|
+
// Headings — larger, bold
|
|
18
|
+
{
|
|
19
|
+
tag: t.heading1,
|
|
20
|
+
fontSize: '1.5rem',
|
|
21
|
+
fontWeight: 'bold',
|
|
22
|
+
color: 'var(--cms-fg)',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
tag: t.heading2,
|
|
26
|
+
fontSize: '1.25rem',
|
|
27
|
+
fontWeight: 'bold',
|
|
28
|
+
color: 'var(--cms-fg)',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
tag: t.heading3,
|
|
32
|
+
fontSize: '1rem',
|
|
33
|
+
fontWeight: 'bold',
|
|
34
|
+
color: 'var(--cms-fg)',
|
|
35
|
+
},
|
|
36
|
+
// Emphasis
|
|
37
|
+
{ tag: t.strong, fontWeight: 'bold', color: 'var(--cms-fg)' },
|
|
38
|
+
{ tag: t.emphasis, fontStyle: 'italic', color: 'var(--cms-fg)' },
|
|
39
|
+
// Inline code
|
|
40
|
+
{ tag: t.monospace, color: 'var(--light-orange)' },
|
|
41
|
+
// Links
|
|
42
|
+
{ tag: t.link, color: 'var(--light-teal)', textDecoration: 'underline' },
|
|
43
|
+
{ tag: t.url, color: 'var(--light-green)' },
|
|
44
|
+
// Syntax markers — dimmed
|
|
45
|
+
{ tag: t.processingInstruction, color: 'var(--cms-muted)' },
|
|
46
|
+
{ tag: t.labelName, color: 'var(--light-teal)' },
|
|
47
|
+
// Code block language tag
|
|
48
|
+
{ tag: t.tagName, color: 'var(--light-purple)' },
|
|
49
|
+
// Lists
|
|
50
|
+
{ tag: t.list, color: 'var(--light-teal)' },
|
|
51
|
+
// Blockquotes
|
|
52
|
+
{ tag: t.quote, color: 'var(--cms-muted)', fontStyle: 'italic' },
|
|
53
|
+
// Code block contents — language-specific highlighting
|
|
54
|
+
{ tag: t.keyword, color: 'var(--light-plum)' },
|
|
55
|
+
{ tag: t.string, color: 'var(--light-orange)' },
|
|
56
|
+
{ tag: t.variableName, color: 'var(--light-teal)' },
|
|
57
|
+
{ tag: t.function(t.variableName), color: 'var(--gold)' },
|
|
58
|
+
{ tag: t.typeName, color: 'var(--light-green)' },
|
|
59
|
+
{ tag: t.number, color: 'var(--light-purple)' },
|
|
60
|
+
{ tag: t.bool, color: 'var(--light-purple)' },
|
|
61
|
+
{ tag: t.comment, color: 'var(--cms-muted)', fontStyle: 'italic' },
|
|
62
|
+
{ tag: t.operator, color: 'var(--light-red)' },
|
|
63
|
+
{ tag: t.punctuation, color: 'var(--cms-muted)' },
|
|
64
|
+
{ tag: t.meta, color: 'var(--cms-muted)' },
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* Cached extensions per file type — avoids re-creating parser instances and
|
|
69
|
+
* lets CodeMirror short-circuit its extension diff via reference equality.
|
|
70
|
+
*/
|
|
71
|
+
const cache = new Map<string, Extension>();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the composed language extension (parser + syntax highlighting)
|
|
75
|
+
* for a given file type. Results are cached per type so repeated calls
|
|
76
|
+
* return the same reference. Today all body formats use the same markdown
|
|
77
|
+
* extension — individual entries can diverge when custom parsers are added.
|
|
78
|
+
* @param {string} fileType - Type identifier from the file type registry (e.g. 'md', 'mdx', 'markdoc')
|
|
79
|
+
* @return {Extension} The composed CodeMirror language extension
|
|
80
|
+
*/
|
|
81
|
+
export function getLanguageExtension(fileType: string): Extension {
|
|
82
|
+
let ext = cache.get(fileType);
|
|
83
|
+
if (ext) return ext;
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* All body formats currently use the same markdown parser.
|
|
87
|
+
* When MDX/Markdoc-specific parsers are built, add branches here.
|
|
88
|
+
*/
|
|
89
|
+
ext = [
|
|
90
|
+
markdown({
|
|
91
|
+
base: markdownLanguage,
|
|
92
|
+
codeLanguages: languages,
|
|
93
|
+
}),
|
|
94
|
+
syntaxHighlighting(editorHighlight),
|
|
95
|
+
];
|
|
96
|
+
cache.set(fileType, ext);
|
|
97
|
+
return ext;
|
|
98
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* CodeMirror extension that decorates markdown link nodes with a CSS class.
|
|
3
|
+
* Enables visual styling of link syntax in the editor.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ViewPlugin, Decoration, type DecorationSet } from '@codemirror/view';
|
|
7
|
+
import { syntaxTree } from '@codemirror/language';
|
|
8
|
+
import { RangeSetBuilder, type EditorState } from '@codemirror/state';
|
|
9
|
+
|
|
10
|
+
// Mark decoration that adds the cm-link-wrap class to link nodes
|
|
11
|
+
const linkMark = Decoration.mark({ class: 'cm-link-wrap' });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds a DecorationSet marking all Link nodes in the syntax tree with the cm-link-wrap class.
|
|
15
|
+
* @param {EditorState} state - The current editor state
|
|
16
|
+
* @return {DecorationSet} The decoration set with all link ranges marked
|
|
17
|
+
*/
|
|
18
|
+
function buildDecorations(state: EditorState): DecorationSet {
|
|
19
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
20
|
+
syntaxTree(state).iterate({
|
|
21
|
+
enter(node) {
|
|
22
|
+
if (node.name === 'Link') {
|
|
23
|
+
builder.add(node.from, node.to, linkMark);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return builder.finish();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* ViewPlugin that adds a word-break: break-all wrapper around markdown links.
|
|
32
|
+
* This prevents the Unicode Line Break Algorithm from breaking between ] and (
|
|
33
|
+
* in [text](url) syntax, which causes URLs to jump to the next line.
|
|
34
|
+
*/
|
|
35
|
+
export const linkWrapPlugin = ViewPlugin.define(
|
|
36
|
+
(view) => ({
|
|
37
|
+
decorations: buildDecorations(view.state),
|
|
38
|
+
update(update) {
|
|
39
|
+
if (update.docChanged || update.viewportChanged) {
|
|
40
|
+
this.decorations = buildDecorations(update.state);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
{ decorations: (v) => v.decorations },
|
|
45
|
+
);
|