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,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
|
+
});
|