@nuasite/cms 0.42.1 → 0.43.0-beta.2

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.
@@ -1,103 +1,12 @@
1
1
  import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { getProjectRoot } from '../config'
4
- import type { CreatePageRequest, DeletePageRequest, DuplicatePageRequest, LayoutInfo, PageOperationResponse } from '../types'
5
- import { escapeHtml, isNodeError, resolveAndValidatePath, slugify } from '../utils'
2
+ import { resolveAndValidatePath, slugify } from '../utils'
6
3
 
7
4
  const PAGE_EXTENSIONS = ['.astro', '.md', '.mdx']
8
5
 
9
- export async function handleCreatePage(request: CreatePageRequest): Promise<PageOperationResponse> {
10
- const { title, slug } = request
11
- const normalizedSlug = slugify(slug || title)
12
-
13
- if (!normalizedSlug) {
14
- return { success: false, error: 'Could not generate a valid slug from the provided title/slug' }
15
- }
16
-
17
- const filePath = `src/pages/${normalizedSlug}.astro`
18
- const fullPath = resolveAndValidatePath(filePath)
19
-
20
- const layoutImport = await resolveLayoutImport(request.layoutPath)
21
- const content = generatePageContent(title, layoutImport)
22
-
23
- try {
24
- await fs.mkdir(path.dirname(fullPath), { recursive: true })
25
- // 'wx' flag atomically fails if file exists — no pre-check needed
26
- await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' })
27
-
28
- const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
29
- return { success: true, filePath, slug: normalizedSlug, url }
30
- } catch (error) {
31
- if (isNodeError(error, 'EEXIST')) {
32
- return { success: false, error: `Page already exists: ${filePath}` }
33
- }
34
- return { success: false, error: errorMessage(error) }
35
- }
36
- }
37
-
38
- export async function handleDuplicatePage(request: DuplicatePageRequest): Promise<PageOperationResponse> {
39
- const { sourcePagePath, slug, title } = request
40
- const normalizedSlug = slugify(slug)
41
-
42
- if (!normalizedSlug) {
43
- return { success: false, error: 'Could not generate a valid slug' }
44
- }
45
-
46
- const sourceFile = await findPageFile(sourcePagePath)
47
- if (!sourceFile) {
48
- return { success: false, error: `Source page not found: ${sourcePagePath}` }
49
- }
50
-
51
- let content: string
52
- try {
53
- content = await fs.readFile(resolveAndValidatePath(sourceFile), 'utf-8')
54
- } catch {
55
- return { success: false, error: `Could not read source file: ${sourceFile}` }
56
- }
57
-
58
- if (title) {
59
- content = replacePageTitle(content, title)
60
- }
61
-
62
- const newFilePath = `src/pages/${normalizedSlug}.astro`
63
- const newFullPath = resolveAndValidatePath(newFilePath)
64
-
65
- try {
66
- await fs.mkdir(path.dirname(newFullPath), { recursive: true })
67
- await fs.writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' })
68
-
69
- const url = normalizedSlug === 'index' ? '/' : `/${normalizedSlug}`
70
- return { success: true, filePath: newFilePath, slug: normalizedSlug, url }
71
- } catch (error) {
72
- if (isNodeError(error, 'EEXIST')) {
73
- return { success: false, error: `Page already exists: ${newFilePath}` }
74
- }
75
- return { success: false, error: errorMessage(error) }
76
- }
77
- }
78
-
79
- export async function handleDeletePage(request: DeletePageRequest): Promise<PageOperationResponse> {
80
- const { pagePath } = request
81
-
82
- const pageFile = await findPageFile(pagePath)
83
- if (!pageFile) {
84
- return { success: false, error: `Page not found: ${pagePath}` }
85
- }
86
-
87
- try {
88
- // No pre-check — just unlink and handle ENOENT
89
- await fs.unlink(resolveAndValidatePath(pageFile))
90
- return { success: true, filePath: pageFile, url: pagePath }
91
- } catch (error) {
92
- if (isNodeError(error, 'ENOENT')) {
93
- return { success: false, error: `File not found: ${pageFile}` }
94
- }
95
- return { success: false, error: errorMessage(error) }
96
- }
97
- }
98
-
99
6
  /**
100
- * Reuses findPageFile to check whether a slug is already taken.
7
+ * Check whether a page slug is already taken. Page create/duplicate/delete and
8
+ * layout listing now live in `@nuasite/cms-core`; this slug check stays here
9
+ * because it is not part of the cms-core structural interface.
101
10
  */
