@nuasite/cms 0.43.0-beta.1 → 0.43.0-beta.3

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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Local-mode admin server (cms-headless F7).
3
+ *
4
+ * In `local` mode (`pletivo dev` on a developer machine), `@nuasite/cms` gives the
5
+ * same full-page collections admin as the webmaster tab — reusing the exact
6
+ * `@nuasite/collections-admin` SPA — instead of the cramped in-iframe collection
7
+ * form. This module wires two things into Astro's Vite dev server:
8
+ *
9
+ * 1. an **in-process** cms-sidecar (`@nuasite/cms-sidecar` `createServer` over
10
+ * `createCmsCore(createNodeFs(root))`) mounted at `/_nua/cms-admin-api/*`,
11
+ * forwarding to the sidecar's `/cms/v1` contract;
12
+ * 2. the collections-admin SPA served at `/_nua/admin`, with
13
+ * `apiBase = /_nua/cms-admin-api/cms/v1`.
14
+ *
15
+ * Both are **lazy**: nothing is built on `pletivo dev` startup. The sidecar core
16
+ * is created on the first `/_nua/admin` (or API) hit and reused thereafter, so
17
+ * dev startup stays fast.
18
+ *
19
+ * The sidecar runs in-process (not a child `bunx` process) on purpose: the dev
20
+ * server already runs under Bun/Node, the sidecar's `createServer` is a pure
21
+ * Web-standard `fetch` handler, and `createCmsCore(createNodeFs(root))` is the
22
+ * very same brain the legacy `/_nua/cms` dev API already builds here. In-process
23
+ * avoids a port allocation, a `bunx` cold-start, and a network hop on every
24
+ * request — and keeps `pletivo dev` a single self-contained process.
25
+ */
26
+
27
+ import { createCmsCore, createNodeFs } from '@nuasite/cms-core'
28
+ // `@nuasite/cms-sidecar` is an OPTIONAL peer (cms-headless F6 slim): a generated
29
+ // site that depends on `@nuasite/cms` must not pull the sidecar (+ collections-admin
30
+ // + React) into its resolved tree. The TYPES are imported `import type` (erased at
31
+ // build, so no runtime dependency edge), and the VALUES are loaded lazily via a
32
+ // dynamic `import()` only when local mode actually serves `/_nua/admin`. Hosted
33
+ // (sandbox) mode never registers this middleware, so it never imports the peer.
34
+ import type { CmsSidecarServer, createServer as CreateSidecarServer } from '@nuasite/cms-sidecar'
35
+ import type { IncomingMessage, ServerResponse } from 'node:http'
36
+ import { getProjectRoot } from './config'
37
+ import { readBody } from './handlers/request-utils'
38
+ import type { MediaStorageAdapter } from './media/types'
39
+
40
+ /** Minimal Vite dev-server surface this module needs (kept loose to dodge Vite version skew). */
41
+ export interface AdminViteServerLike {
42
+ middlewares: {
43
+ use: (middleware: (req: IncomingMessage, res: ServerResponse, next: () => void) => void) => void
44
+ }
45
+ transformIndexHtml: (url: string, html: string) => Promise<string>
46
+ }
47
+
48
+ export interface LocalAdminOptions {
49
+ /** Content collections directory, relative to the project root. */
50
+ contentDir: string
51
+ /** Component directories, used by the core for MDX import resolution. */
52
+ componentDirs: string[]
53
+ /** Media adapter for the sidecar's `/media` routes (defaults to `local` in this mode). */
54
+ mediaAdapter?: MediaStorageAdapter
55
+ /** Max upload size in bytes for sidecar media uploads. */
56
+ maxUploadSize: number
57
+ /** Virtual-module id of the SPA entry the HTML shell loads (transformed by Vite). */
58
+ entryModuleId: string
59
+ /**
60
+ * Loader for the optional peers (cms-headless F6). Defaults to the real
61
+ * dynamic-`import()` loader; injectable so a test can simulate the peers being
62
+ * absent. The peers are loaded lazily on first `/_nua/admin` hit, never on
63
+ * `pletivo dev` startup.
64
+ */
65
+ peerLoader?: AdminPeerLoader
66
+ }
67
+
68
+ /** URL the collections-admin SPA is served at. */
69
+ export const ADMIN_ROUTE = '/_nua/admin'
70
+
71
+ /** Local mount of the in-process sidecar. The SPA's `apiBase` targets `${API_PREFIX}/cms/v1`. */
72
+ export const ADMIN_API_PREFIX = '/_nua/cms-admin-api'
73
+
74
+ /** `apiBase` passed to the SPA — the sidecar serves its routes under `/cms/v1`. */
75
+ export const ADMIN_API_BASE = `${ADMIN_API_PREFIX}/cms/v1`
76
+
77
+ /**
78
+ * Message shown when local mode tries to open `/_nua/admin` but the optional
79
+ * peers (`@nuasite/cms-sidecar` + `@nuasite/collections-admin`) are not installed.
80
+ * The marker pipeline + inline editing keep working — only the full-page admin is
81
+ * unavailable until the peers are added (or the site is run via pletivo).
82
+ */
83
+ export const ADMIN_PEERS_MISSING_MESSAGE =
84
+ 'Local CMS admin requires @nuasite/cms-sidecar and @nuasite/collections-admin — install them (or run via pletivo) to enable /_nua/admin.'
85
+
86
+ /**
87
+ * Loader seam for the two optional peers (cms-headless F6). The default
88
+ * implementation loads them lazily via dynamic `import()` — the only dynamic
89
+ * imports in this package (user-approved) — keeping them out of a slim generated
90
+ * site's resolved tree. Injectable so a test can simulate the peers being absent
91
+ * without uninstalling them from the workspace.
92
+ */
93
+ export interface AdminPeerLoader {
94
+ /** Resolve `@nuasite/cms-sidecar`'s `createServer`, or `null` if the peer is absent. */
95
+ loadCreateSidecar(): Promise<typeof CreateSidecarServer | null>
96
+ /** Whether `@nuasite/collections-admin` (the SPA the shell mounts) is installed. */
97
+ isCollectionsAdminInstalled(): Promise<boolean>
98
+ }
99
+
100
+ /**
101
+ * Default peer loader: lazily `import()` the optional peers on first use. The
102
+ * module ids are bound to `const`s so they are NOT static-import string literals —
103
+ * a slim site that doesn't install the peers never resolves them, and the import
104
+ * only runs in local mode on the first `/_nua/admin` (or admin-api) hit. Both
105
+ * rejections (peer absent) degrade to `null` / `false` rather than throwing.
106
+ */
107
+ export const defaultAdminPeerLoader: AdminPeerLoader = {
108
+ async loadCreateSidecar() {
109
+ const moduleId = '@nuasite/cms-sidecar'
110
+ const loaded = await import(moduleId).then(
111
+ (mod: { createServer: typeof CreateSidecarServer }) => mod,
112
+ () => null,
113
+ )
114
+ return loaded === null ? null : loaded.createServer
115
+ },
116
+ async isCollectionsAdminInstalled() {
117
+ const moduleId = '@nuasite/collections-admin'
118
+ return import(moduleId).then(
119
+ () => true,
120
+ () => false,
121
+ )
122
+ },
123
+ }
124
+
125
+ /**
126
+ * Build the HTML shell for the admin SPA. It loads the virtual entry module
127
+ * (which imports the lib's stylesheet and mounts `<CollectionsAdminApp apiBase={…} />`),
128
+ * and injects the resolved `apiBase` as a window global so the entry needs no
129
+ * build config. The host-agnostic SPA is reused verbatim — only `apiBase` differs
130
+ * from the webmaster tab.
131
+ */
132
+ function adminShellHtml(entryModuleId: string, apiBase: string): string {
133
+ return `<!doctype html>
134
+ <html lang="en">
135
+ <head>
136
+ <meta charset="utf-8" />
137
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
138
+ <title>Collections — Nua CMS</title>
139
+ <style>
140
+ html, body, #nua-admin-root { height: 100%; margin: 0; }
141
+ body { background: #f8fafc; }
142
+ </style>
143
+ <script>window.__NUA_ADMIN_API_BASE__ = ${JSON.stringify(apiBase)};</script>
144
+ </head>
145
+ <body>
146
+ <div id="nua-admin-root"></div>
147
+ <script type="module" src=${JSON.stringify(entryModuleId)}></script>
148
+ </body>
149
+ </html>
150
+ `
151
+ }
152
+
153
+ /**
154
+ * Lazily create the in-process cms-sidecar over the project's `node:fs`. The core
155
+ * is built once on first use and reused; the media adapter mirrors the dev
156
+ * server's selection (local `public/uploads` by default in this mode).
157
+ *
158
+ * Returns `null` when the optional `@nuasite/cms-sidecar` peer is not installed —
159
+ * callers serve the {@link ADMIN_PEERS_MISSING_MESSAGE} placeholder rather than
160
+ * crashing. The dynamic `import()` of the peer happens here, on first use only.
161
+ */
162
+ function makeLazySidecar(options: LocalAdminOptions, peerLoader: AdminPeerLoader): () => Promise<CmsSidecarServer | null> {
163
+ let server: CmsSidecarServer | null = null
164
+ return async () => {
165
+ if (server) return server
166
+ const createServer = await peerLoader.loadCreateSidecar()
167
+ if (createServer === null) return null
168
+ const root = getProjectRoot()
169
+ const fs = createNodeFs(root)
170
+ const core = createCmsCore(fs, {
171
+ contentDir: options.contentDir,
172
+ media: options.mediaAdapter,
173
+ componentDirs: options.componentDirs,
174
+ })
175
+ server = createServer({
176
+ core,
177
+ fs,
178
+ root,
179
+ // In-process: the core ships with the site's @nuasite/cms version, so the
180
+ // package version is not separately meaningful — report it as local.
181
+ coreVersion: 'local',
182
+ contentDir: options.contentDir,
183
+ maxUploadSize: options.maxUploadSize,
184
+ })
185
+ return server
186
+ }
187
+ }
188
+
189
+ /** Build a Web `Request` from a Node `IncomingMessage` (+ already-buffered body). */
190
+ function toWebRequest(req: IncomingMessage, path: string, body: Buffer | undefined): Request {
191
+ const host = req.headers.host ?? 'localhost'
192
+ const url = `http://${host}${path}`
193
+ const headers = new Headers()
194
+ for (const [key, value] of Object.entries(req.headers)) {
195
+ if (value === undefined) continue
196
+ if (Array.isArray(value)) {
197
+ for (const v of value) headers.append(key, v)
198
+ } else {
199
+ headers.set(key, value)
200
+ }
201
+ }
202
+ const method = req.method ?? 'GET'
203
+ const init: RequestInit = { method, headers }
204
+ if (method !== 'GET' && method !== 'HEAD' && body !== undefined && body.length > 0) {
205
+ // `Buffer` is not directly a DOM `BodyInit`; copy into a standalone
206
+ // `Uint8Array` (a valid `ArrayBufferView` body) over its own `ArrayBuffer`.
207
+ const bytes = new Uint8Array(body.byteLength)
208
+ bytes.set(body)
209
+ init.body = bytes
210
+ }
211
+ return new Request(url, init)
212
+ }
213
+
214
+ /** Write a Web `Response` back to a Node `ServerResponse`. */
215
+ async function writeWebResponse(res: ServerResponse, response: Response): Promise<void> {
216
+ res.statusCode = response.status
217
+ response.headers.forEach((value, key) => {
218
+ res.setHeader(key, value)
219
+ })
220
+ const buffer = Buffer.from(await response.arrayBuffer())
221
+ res.end(buffer)
222
+ }
223
+
224
+ /**
225
+ * Register the local-mode admin middleware on the Vite dev server. The caller
226
+ * must only invoke this in `local` mode — in `hosted` mode the managed sandbox
227
+ * sidecar + the webmaster tab own these responsibilities, so the plugin never
228
+ * registers this middleware (verified by the no-op test).
229
+ */
230
+ export function createLocalAdminMiddleware(server: AdminViteServerLike, options: LocalAdminOptions): void {
231
+ const peerLoader = options.peerLoader ?? defaultAdminPeerLoader
232
+ const getSidecar = makeLazySidecar(options, peerLoader)
233
+ const shell = adminShellHtml(options.entryModuleId, ADMIN_API_BASE)
234
+
235
+ // 1. In-process sidecar API. Strip the local mount prefix and forward the rest
236
+ // (which begins with `/cms/v1/...`) to the sidecar's Web `fetch` handler.
237
+ server.middlewares.use((req, res, next) => {
238
+ const rawUrl = req.url ?? ''
239
+ if (!rawUrl.startsWith(`${ADMIN_API_PREFIX}/`) && rawUrl !== ADMIN_API_PREFIX) {
240
+ next()
241
+ return
242
+ }
243
+ const forwardedPath = rawUrl.slice(ADMIN_API_PREFIX.length) || '/'
244
+
245
+ readBody(req, options.maxUploadSize)
246
+ .then(async (body) => {
247
+ const sidecar = await getSidecar()
248
+ if (sidecar === null) {
249
+ // Optional peer not installed — degrade gracefully (cms-headless F6).
250
+ res.statusCode = 501
251
+ res.setHeader('content-type', 'application/json; charset=utf-8')
252
+ res.end(JSON.stringify({ error: ADMIN_PEERS_MISSING_MESSAGE, code: 'unsupported' }))
253
+ return
254
+ }
255
+ const request = toWebRequest(req, forwardedPath, body)
256
+ const response = await sidecar.fetch(request)
257
+ await writeWebResponse(res, response)
258
+ })
259
+ .catch((error) => {
260
+ console.error('[nua-cms] /_nua/admin API error:', error)
261
+ if (!res.headersSent) {
262
+ res.statusCode = 500
263
+ res.setHeader('content-type', 'application/json; charset=utf-8')
264
+ }
265
+ res.end(JSON.stringify({ error: 'Internal server error', code: 'io_error' }))
266
+ })
267
+ })
268
+
269
+ // 2. Admin SPA shell at /_nua/admin (and any sub-path under it — the SPA owns
270
+ // its own internal view-state navigation). Health-check the in-process
271
+ // sidecar before serving so a broken project surfaces a clear error rather
272
+ // than a blank SPA. The HTML is run through Vite's transform so the dev
273
+ // client + the virtual entry module resolve and HMR works.
274
+ server.middlewares.use((req, res, next) => {
275
+ const pathname = (req.url ?? '').split('?')[0] ?? ''
276
+ const isAdmin = pathname === ADMIN_ROUTE || pathname.startsWith(`${ADMIN_ROUTE}/`)
277
+ if (!isAdmin || (req.method !== 'GET' && req.method !== 'HEAD')) {
278
+ next()
279
+ return
280
+ }
281
+
282
+ const serve = async () => {
283
+ const sidecar = await getSidecar()
284
+ // Optional peers not installed — serve a clear placeholder rather than a
285
+ // blank/erroring SPA (cms-headless F6). Both halves of the pair are needed:
286
+ // the sidecar (API brain) and collections-admin (the SPA the shell mounts).
287
+ if (sidecar === null || !(await peerLoader.isCollectionsAdminInstalled())) {
288
+ res.statusCode = 501
289
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
290
+ res.end(ADMIN_PEERS_MISSING_MESSAGE)
291
+ return
292
+ }
293
+ const health = await sidecar.fetch(new Request('http://localhost/health'))
294
+ if (!health.ok) {
295
+ res.statusCode = 503
296
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
297
+ res.end('Nua CMS local sidecar is not healthy.')
298
+ return
299
+ }
300
+ const html = await server.transformIndexHtml(pathname, shell)
301
+ res.statusCode = 200
302
+ res.setHeader('content-type', 'text/html; charset=utf-8')
303
+ res.setHeader('cache-control', 'no-store')
304
+ res.end(html)
305
+ }
306
+
307
+ serve().catch((error) => {
308
+ console.error('[nua-cms] /_nua/admin serve error:', error)
309
+ if (!res.headersSent) {
310
+ res.statusCode = 500
311
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
312
+ }
313
+ res.end('Failed to load Nua CMS admin.')
314
+ })
315
+ })
316
+ }
package/src/mode.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * CMS run mode — `local` vs `hosted` (cms-headless F7).
3
+ *
4
+ * The marker plugin behaves differently by host:
5
+ *
6
+ * - **`local`** (default for `pletivo dev` on a developer machine): the plugin
7
+ * lazily spawns an in-process cms-sidecar over the project's `node:fs` and
8
+ * serves the `@nuasite/collections-admin` SPA at `/_nua/admin`. The inline
9
+ * widget runs in-page as usual.
10
+ * - **`hosted`** (inside the agent sandbox): the sidecar is a managed sandbox
11
+ * service (cms-headless F2) and the collections admin is a webmaster tab (F3),
12
+ * so the plugin must NOT spawn a sidecar and must NOT serve `/_nua/admin`. It
13
+ * stays a pure marker + CDN-inject plugin (F5).
14
+ *
15
+ * Detection is automatic and zero-config for local dev: the agent runtime sets a
16
+ * sandbox environment variable that the `pletivo dev` process inherits, which
17
+ * flips the mode to `hosted`. An explicit `mode` in the plugin config always
18
+ * wins over auto-detection.
19
+ */
20
+
21
+ export type CmsMode = 'local' | 'hosted'
22
+
23
+ /**
24
+ * Environment signals that mean "this `pletivo dev` runs inside the managed
25
+ * agent sandbox" (⇒ `hosted`):
26
+ *
27
+ * - `SANDBOX_ID` — set by the webmaster sandbox deployer when it starts the agent
28
+ * process (the Contember project id); every service the agent spawns, including
29
+ * `pletivo dev`, inherits it. It is never set on a developer machine. This is
30
+ * the canonical "we are in the sandbox" marker used across the agent runtime.
31
+ * - `CMS_SIDECAR_LOCAL_EDVABE` — set (to `'1'`) by the deployer for the local
32
+ * edvabe sandbox variant; the managed sidecar bundle is bind-mounted there.
33
+ * Also implies a managed sidecar, hence `hosted`.
34
+ */
35
+ const HOSTED_ENV_KEYS: readonly string[] = ['SANDBOX_ID', 'CMS_SIDECAR_LOCAL_EDVABE']
36
+
37
+ /** Whether any hosted-sandbox env signal is present (and non-empty). */
38
+ export function detectHostedFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
39
+ return HOSTED_ENV_KEYS.some((key) => {
40
+ const value = env[key]
41
+ return typeof value === 'string' && value.trim() !== ''
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Resolve the effective CMS mode. An explicit `override` (from the plugin config)
47
+ * always wins; otherwise the mode is auto-detected from the environment, defaulting
48
+ * to `local` when no sandbox signal is present.
49
+ */
50
+ export function resolveCmsMode(override?: CmsMode, env: NodeJS.ProcessEnv = process.env): CmsMode {
51
+ if (override !== undefined) return override
52
+ return detectHostedFromEnv(env) ? 'hosted' : 'local'
53
+ }
package/src/tsconfig.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "extends": "../tsconfig.settings.json",
3
+ "exclude": [
4
+ "admin"
5
+ ],
3
6
  "compilerOptions": {
4
7
  "outDir": "../dist/src",
5
8
  "composite": true,
@@ -22,6 +25,7 @@
22
25
  },
23
26
  "references": [
24
27
  { "path": "../../cms-core/src" },
25
- { "path": "../../cms-types/src" }
28
+ { "path": "../../cms-types/src" },
29
+ { "path": "../../cms-sidecar/src" },
26
30
  ]
27
31
  }
package/src/types.ts CHANGED
@@ -527,6 +527,13 @@ export type CmsPostMessage =
527
527
 
528
528
  export interface CmsFeatures {
529
529
  selectElement?: boolean
530
+ /**
531
+ * Controls the in-preview collection browser/management UI (browse collections,
532
+ * list/open entries). Defaults to enabled. When `false`, the widget hides this
533
+ * UI because collection editing is owned elsewhere (e.g. the webmaster
534
+ * Collections tab); inline text/image/color editing stays unaffected.
535
+ */
536
+ collectionManagement?: boolean
530
537
  }
531
538
 
532
539
  // ============================================================================