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,295 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Astro integration entry point for Nebula CMS.
|
|
3
|
+
* Exposes content collection JSON schemas and CMS configuration to
|
|
4
|
+
* client-side JavaScript via virtual modules, dev middleware, and
|
|
5
|
+
* build-time file copy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import type { AstroIntegration, AstroIntegrationLogger } from 'astro';
|
|
18
|
+
import type { NebulaCMSConfig } from '../types.js';
|
|
19
|
+
|
|
20
|
+
// Vite virtual module IDs
|
|
21
|
+
const CONFIG_VIRTUAL_ID = 'virtual:nebula/config';
|
|
22
|
+
const CONFIG_RESOLVED_ID = '\0' + CONFIG_VIRTUAL_ID;
|
|
23
|
+
const COLLECTIONS_VIRTUAL_ID = 'virtual:nebula/collections';
|
|
24
|
+
const COLLECTIONS_RESOLVED_ID = '\0' + COLLECTIONS_VIRTUAL_ID;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes a path config value to an absolute path using the URL API.
|
|
28
|
+
* Handles leading-slash prepending, consecutive-slash collapsing,
|
|
29
|
+
* trailing-slash stripping, and rejects paths with characters that
|
|
30
|
+
* require percent-encoding (e.g. spaces, angle brackets).
|
|
31
|
+
* @param {string} label - Config field name for error messages (e.g. 'basePath')
|
|
32
|
+
* @param {string} value - The raw config value
|
|
33
|
+
* @return {string} The normalized absolute path
|
|
34
|
+
*/
|
|
35
|
+
function normalizePath(label: string, value: string): string {
|
|
36
|
+
/*
|
|
37
|
+
* Reject protocol-relative inputs ('//admin') — the URL API interprets
|
|
38
|
+
* these as hostnames, silently producing pathname '/' instead of '/admin'.
|
|
39
|
+
*/
|
|
40
|
+
if (value.startsWith('//')) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Invalid ${label} "${value}". Path must not start with "//".`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
/*
|
|
46
|
+
* URL constructor with a dummy base handles both relative ('admin') and
|
|
47
|
+
* absolute ('/admin') inputs identically. Collapse consecutive slashes
|
|
48
|
+
* manually since the URL API preserves them in pathnames.
|
|
49
|
+
*/
|
|
50
|
+
const collapsed = new URL(value, 'http://x').pathname.replace(/\/\/+/g, '/');
|
|
51
|
+
// Strip trailing slash unless root
|
|
52
|
+
const normalized =
|
|
53
|
+
collapsed.length > 1 && collapsed.endsWith('/')
|
|
54
|
+
? collapsed.slice(0, -1)
|
|
55
|
+
: collapsed;
|
|
56
|
+
/*
|
|
57
|
+
* The URL API percent-encodes special characters rather than rejecting them
|
|
58
|
+
* (e.g. '/admin/<script>' → '/admin/%3Cscript%3E'). Comparing the decoded
|
|
59
|
+
* form to the normalized result catches any input that required encoding.
|
|
60
|
+
*/
|
|
61
|
+
if (decodeURIComponent(normalized) !== normalized) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Invalid ${label} "${value}". Path contains characters that require URL encoding.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Astro integration that exposes CMS configuration and content collection JSON schemas to client-side JavaScript via virtual modules, dev middleware, and build-time file copy.
|
|
71
|
+
* @param {NebulaCMSConfig} config - Optional configuration object
|
|
72
|
+
* @return {AstroIntegration} The configured Astro integration object
|
|
73
|
+
*/
|
|
74
|
+
export default function NebulaCMS(
|
|
75
|
+
config: NebulaCMSConfig = {},
|
|
76
|
+
): AstroIntegration {
|
|
77
|
+
if (config.basePath === '') {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'Invalid basePath "". Provide a path like "/admin" or "/".',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (config.collectionsPath === '') {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'Invalid collectionsPath "". Provide a path like "/collections".',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const basePath = normalizePath('basePath', config.basePath ?? '/admin');
|
|
90
|
+
const collectionsPath = normalizePath(
|
|
91
|
+
'collectionsPath',
|
|
92
|
+
config.collectionsPath ?? '/collections',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (collectionsPath === '/') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'Invalid collectionsPath "/". Collections require a path prefix.',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Normalized config passed to the Vite plugin
|
|
102
|
+
const normalizedConfig = { basePath, collectionsPath };
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: 'nebula-cms',
|
|
106
|
+
hooks: {
|
|
107
|
+
'astro:config:setup': ({ updateConfig, logger }) => {
|
|
108
|
+
updateConfig({
|
|
109
|
+
vite: {
|
|
110
|
+
plugins: [
|
|
111
|
+
nebulaVitePlugin(logger, process.cwd(), normalizedConfig),
|
|
112
|
+
],
|
|
113
|
+
/*
|
|
114
|
+
* Workers use dynamic imports (e.g. storage worker lazy-loads
|
|
115
|
+
* adapters), which require code splitting. The default 'iife'
|
|
116
|
+
* format does not support code splitting, so use ES modules.
|
|
117
|
+
*/
|
|
118
|
+
worker: { format: 'es' },
|
|
119
|
+
/*
|
|
120
|
+
* smol-toml is only imported inside the TOML parser sub-worker,
|
|
121
|
+
* never on the main thread. Without this, Vite discovers it late
|
|
122
|
+
* and re-optimizes mid-session, causing the worker to request a
|
|
123
|
+
* stale dep hash (504 Outdated Optimize Dep).
|
|
124
|
+
*/
|
|
125
|
+
optimizeDeps: {
|
|
126
|
+
include: ['smol-toml'],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
// Copy schema files into the build output after Astro finishes.
|
|
132
|
+
'astro:build:done': ({ dir, logger }) => {
|
|
133
|
+
const source = resolve(process.cwd(), '.astro/collections');
|
|
134
|
+
if (!existsSync(source)) {
|
|
135
|
+
logger.warn(
|
|
136
|
+
'`.astro/collections` not found — schema files will not be in the build output.',
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const outDir = fileURLToPath(dir);
|
|
141
|
+
// Strip leading slash for filesystem path resolution
|
|
142
|
+
const target = resolve(outDir, collectionsPath.slice(1));
|
|
143
|
+
mkdirSync(target, { recursive: true });
|
|
144
|
+
const files = readdirSync(source).filter((f) =>
|
|
145
|
+
f.endsWith('.schema.json'),
|
|
146
|
+
);
|
|
147
|
+
for (const f of files) {
|
|
148
|
+
copyFileSync(resolve(source, f), resolve(target, f));
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Vite plugin that serves collection schemas and CMS config via virtual modules.
|
|
157
|
+
* @internal Not part of the public API — exported for testing only
|
|
158
|
+
* @param {AstroIntegrationLogger} logger - Astro integration logger for warnings
|
|
159
|
+
* @param {string} root - Project root directory
|
|
160
|
+
* @param {Required<NebulaCMSConfig>} config - Normalized CMS configuration (both paths absolute, no trailing slash)
|
|
161
|
+
* @return {object} A Vite plugin object with configureServer, resolveId, and load hooks
|
|
162
|
+
*/
|
|
163
|
+
export function nebulaVitePlugin(
|
|
164
|
+
logger: AstroIntegrationLogger,
|
|
165
|
+
root: string,
|
|
166
|
+
config: Required<NebulaCMSConfig>,
|
|
167
|
+
) {
|
|
168
|
+
return {
|
|
169
|
+
name: 'vite-plugin-nebula-cms',
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Registers dev middleware: serves schema files from .astro/collections/
|
|
173
|
+
* and rewrites SPA sub-routes under basePath to the basePath page.
|
|
174
|
+
* @param {{ middlewares: { use: Function } }} server - The Vite dev server
|
|
175
|
+
* @return {void}
|
|
176
|
+
*/
|
|
177
|
+
configureServer(server: { middlewares: { use: Function } }) {
|
|
178
|
+
const prefix = config.collectionsPath + '/';
|
|
179
|
+
const collectionsDir = resolve(root, '.astro/collections');
|
|
180
|
+
|
|
181
|
+
// Serve collection schema JSON files
|
|
182
|
+
server.middlewares.use(
|
|
183
|
+
(
|
|
184
|
+
req: { url?: string },
|
|
185
|
+
res: { setHeader: Function; end: Function },
|
|
186
|
+
next: Function,
|
|
187
|
+
) => {
|
|
188
|
+
// Extract pathname, stripping query strings and fragments
|
|
189
|
+
const url = new URL(req.url ?? '', 'http://x').pathname;
|
|
190
|
+
if (!url.startsWith(prefix) || !url.endsWith('.schema.json')) {
|
|
191
|
+
return next();
|
|
192
|
+
}
|
|
193
|
+
const filename = url.slice(prefix.length);
|
|
194
|
+
const filePath = resolve(collectionsDir, filename);
|
|
195
|
+
// Reject path traversal attempts (e.g. /../../../etc/passwd.schema.json)
|
|
196
|
+
if (!filePath.startsWith(collectionsDir + '/')) return next();
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
res.setHeader('Content-Type', 'application/json');
|
|
200
|
+
res.end(readFileSync(filePath, 'utf-8'));
|
|
201
|
+
} catch (err: unknown) {
|
|
202
|
+
// File not found — fall through to Vite's default handler
|
|
203
|
+
if (
|
|
204
|
+
err instanceof Error &&
|
|
205
|
+
(err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
206
|
+
) {
|
|
207
|
+
return next();
|
|
208
|
+
}
|
|
209
|
+
// Re-throw permission errors, I/O errors, etc. so they surface
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// SPA fallback: rewrite HTML requests under basePath to basePath
|
|
216
|
+
server.middlewares.use(
|
|
217
|
+
(
|
|
218
|
+
req: { url?: string; headers?: Record<string, string | undefined> },
|
|
219
|
+
_res: unknown,
|
|
220
|
+
next: Function,
|
|
221
|
+
) => {
|
|
222
|
+
const rawURL = req.url ?? '';
|
|
223
|
+
const accept = req.headers?.accept ?? '';
|
|
224
|
+
|
|
225
|
+
// Only rewrite document requests
|
|
226
|
+
if (!accept.includes('text/html')) return next();
|
|
227
|
+
|
|
228
|
+
const parsed = new URL(rawURL, 'http://x');
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* Check segment boundary: /admin/foo rewrites, /administrator does not.
|
|
232
|
+
* Root basePath '/' needs special handling — every path is a sub-path
|
|
233
|
+
* except '/' itself, mirroring router.svelte.ts isUnderBasePath.
|
|
234
|
+
*/
|
|
235
|
+
const isSubPath =
|
|
236
|
+
config.basePath === '/'
|
|
237
|
+
? parsed.pathname !== '/' && parsed.pathname.startsWith('/')
|
|
238
|
+
: parsed.pathname !== config.basePath &&
|
|
239
|
+
parsed.pathname.startsWith(config.basePath + '/');
|
|
240
|
+
|
|
241
|
+
if (isSubPath) {
|
|
242
|
+
// Preserve query string and hash for deep-linking and OAuth callbacks
|
|
243
|
+
req.url = config.basePath + parsed.search + parsed.hash;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return next();
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolves virtual:nebula/* imports to Vite-internal IDs.
|
|
253
|
+
* @param {string} id - The module ID being resolved
|
|
254
|
+
* @return {string | undefined} The resolved internal ID, or undefined if not handled
|
|
255
|
+
*/
|
|
256
|
+
resolveId(id: string) {
|
|
257
|
+
if (id === CONFIG_VIRTUAL_ID) return CONFIG_RESOLVED_ID;
|
|
258
|
+
if (id === COLLECTIONS_VIRTUAL_ID) return COLLECTIONS_RESOLVED_ID;
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Generates virtual module source code for config and collections.
|
|
263
|
+
* @param {string} id - The resolved module ID to load
|
|
264
|
+
* @return {string | undefined} Generated module source code, or undefined if not handled
|
|
265
|
+
*/
|
|
266
|
+
load(id: string) {
|
|
267
|
+
if (id === CONFIG_RESOLVED_ID) {
|
|
268
|
+
return `export default ${JSON.stringify(config)};`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (id !== COLLECTIONS_RESOLVED_ID) return;
|
|
272
|
+
|
|
273
|
+
const collectionsDir = resolve(root, '.astro/collections');
|
|
274
|
+
|
|
275
|
+
// Guard: return empty object if directory doesn't exist
|
|
276
|
+
if (!existsSync(collectionsDir)) {
|
|
277
|
+
logger.warn(
|
|
278
|
+
'`.astro/collections` not found — virtual:nebula/collections will be empty.',
|
|
279
|
+
);
|
|
280
|
+
return 'export default {};';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const files = readdirSync(collectionsDir).filter((f) =>
|
|
284
|
+
f.endsWith('.schema.json'),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const entries = files.map((f) => {
|
|
288
|
+
const name = f.replace('.schema.json', '');
|
|
289
|
+
return ` ${JSON.stringify(name)}: ${JSON.stringify(config.collectionsPath + '/' + f)}`;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return `export default {\n${entries.join(',\n')}\n};`;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { initRouter, nav, adminPath } from './js/state/router.svelte';
|
|
4
|
+
import {
|
|
5
|
+
backend,
|
|
6
|
+
content,
|
|
7
|
+
drafts,
|
|
8
|
+
restoreBackend,
|
|
9
|
+
loadCollection,
|
|
10
|
+
} from './js/state/state.svelte';
|
|
11
|
+
import {
|
|
12
|
+
preloadFile,
|
|
13
|
+
loadFileBody,
|
|
14
|
+
clearEditor,
|
|
15
|
+
editor,
|
|
16
|
+
getEditorFile,
|
|
17
|
+
setDefaultFormat,
|
|
18
|
+
} from './js/editor/editor.svelte';
|
|
19
|
+
import { loadDraftById } from './js/drafts/ops.svelte';
|
|
20
|
+
import {
|
|
21
|
+
fetchSchema,
|
|
22
|
+
schema,
|
|
23
|
+
clearSchema,
|
|
24
|
+
prefetchAllSchemas,
|
|
25
|
+
collectionHasDates,
|
|
26
|
+
getCollectionTitle,
|
|
27
|
+
} from './js/state/schema.svelte';
|
|
28
|
+
import {
|
|
29
|
+
handleDeleteDraft,
|
|
30
|
+
handleFilenameConfirm,
|
|
31
|
+
buildContentItems,
|
|
32
|
+
buildCollectionItems,
|
|
33
|
+
buildActiveFileHref,
|
|
34
|
+
} from './js/handlers/admin';
|
|
35
|
+
import { dialog } from './js/state/dialogs.svelte';
|
|
36
|
+
import { stripExtension } from './js/utils/file-types';
|
|
37
|
+
import { initTheme, theme } from './js/state/theme.svelte';
|
|
38
|
+
import './css/reset.css';
|
|
39
|
+
import './css/icons.css';
|
|
40
|
+
import './css/theme.css';
|
|
41
|
+
import './css/btn.css';
|
|
42
|
+
import './css/field-input.css';
|
|
43
|
+
import './css/dialog.css';
|
|
44
|
+
import './css/a11y.css';
|
|
45
|
+
import BackendPicker from './components/BackendPicker.svelte';
|
|
46
|
+
import AdminSidebar from './components/sidebar/AdminSidebar.svelte';
|
|
47
|
+
import EditorToolbar from './components/editor/EditorToolbar.svelte';
|
|
48
|
+
import EditorPane from './components/editor/EditorPane.svelte';
|
|
49
|
+
import EditorTabs from './components/editor/EditorTabs.svelte';
|
|
50
|
+
import MetadataForm from './components/MetadataForm.svelte';
|
|
51
|
+
import FilenameDialog from './components/dialogs/FilenameDialog.svelte';
|
|
52
|
+
import DeleteDraftDialog from './components/dialogs/DeleteDraftDialog.svelte';
|
|
53
|
+
|
|
54
|
+
// Whether a collection is currently selected (including draft view)
|
|
55
|
+
const hasCollection = $derived(nav.route.view !== 'home');
|
|
56
|
+
|
|
57
|
+
// The active collection name, if any
|
|
58
|
+
const activeCollection = $derived(
|
|
59
|
+
nav.route.view !== 'home' ? nav.route.collection : null,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Whether a file or draft is currently open in the editor
|
|
63
|
+
const fileOpen = $derived(
|
|
64
|
+
nav.route.view === 'file' || nav.route.view === 'draft',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// The active file/draft href for highlighting in the content sidebar
|
|
68
|
+
const activeFileHref = $derived(buildActiveFileHref(nav.route));
|
|
69
|
+
|
|
70
|
+
// Collection names mapped to SidebarItems, using schema title/description when available
|
|
71
|
+
const collectionItems = $derived(buildCollectionItems());
|
|
72
|
+
|
|
73
|
+
// Content items merged with draft data (DRAFT/OUTDATED chips) plus new draft items
|
|
74
|
+
const contentItems = $derived(
|
|
75
|
+
buildContentItems(
|
|
76
|
+
content.list,
|
|
77
|
+
drafts.all,
|
|
78
|
+
drafts.outdated,
|
|
79
|
+
activeCollection,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Whether the active collection has date fields for sort controls
|
|
84
|
+
const contentHasDates = $derived(
|
|
85
|
+
activeCollection ? collectionHasDates(activeCollection) : false,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Existing filenames for uniqueness validation in the filename dialog — includes both live files and drafts with filenames
|
|
89
|
+
const existingFilenames = $derived([
|
|
90
|
+
...content.list.map((item) => item.filename),
|
|
91
|
+
...drafts.all.filter((d) => d.filename).map((d) => d.filename!),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Type identifiers from the schema's files array, used to show the format selector
|
|
95
|
+
const schemaFileTypes = $derived(
|
|
96
|
+
Array.isArray(schema.active?.['files'])
|
|
97
|
+
? (schema.active['files'] as string[])
|
|
98
|
+
: [],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Sync the resolved theme to :root so top-layer elements (dialogs) inherit the tokens
|
|
102
|
+
$effect(() => {
|
|
103
|
+
document.documentElement.dataset.theme = theme.resolved;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Trigger collection loading when route changes to a collection, file, or draft view
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (backend.ready && nav.route.view !== 'home') {
|
|
109
|
+
loadCollection(nav.route.collection);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/*
|
|
114
|
+
* Loads content for the file or draft view. Both branches gate on `backend.ready`
|
|
115
|
+
* so they re-run when the directory handle is restored on page load.
|
|
116
|
+
*/
|
|
117
|
+
$effect(() => {
|
|
118
|
+
if (backend.ready && nav.route.view === 'file' && content.list.length > 0) {
|
|
119
|
+
const item = content.list.find(
|
|
120
|
+
(i) => stripExtension(i.filename) === nav.route.slug,
|
|
121
|
+
);
|
|
122
|
+
if (!item) return;
|
|
123
|
+
|
|
124
|
+
// preloadFile is async — it checks IDB for a draft first
|
|
125
|
+
preloadFile(nav.route.collection, item.filename, item.data).then(() => {
|
|
126
|
+
// If preloadFile loaded a draft (body already present), skip disk read
|
|
127
|
+
const editorFile = getEditorFile();
|
|
128
|
+
if (editorFile?.draftId) return;
|
|
129
|
+
|
|
130
|
+
loadFileBody(nav.route.collection, item.filename);
|
|
131
|
+
});
|
|
132
|
+
} else if (backend.ready && nav.route.view === 'draft') {
|
|
133
|
+
loadDraftById(nav.route.draftId, nav.route.collection);
|
|
134
|
+
} else if (nav.route.view !== 'file' && nav.route.view !== 'draft') {
|
|
135
|
+
clearEditor();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* Set the default format for new drafts once the schema is available.
|
|
141
|
+
* New drafts start with an empty filename, so setDefaultFormat assigns
|
|
142
|
+
* the collection's first file type extension (e.g. '.mdx' for guides).
|
|
143
|
+
*/
|
|
144
|
+
$effect(() => {
|
|
145
|
+
const file = getEditorFile();
|
|
146
|
+
if (file?.isNewDraft && !file.filename && schemaFileTypes.length > 0) {
|
|
147
|
+
setDefaultFormat(schemaFileTypes);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Fetch the JSON Schema when the active collection changes
|
|
152
|
+
$effect(() => {
|
|
153
|
+
if (backend.ready && nav.route.view !== 'home') {
|
|
154
|
+
fetchSchema(nav.route.collection);
|
|
155
|
+
} else {
|
|
156
|
+
clearSchema();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Handles filename dialog confirmation — hides the dialog and triggers publish with the chosen filename.
|
|
162
|
+
* @param {string} filename - The chosen filename
|
|
163
|
+
* @return {Promise<void>}
|
|
164
|
+
*/
|
|
165
|
+
async function onFilenameConfirm(filename: string): Promise<void> {
|
|
166
|
+
dialog.close();
|
|
167
|
+
await handleFilenameConfirm(filename, activeCollection);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Handles delete draft confirmation — hides the dialog and deletes the current draft.
|
|
172
|
+
* @return {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async function onDeleteConfirm(): Promise<void> {
|
|
175
|
+
dialog.close();
|
|
176
|
+
await handleDeleteDraft(activeCollection);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onMount(() => {
|
|
180
|
+
const cleanupTheme = initTheme();
|
|
181
|
+
initRouter();
|
|
182
|
+
restoreBackend();
|
|
183
|
+
prefetchAllSchemas();
|
|
184
|
+
return cleanupTheme;
|
|
185
|
+
});
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<div
|
|
189
|
+
class="admin"
|
|
190
|
+
class:admin--connected={backend.ready}
|
|
191
|
+
class:admin--collection={backend.ready && hasCollection}
|
|
192
|
+
class:admin--file-open={backend.ready && fileOpen}
|
|
193
|
+
>
|
|
194
|
+
{#if !backend.ready}
|
|
195
|
+
<BackendPicker />
|
|
196
|
+
{:else}
|
|
197
|
+
<AdminSidebar
|
|
198
|
+
title="Collections"
|
|
199
|
+
items={collectionItems}
|
|
200
|
+
activeItem={activeCollection ? adminPath(activeCollection) : undefined}
|
|
201
|
+
showFooter={true}
|
|
202
|
+
/>
|
|
203
|
+
{#if hasCollection && activeCollection}
|
|
204
|
+
<AdminSidebar
|
|
205
|
+
title={getCollectionTitle(activeCollection) ??
|
|
206
|
+
activeCollection.charAt(0).toUpperCase() + activeCollection.slice(1)}
|
|
207
|
+
items={contentItems}
|
|
208
|
+
activeItem={activeFileHref}
|
|
209
|
+
storageKey={activeCollection}
|
|
210
|
+
loading={content.loading}
|
|
211
|
+
error={content.error ?? undefined}
|
|
212
|
+
hasDates={contentHasDates}
|
|
213
|
+
collection={activeCollection}
|
|
214
|
+
showAdd={true}
|
|
215
|
+
/>
|
|
216
|
+
{/if}
|
|
217
|
+
{#if fileOpen}
|
|
218
|
+
<div class="editor-area">
|
|
219
|
+
<EditorToolbar />
|
|
220
|
+
<EditorTabs schema={schema.active} />
|
|
221
|
+
<div class="editor-content">
|
|
222
|
+
{#if editor.tab === 'body'}
|
|
223
|
+
<EditorPane />
|
|
224
|
+
{:else if schema.active}
|
|
225
|
+
<MetadataForm
|
|
226
|
+
schema={schema.active}
|
|
227
|
+
tab={editor.tab === 'metadata' ? null : editor.tab}
|
|
228
|
+
/>
|
|
229
|
+
{/if}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
{/if}
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{#if dialog.active === 'filename'}
|
|
237
|
+
{@const file = getEditorFile()}
|
|
238
|
+
<FilenameDialog
|
|
239
|
+
title={typeof file?.formData.title === 'string' ? file.formData.title : ''}
|
|
240
|
+
{existingFilenames}
|
|
241
|
+
onConfirm={onFilenameConfirm}
|
|
242
|
+
onCancel={dialog.close}
|
|
243
|
+
/>
|
|
244
|
+
{/if}
|
|
245
|
+
|
|
246
|
+
{#if dialog.active === 'delete'}
|
|
247
|
+
<DeleteDraftDialog onConfirm={onDeleteConfirm} onCancel={dialog.close} />
|
|
248
|
+
{/if}
|
|
249
|
+
|
|
250
|
+
<style>
|
|
251
|
+
.admin {
|
|
252
|
+
/* Lock to viewport height so the page never scrolls — all scrolling happens inside editor-content or sidebars */
|
|
253
|
+
height: 100dvh;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.admin--connected {
|
|
257
|
+
display: grid;
|
|
258
|
+
grid-template-columns: 15rem 1fr;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.admin--collection {
|
|
262
|
+
grid-template-columns: 15rem 15rem 1fr;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.admin--file-open {
|
|
266
|
+
grid-template-columns: 15rem 15rem 1fr;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.editor-area {
|
|
270
|
+
display: grid;
|
|
271
|
+
/* Toolbar + tabs above, scrollable content below */
|
|
272
|
+
grid-template-rows: auto auto 1fr;
|
|
273
|
+
overflow: hidden;
|
|
274
|
+
border-left: 1px solid var(--cms-border);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/* Scrollable content area; min-height: 0 allows the 1fr grid row to shrink */
|
|
278
|
+
.editor-content {
|
|
279
|
+
overflow-y: auto;
|
|
280
|
+
overflow-x: hidden;
|
|
281
|
+
min-height: 0;
|
|
282
|
+
}
|
|
283
|
+
</style>
|