102
11
  export async function handleCheckSlugExists(slug: string): Promise<{ exists: boolean; filePath?: string }> {
103
12
  const normalizedSlug = slugify(slug)
@@ -107,35 +16,8 @@ export async function handleCheckSlugExists(slug: string): Promise<{ exists: boo
107
16
  return found ? { exists: true, filePath: found } : { exists: false }
108
17
  }
109
18
 
110
- export async function handleGetLayouts(): Promise<LayoutInfo[]> {
111
- const layoutsDir = path.join(getProjectRoot(), 'src', 'layouts')
112
-
113
- let entries
114
- try {
115
- entries = await fs.readdir(layoutsDir, { withFileTypes: true })
116
- } catch {
117
- return []
118
- }
119
-
120
- const layouts: LayoutInfo[] = []
121
- for (const entry of entries) {
122
- if (entry.isFile() && entry.name.endsWith('.astro')) {
123
- layouts.push({
124
- name: path.basename(entry.name, '.astro'),
125
- path: `src/layouts/${entry.name}`,
126
- })
127
- }
128
- }
129
-
130
- return layouts.sort((a, b) => a.name.localeCompare(b.name))
131
- }
132
-
133
19
  // --- Internal helpers ---
134
20
 
135
- function errorMessage(error: unknown): string {
136
- return error instanceof Error ? error.message : String(error)
137
- }
138
-
139
21
  async function fileExists(fullPath: string): Promise<boolean> {
140
22
  try {
141
23
  await fs.access(fullPath)
@@ -160,70 +42,3 @@ async function findPageFile(pagePath: string): Promise<string | null> {
160
42
 
161
43
  return null
162
44
  }
163
-
164
- async function resolveLayoutImport(layoutPath?: string): Promise<{ importPath: string; componentName: string } | null> {
165
- if (layoutPath) {
166
- const name = path.basename(layoutPath, '.astro')
167
- const importPath = `../${layoutPath.replace(/^src\//, '')}`
168
- return { importPath, componentName: pascalCase(name) }
169
- }
170
-
171
- const layouts = await handleGetLayouts()
172
- if (layouts.length === 0) return null
173
-
174
- const layout = layouts[0]!
175
- const importPath = `../${layout.path.replace(/^src\//, '')}`
176
- return { importPath, componentName: pascalCase(layout.name) }
177
- }
178
-
179
- function pascalCase(name: string): string {
180
- return name.replace(/(^|[-_])(\w)/g, (_, _sep, char) => char.toUpperCase())
181
- }
182
-
183
- function generatePageContent(
184
- title: string,
185
- layoutImport: { importPath: string; componentName: string } | null,
186
- ): string {
187
- const escapedTitle = title.replace(/'/g, "\\'").replace(/`/g, '\\`')
188
- const htmlTitle = escapeHtml(title)
189
-
190
- if (layoutImport) {
191
- const { importPath, componentName } = layoutImport
192
- return `---
193
- import ${componentName} from '${importPath}'
194
- ---
195
-
196
- <${componentName} title="${escapedTitle}" description="">
197
- \t<main>
198
- \t\t<h1>${htmlTitle}</h1>
199
- \t</main>
200
- </${componentName}>
201
- `
202
- }
203
-
204
- return `---
205
-
206
- ---
207
-
208
- <html lang="en">
209
- \t<head>
210
- \t\t<meta charset="utf-8" />
211
- \t\t<meta name="viewport" content="width=device-width" />
212
- \t\t<title>${escapedTitle}</title>
213
- \t</head>
214
- \t<body>
215
- \t\t<main>
216
- \t\t\t<h1>${htmlTitle}</h1>
217
- \t\t</main>
218
- \t</body>
219
- </html>
220
- `
221
- }
222
-
223
- function replacePageTitle(content: string, newTitle: string): string {
224
- let result = content
225
- result = result.replace(/(title\s*=\s*")([^"]*)(")/, `$1${newTitle}$3`)
226
- result = result.replace(/(<title>)([^<]*)(<\/title>)/, `$1${newTitle}$3`)
227
- result = result.replace(/(<h1[^>]*>)([^<]*)(<\/h1>)/, `$1${escapeHtml(newTitle)}$3`)
228
- return result
229
- }
package/src/index.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  import type { AstroIntegration } from 'astro'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { existsSync } from 'node:fs'
3
3
  import fs from 'node:fs/promises'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
 
7
+ import { createLocalStorageAdapter } from '@nuasite/cms-core'
7
8
  import { processBuildOutput } from './build-processor'
8
9
  import { scanCollections } from './collection-scanner'
9
10
  import { ComponentRegistry } from './component-registry'
10
11
  import { resetProjectRoot } from './config'
11
12
  import { createDevMiddleware } from './dev-middleware'
12
13
  import { getErrorCollector, resetErrorCollector } from './error-collector'
14
+ import { ADMIN_ROUTE, createLocalAdminMiddleware } from './local-admin'
13
15
  import { ManifestWriter } from './manifest-writer'
14
- import { createLocalStorageAdapter } from './media/local'
15
16
  import type { MediaStorageAdapter } from './media/types'
17
+ import { type CmsMode, resolveCmsMode } from './mode'
16
18
  import { rehypeCmsMarker } from './rehype-cms-marker'
17
19
  import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
18
20
  import { createPublicStaticFileChecker } from './utils'
@@ -20,8 +22,18 @@ import { createVitePlugin } from './vite-plugin'
20
22
 
21
23
  export interface NuaCmsOptions extends CmsMarkerOptions {
22
24
  /**
23
- * URL to the CMS editor script.
24
- * If not set, the built-in editor bundle is served from the dev server.
25
+ * URL to the CMS editor (inline visual-editing widget) script.
26
+ *
27
+ * Resolution order in dev:
28
+ * 1. this `src`, if set;
29
+ * 2. the `NUA_CMS_EDITOR_SRC` environment variable (lets the host runtime point
30
+ * dev-preview at a specific CDN/local editor without a config change);
31
+ * 3. the public CDN editor (`DEFAULT_CDN_EDITOR_SRC`) when a pre-built editor
32
+ * bundle ships with the package (the npm-installed case — e.g. a generated
33
+ * site's dev-preview), so the inline editor updates via a CDN push,
34
+ * independent of the site's `@nuasite/cms` version (matching hosting);
35
+ * 4. the bundled editor served from the dev server, used only in the monorepo
36
+ * (no pre-built bundle) so editor source changes hot-reload.
25
37
  */
26
38
  src?: string
27
39
  /**
@@ -44,6 +56,22 @@ export interface NuaCmsOptions extends CmsMarkerOptions {
44
56
  */
45
57
  siteTheme?: 'auto' | 'light' | 'dark'
46
58
  }
59
+ /**
60
+ * Run mode (cms-headless F7). Controls whether the plugin serves a local
61
+ * full-page collections admin:
62
+ *
63
+ * - `local` (default for `pletivo dev` on a developer machine): lazily spawn an
64
+ * in-process cms-sidecar over the project's `node:fs` and serve the
65
+ * `@nuasite/collections-admin` SPA at `/_nua/admin`. The inline widget runs
66
+ * in-page as usual.
67
+ * - `hosted` (inside the agent sandbox): a no-op for spawning/serving — the
68
+ * sidecar is a managed sandbox service and the admin is the webmaster tab.
69
+ * The plugin stays marker + CDN-inject only.
70
+ *
71
+ * Auto-detected from the environment when omitted (a sandbox env signal ⇒
72
+ * `hosted`; otherwise `local`). An explicit value always wins.
73
+ */
74
+ mode?: CmsMode
47
75
  /**
48
76
  * Proxy /_nua/cms requests to this target URL during dev.
49
77
  * Example: 'http://localhost:8787'
@@ -88,6 +116,20 @@ const DEFAULT_MAX_UPLOAD_SIZE = 10 * 1024 * 1024
88
116
 
89
117
  const VIRTUAL_CMS_PATH = '/@nuasite/cms-editor.js'
90
118
 
119
+ /**
120
+ * Virtual module id of the local `/_nua/admin` SPA entry (cms-headless F7). The
121
+ * HTML shell loads it as `<script type="module">`; the Vite plugin resolves it to
122
+ * `src/admin/entry.tsx` so the dev server transforms it (TSX → JS, real React).
123
+ */
124
+ const VIRTUAL_ADMIN_ENTRY = '/@nuasite/cms-admin-entry.js'
125
+
126
+ /**
127
+ * Public CDN editor script. Same URL hosting injects (see webmaster
128
+ * `packages/worker-hosting` `editorSrc`), so dev-preview and hosting load the
129
+ * identical inline editor and a CDN push updates both without touching the site.
130
+ */
131
+ const DEFAULT_CDN_EDITOR_SRC = 'https://cdn.nuasite.com/script/latest/cms-editor.js'
132
+
91
133
  export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
92
134
  const {
93
135
  // CMS editor options
@@ -111,6 +153,13 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
111
153
  maxUploadSize = DEFAULT_MAX_UPLOAD_SIZE,
112
154
  } = options
113
155
 
156
+ // Run mode (cms-headless F7): `local` serves a full-page collections admin at
157
+ // /_nua/admin over an in-process sidecar; `hosted` (inside the agent sandbox)
158
+ // is a no-op for that — the managed sandbox sidecar + the webmaster tab own it.
159
+ // An explicit `options.mode` wins; otherwise auto-detect from the environment.
160
+ const mode = resolveCmsMode(options.mode)
161
+ const serveLocalAdmin = mode === 'local'
162
+
114
163
  // When no proxy, enable local CMS API with default media adapter
115
164
  const enableCmsApi = !proxy
116
165
  const mediaAdapter = media ?? (enableCmsApi ? createLocalStorageAdapter() : undefined)
@@ -218,15 +267,41 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
218
267
  }
219
268
 
220
269
  const vitePlugins: any[] = [...(createVitePlugin(pluginContext) as any)]
221
- const cmsDir = !src ? dirname(fileURLToPath(import.meta.url)) : undefined
270
+ const cmsDir = dirname(fileURLToPath(import.meta.url))
271
+
272
+ // Local-mode (cms-headless F7): resolve the /_nua/admin SPA entry virtual
273
+ // module to src/admin/entry.tsx so Vite transforms it (TSX → JS, real
274
+ // React) and HMR works. The HTML shell loads this id as a module script.
275
+ if (serveLocalAdmin) {
276
+ vitePlugins.push({
277
+ name: 'nuasite-cms-admin-entry',
278
+ resolveId(id: string) {
279
+ if (id === VIRTUAL_ADMIN_ENTRY) {
280
+ return join(cmsDir, 'admin/entry.tsx')
281
+ }
282
+ },
283
+ })
284
+ }
222
285
 
223
286
  // Detect pre-built editor bundle (present when installed from npm)
224
- const editorBundlePath = cmsDir ? join(cmsDir, '../dist/editor.js') : undefined
225
- const hasPrebuiltBundle = editorBundlePath ? existsSync(editorBundlePath) : false
287
+ const editorBundlePath = join(cmsDir, '../dist/editor.js')
288
+ const hasPrebuiltBundle = existsSync(editorBundlePath)
289
+
290
+ // Resolve which editor script dev-preview injects:
291
+ // 1. explicit `src`; 2. `NUA_CMS_EDITOR_SRC` env; 3. the public CDN editor
292
+ // when shipping a pre-built bundle (npm-installed site) — so dev-preview
293
+ // loads the same CDN editor as hosting and a CDN push updates it without a
294
+ // site rebuild; 4. the bundled source (monorepo only) for editor HMR.
295
+ const envSrc = process.env.NUA_CMS_EDITOR_SRC
296
+ const resolvedEditorSrc = src ?? envSrc ?? (hasPrebuiltBundle ? DEFAULT_CDN_EDITOR_SRC : VIRTUAL_CMS_PATH)
297
+
298
+ // Local virtual-module serving is only needed when injecting the bundled
299
+ // editor (the monorepo source path) — not for an external/CDN URL.
300
+ const servesBundledEditor = resolvedEditorSrc === VIRTUAL_CMS_PATH
226
301
 
227
302
  // --- CMS Editor setup (dev only) ---
228
303
  if (command === 'dev') {
229
- const editorSrc = src ?? VIRTUAL_CMS_PATH
304
+ const editorSrc = resolvedEditorSrc
230
305
 
231
306
  const configScript = `window.NuaCmsConfig = ${JSON.stringify(resolvedCmsConfig)};`
232
307
 
@@ -248,52 +323,32 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
248
323
  `,
249
324
  )
250
325
 
251
- if (!src) {
252
- if (hasPrebuiltBundle) {
253
- // Pre-built bundle exists (npm install case):
254
- // Serve it via a virtual module — no JSX pragma, Tailwind, or aliases needed.
255
- // Read on every load() so rebuilds during dev pick up without restarting
256
- // the host (Astro, pletivo, etc).
257
- vitePlugins.push({
258
- name: 'nuasite-cms-editor',
259
- resolveId(id: string) {
260
- if (id === VIRTUAL_CMS_PATH) {
261
- return VIRTUAL_CMS_PATH
262
- }
263
- },
264
- load(id: string) {
265
- if (id === VIRTUAL_CMS_PATH) {
266
- return readFileSync(editorBundlePath!, 'utf-8')
267
- }
268
- },
269
- })
270
- } else {
271
- // No pre-built bundle (monorepo dev case):
272
- // Serve source files directly — Vite transforms TSX, resolves imports, HMR works.
273
- vitePlugins.push({
274
- name: 'nuasite-cms-editor',
275
- resolveId(id: string) {
276
- if (id === VIRTUAL_CMS_PATH) {
277
- return join(cmsDir!, 'editor/index.tsx')
278
- }
279
- },
280
- })
281
-
282
- // Prepend @jsxImportSource pragma for editor .tsx files
283
- // so Vite's esbuild uses Preact's h function
284
- vitePlugins.push({
285
- name: 'nuasite-cms-preact-jsx',
286
- transform(code: string, id: string) {
287
- if (id.includes('/src/editor/') && id.endsWith('.tsx') && !code.includes('@jsxImportSource')) {
288
- return `/** @jsxImportSource preact */\n${code}`
289
- }
290
- },
291
- })
292
-
293
- // Add Tailwind CSS Vite plugin for editor styles
294
- const tailwindcss = (await import('@tailwindcss/vite')).default
295
- vitePlugins.push(tailwindcss())
296
- }
326
+ if (servesBundledEditor) {
327
+ // Monorepo dev (no pre-built bundle): serve the editor source files
328
+ // directly so Vite transforms TSX, resolves imports, and HMR works.
329
+ vitePlugins.push({
330
+ name: 'nuasite-cms-editor',
331
+ resolveId(id: string) {
332
+ if (id === VIRTUAL_CMS_PATH) {
333
+ return join(cmsDir, 'editor/index.tsx')
334
+ }
335
+ },
336
+ })
337
+
338
+ // Prepend @jsxImportSource pragma for editor .tsx files
339
+ // so Vite's esbuild uses Preact's h function
340
+ vitePlugins.push({
341
+ name: 'nuasite-cms-preact-jsx',
342
+ transform(code: string, id: string) {
343
+ if (id.includes('/src/editor/') && id.endsWith('.tsx') && !code.includes('@jsxImportSource')) {
344
+ return `/** @jsxImportSource preact */\n${code}`
345
+ }
346
+ },
347
+ })
348
+
349
+ // Add Tailwind CSS Vite plugin for editor styles
350
+ const tailwindcss = (await import('@tailwindcss/vite')).default
351
+ vitePlugins.push(tailwindcss())
297
352
  }
298
353
  }
299
354
 
@@ -306,8 +361,9 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
306
361
  }
307
362
  }
308
363
 
309
- // Only add react->preact aliases when serving source files (not pre-built bundle)
310
- const needsAliases = !src && !hasPrebuiltBundle
364
+ // Only add react->preact aliases when serving the editor source files
365
+ // (monorepo dev) an external/CDN editor URL needs no module rewriting.
366
+ const needsAliases = servesBundledEditor
311
367
 
312
368
  updateConfig({
313
369
  markdown: {
@@ -349,6 +405,22 @@ export default function nuaCms(options: NuaCmsOptions = {}): AstroIntegration {
349
405
  if (enableCmsApi) {
350
406
  logger.info('CMS API enabled at /_nua/cms/')
351
407
  }
408
+
409
+ // Local-mode full-page collections admin (cms-headless F7). Registered
410
+ // only in `local` mode — in `hosted` (sandbox) this is a strict no-op:
411
+ // the managed sandbox sidecar (F2) + the webmaster tab (F3) own it.
412
+ // The in-process sidecar + SPA are built lazily on the first /_nua/admin
413
+ // hit, so dev startup stays fast. Media defaults to local public/uploads.
414
+ if (serveLocalAdmin) {
415
+ createLocalAdminMiddleware(server, {
416
+ contentDir,
417
+ componentDirs,
418
+ mediaAdapter: media ?? createLocalStorageAdapter(),
419
+ maxUploadSize,
420
+ entryModuleId: VIRTUAL_ADMIN_ENTRY,
421
+ })
422
+ logger.info(`CMS collections admin available at ${ADMIN_ROUTE}`)
423
+ }
352
424
  },
353
425
 
354
426
  'astro:build:done': async ({ dir, logger }) => {
@@ -391,12 +463,18 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
391
463
  logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
392
464
  }
393
465
 
466
+ // Shared structural contract from @nuasite/cms-types — surfaced through the cms public API
467
+ // so consumers of @nuasite/cms get the field-type list + guard from one place.
468
+ export {
469
+ createContemberStorageAdapter as contemberMedia,
470
+ createLocalStorageAdapter as localMedia,
471
+ createS3StorageAdapter as s3Media,
472
+ } from '@nuasite/cms-core'
473
+ export { FIELD_TYPES, isFieldType } from '@nuasite/cms-types'
394
474
  export { n } from './field-types'
395
475
  export type { DateHints, ImageHints, NumberHints, TextareaHints, TextHints } from './field-types'
396
- export { createContemberStorageAdapter as contemberMedia } from './media/contember'
397
- export { createLocalStorageAdapter as localMedia } from './media/local'
398
- export { createS3StorageAdapter as s3Media } from './media/s3'
399
476
  export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
477
+ export { type CmsMode, detectHostedFromEnv, resolveCmsMode } from './mode'
400
478
  export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Url } from './prop-types'
401
479
 
402
480
  export { scanCollections } from './collection-scanner'