@nuasite/cms 0.42.1 → 0.43.0-beta.1
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/dist/editor.js +1 -1
- package/package.json +3 -1
- package/src/dev-middleware.ts +12 -1
- package/src/handlers/api-routes.ts +192 -48
- package/src/handlers/page-ops.ts +4 -189
- package/src/index.ts +9 -4
- package/src/media/types.ts +11 -55
- package/src/tsconfig.json +5 -1
- package/src/types.ts +31 -225
- package/src/handlers/markdown-ops.ts +0 -474
- package/src/handlers/redirect-ops.ts +0 -163
- package/src/media/contember.ts +0 -85
- package/src/media/local.ts +0 -152
- package/src/media/project-images.ts +0 -81
- package/src/media/s3.ts +0 -154
package/dist/editor.js
CHANGED
|
@@ -386,7 +386,7 @@ function fC(t, e) {
|
|
|
386
386
|
function mC(t, e) {
|
|
387
387
|
return typeof e == "function" ? e(t) : e;
|
|
388
388
|
}
|
|
389
|
-
const W3 = "0.
|
|
389
|
+
const W3 = "0.43.0-beta.1", K3 = W3, ct = {
|
|
390
390
|
/** Highlight overlay for hovered elements */
|
|
391
391
|
HIGHLIGHT: 2147483644,
|
|
392
392
|
/** Hover outline for elements/components */
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"directory": "packages/astro-cms"
|
|
15
15
|
},
|
|
16
16
|
"license": "Apache-2.0",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.43.0-beta.1",
|
|
18
18
|
"module": "src/index.ts",
|
|
19
19
|
"types": "src/index.ts",
|
|
20
20
|
"type": "module",
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"@nuasite/cms-core": "0.43.0-beta.1",
|
|
30
|
+
"@nuasite/cms-types": "0.43.0-beta.1",
|
|
29
31
|
"@astrojs/compiler": "^3.0.1",
|
|
30
32
|
"@babel/parser": "^7.29.2",
|
|
31
33
|
"node-html-parser": "^7.1.0",
|
package/src/dev-middleware.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createCmsCore, createNodeFs } from '@nuasite/cms-core'
|
|
1
2
|
import fs from 'node:fs/promises'
|
|
2
3
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -118,7 +119,15 @@ export function createDevMiddleware(
|
|
|
118
119
|
|
|
119
120
|
// CMS API endpoints (local dev server backend)
|
|
120
121
|
if (options.enableCmsApi) {
|
|
121
|
-
|
|
122
|
+
// One cms-core instance per project root. Structural routes (entry/page/redirect
|
|
123
|
+
// CRUD, media) delegate to this brain over a node:fs adapter; the media adapter
|
|
124
|
+
// and component dirs mirror the selection @nuasite/cms makes for the dev server.
|
|
125
|
+
const cmsFs = createNodeFs(getProjectRoot())
|
|
126
|
+
const core = createCmsCore(cmsFs, {
|
|
127
|
+
contentDir: config.contentDir,
|
|
128
|
+
media: options.mediaAdapter,
|
|
129
|
+
componentDirs: config.componentDirs,
|
|
130
|
+
})
|
|
122
131
|
|
|
123
132
|
server.middlewares.use((req, res, next) => {
|
|
124
133
|
const url = req.url || ''
|
|
@@ -136,6 +145,8 @@ export function createDevMiddleware(
|
|
|
136
145
|
res,
|
|
137
146
|
route,
|
|
138
147
|
manifestWriter,
|
|
148
|
+
core,
|
|
149
|
+
fs: cmsFs,
|
|
139
150
|
contentDir: config.contentDir,
|
|
140
151
|
mediaAdapter: options.mediaAdapter,
|
|
141
152
|
maxUploadSize: options.maxUploadSize,
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
+
import type { CmsCore, CmsFileSystem } from '@nuasite/cms-core'
|
|
2
|
+
import { listProjectImages } from '@nuasite/cms-core'
|
|
1
3
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
2
4
|
import path from 'node:path'
|
|
3
5
|
import { scanCollections } from '../collection-scanner'
|
|
4
6
|
import { getProjectRoot } from '../config'
|
|
5
7
|
import { expectedDeletions } from '../dev-middleware'
|
|
6
8
|
import type { ManifestWriter } from '../manifest-writer'
|
|
7
|
-
import { listProjectImages } from '../media/project-images'
|
|
8
9
|
import type { MediaStorageAdapter } from '../media/types'
|
|
9
10
|
import { handleAddArrayItem, handleRemoveArrayItem } from './array-ops'
|
|
10
11
|
import { tryAstroImageUpload } from './astro-image-upload'
|
|
11
12
|
import { handleInsertComponent, handleRemoveComponent } from './component-ops'
|
|
12
|
-
import {
|
|
13
|
-
import { handleCheckSlugExists, handleCreatePage, handleDeletePage, handleDuplicatePage, handleGetLayouts } from './page-ops'
|
|
14
|
-
import { handleAddRedirect, handleDeleteRedirect, handleGetRedirects, handleUpdateRedirect } from './redirect-ops'
|
|
13
|
+
import { handleCheckSlugExists } from './page-ops'
|
|
15
14
|
import { BodyTooLargeError, parseJsonBody, parseMultipartFile, readBody, sendError, sendJson } from './request-utils'
|
|
16
15
|
import { handleUpdate } from './source-writer'
|
|
17
16
|
|
|
@@ -20,6 +19,10 @@ export interface RouteContext {
|
|
|
20
19
|
res: ServerResponse
|
|
21
20
|
route: string
|
|
22
21
|
manifestWriter: ManifestWriter
|
|
22
|
+
/** The framework-agnostic brain. Structural routes delegate here. */
|
|
23
|
+
core: CmsCore
|
|
24
|
+
/** Raw FileSystem port (for the few helpers that scan the project directly). */
|
|
25
|
+
fs: CmsFileSystem
|
|
23
26
|
contentDir: string
|
|
24
27
|
mediaAdapter?: MediaStorageAdapter
|
|
25
28
|
maxUploadSize: number
|
|
@@ -39,6 +42,37 @@ function getQuery(ctx: RouteContext): URLSearchParams {
|
|
|
39
42
|
return new URL(ctx.req.url!, `http://${ctx.req.headers.host}`).searchParams
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Derive `{ collection, slug }` from a root-relative content-entry `filePath`.
|
|
47
|
+
*
|
|
48
|
+
* The dev API is addressed by `filePath` (e.g. `src/content/blog/hello.md`),
|
|
49
|
+
* while cms-core is addressed by `{ collection, slug }`. The collection is the
|
|
50
|
+
* first path segment under `contentDir`; the slug is the remainder with the
|
|
51
|
+
* extension stripped (and a trailing `/index` collapsed for index-layout
|
|
52
|
+
* entries). cms-core's path resolution is the exact inverse: a flat `<slug>.<ext>`
|
|
53
|
+
* resolves first, an index `<slug>/index.{md,mdx}` after — so the derived pair
|
|
54
|
+
* resolves back to the same file.
|
|
55
|
+
*/
|
|
56
|
+
function filePathToEntry(contentDir: string, filePath: string): { collection: string; slug: string } | null {
|
|
57
|
+
const normalized = filePath.replace(/^\/+/, '')
|
|
58
|
+
const prefix = `${contentDir.replace(/\/+$/, '')}/`
|
|
59
|
+
if (!normalized.startsWith(prefix)) return null
|
|
60
|
+
|
|
61
|
+
const rel = normalized.slice(prefix.length)
|
|
62
|
+
const firstSlash = rel.indexOf('/')
|
|
63
|
+
if (firstSlash < 0) return null
|
|
64
|
+
|
|
65
|
+
const collection = rel.slice(0, firstSlash)
|
|
66
|
+
const entryPath = rel.slice(firstSlash + 1)
|
|
67
|
+
if (!collection || !entryPath) return null
|
|
68
|
+
|
|
69
|
+
const withoutExt = entryPath.replace(/\.(md|mdx|json|yaml|yml)$/, '')
|
|
70
|
+
const slug = withoutExt.replace(/\/index$/, '')
|
|
71
|
+
if (!slug) return null
|
|
72
|
+
|
|
73
|
+
return { collection, slug }
|
|
74
|
+
}
|
|
75
|
+
|
|
42
76
|
// -- Route helper factories --
|
|
43
77
|
|
|
44
78
|
/** POST route: parse JSON body → handler(body, manifestWriter) → sendJson */
|
|
@@ -49,19 +83,19 @@ function post<T>(route: string, handler: (body: T, mw: ManifestWriter) => Promis
|
|
|
49
83
|
}]
|
|
50
84
|
}
|
|
51
85
|
|
|
52
|
-
/** POST route: parse JSON body → handler(body) → sendJson with success-based status */
|
|
53
|
-
function
|
|
54
|
-
return [`POST:${route}`, async ({ req, res }) => {
|
|
86
|
+
/** POST route through cms-core: parse JSON body → handler(body, core) → sendJson with success-based status */
|
|
87
|
+
function postCore<T>(route: string, handler: (body: T, core: CmsCore) => Promise<{ success: boolean }>): [string, RouteHandler] {
|
|
88
|
+
return [`POST:${route}`, async ({ req, res, core }) => {
|
|
55
89
|
const body = await parseJsonBody<T>(req)
|
|
56
|
-
const result = await handler(body)
|
|
90
|
+
const result = await handler(body, core)
|
|
57
91
|
sendJson(res, result, result.success ? 200 : 400)
|
|
58
92
|
}]
|
|
59
93
|
}
|
|
60
94
|
|
|
61
|
-
/** GET route: handler() → sendJson */
|
|
62
|
-
function
|
|
63
|
-
return [`GET:${route}`, async ({ res }) => {
|
|
64
|
-
sendJson(res, await handler())
|
|
95
|
+
/** GET route through cms-core: handler(core) → sendJson */
|
|
96
|
+
function getCore(route: string, handler: (core: CmsCore) => Promise<unknown>): [string, RouteHandler] {
|
|
97
|
+
return [`GET:${route}`, async ({ res, core }) => {
|
|
98
|
+
sendJson(res, await handler(core))
|
|
65
99
|
}]
|
|
66
100
|
}
|
|
67
101
|
|
|
@@ -82,54 +116,150 @@ const ALLOWED_UPLOAD_TYPES = new Set([
|
|
|
82
116
|
'application/pdf',
|
|
83
117
|
])
|
|
84
118
|
|
|
119
|
+
/** Frontmatter shape the create route enriches with title/date for markdown entries. */
|
|
120
|
+
interface CreateMarkdownBody {
|
|
121
|
+
collection: string
|
|
122
|
+
title: string
|
|
123
|
+
slug: string
|
|
124
|
+
frontmatter?: Record<string, unknown>
|
|
125
|
+
content?: string
|
|
126
|
+
fileExtension?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface UpdateMarkdownBody {
|
|
130
|
+
filePath: string
|
|
131
|
+
frontmatter?: Record<string, unknown>
|
|
132
|
+
content?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface DeleteMarkdownBody {
|
|
136
|
+
filePath: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface RenameMarkdownBody {
|
|
140
|
+
filePath: string
|
|
141
|
+
newSlug: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface DuplicatePageBody {
|
|
145
|
+
sourcePagePath: string
|
|
146
|
+
slug: string
|
|
147
|
+
title?: string
|
|
148
|
+
layoutPath?: string
|
|
149
|
+
createRedirect?: boolean
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface DeletePageBody {
|
|
153
|
+
pagePath: string
|
|
154
|
+
createRedirect?: boolean
|
|
155
|
+
redirectTo?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const DATA_EXTENSIONS = new Set(['json', 'yaml', 'yml'])
|
|
159
|
+
|
|
85
160
|
/** O(1) route lookup map: "METHOD:route" → handler */
|
|
86
161
|
const routeMap = new Map<string, RouteHandler>([
|
|
87
|
-
// Source editing
|
|
162
|
+
// Source editing — manifest-coupled, stays in @nuasite/cms
|
|
88
163
|
post('update', (body: Parameters<typeof handleUpdate>[0], mw) => handleUpdate(body, mw)),
|
|
89
164
|
post('insert-component', (body: Parameters<typeof handleInsertComponent>[0], mw) => handleInsertComponent(body, mw)),
|
|
90
165
|
post('remove-component', (body: Parameters<typeof handleRemoveComponent>[0], mw) => handleRemoveComponent(body, mw)),
|
|
91
166
|
post('add-array-item', (body: Parameters<typeof handleAddArrayItem>[0], mw) => handleAddArrayItem(body, mw)),
|
|
92
167
|
post('remove-array-item', (body: Parameters<typeof handleRemoveArrayItem>[0], mw) => handleRemoveArrayItem(body, mw)),
|
|
93
168
|
|
|
94
|
-
// Markdown CRUD
|
|
95
|
-
custom('GET', 'markdown/content', async ({ req, res }) => {
|
|
169
|
+
// Markdown / entry CRUD — structural, delegated to cms-core
|
|
170
|
+
custom('GET', 'markdown/content', async ({ req, res, core, contentDir }) => {
|
|
96
171
|
const filePath = getQuery({ req } as RouteContext).get('filePath')
|
|
97
172
|
if (!filePath) {
|
|
98
173
|
sendError(res, 'filePath query parameter required')
|
|
99
174
|
return
|
|
100
175
|
}
|
|
101
|
-
const
|
|
176
|
+
const entry = filePathToEntry(contentDir, filePath)
|
|
177
|
+
const result = entry ? await core.getEntry(entry.collection, entry.slug) : null
|
|
102
178
|
if (!result) {
|
|
103
179
|
sendError(res, 'File not found', 404)
|
|
104
180
|
return
|
|
105
181
|
}
|
|
106
|
-
sendJson(res, result)
|
|
182
|
+
sendJson(res, { content: result.content, frontmatter: result.frontmatter, filePath })
|
|
183
|
+
}),
|
|
184
|
+
custom('POST', 'markdown/update', async ({ req, res, core, contentDir }) => {
|
|
185
|
+
const body = await parseJsonBody<UpdateMarkdownBody>(req)
|
|
186
|
+
const entry = filePathToEntry(contentDir, body.filePath)
|
|
187
|
+
if (!entry) {
|
|
188
|
+
sendJson(res, { success: false, error: `Invalid content path: ${body.filePath}` })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
// cms-core's updateEntry resolves component definitions internally for MDX imports.
|
|
192
|
+
const result = await core.updateEntry({
|
|
193
|
+
collection: entry.collection,
|
|
194
|
+
slug: entry.slug,
|
|
195
|
+
frontmatter: body.frontmatter,
|
|
196
|
+
body: body.content,
|
|
197
|
+
})
|
|
198
|
+
sendJson(res, { success: result.success, ...(result.error ? { error: result.error } : {}) })
|
|
107
199
|
}),
|
|
108
|
-
custom('POST', 'markdown/
|
|
109
|
-
const body = await parseJsonBody<
|
|
110
|
-
const
|
|
111
|
-
|
|
200
|
+
custom('POST', 'markdown/rename', async ({ req, res, core, contentDir }) => {
|
|
201
|
+
const body = await parseJsonBody<RenameMarkdownBody>(req)
|
|
202
|
+
const entry = filePathToEntry(contentDir, body.filePath)
|
|
203
|
+
if (!entry) {
|
|
204
|
+
sendJson(res, { success: false, error: `Invalid content path: ${body.filePath}` })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
const result = await core.renameEntry(entry.collection, entry.slug, body.newSlug)
|
|
208
|
+
if (!result.success) {
|
|
209
|
+
sendJson(res, { success: false, error: result.error })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
const newSlug = result.sourcePath ? lastSlug(result.sourcePath) : undefined
|
|
213
|
+
sendJson(res, { success: true, newFilePath: result.sourcePath, newSlug })
|
|
112
214
|
}),
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
215
|
+
custom('POST', 'markdown/create', async ({ req, res, core, manifestWriter, contentDir }) => {
|
|
216
|
+
const body = await parseJsonBody<CreateMarkdownBody>(req)
|
|
217
|
+
const ext = body.fileExtension ?? 'md'
|
|
218
|
+
const isData = DATA_EXTENSIONS.has(ext)
|
|
219
|
+
// Markdown entries get title + an ISO date injected; data entries take frontmatter verbatim.
|
|
220
|
+
const frontmatter = isData
|
|
221
|
+
? { ...(body.frontmatter ?? {}) }
|
|
222
|
+
: { title: body.title, date: new Date().toISOString().split('T')[0]!, ...(body.frontmatter ?? {}) }
|
|
223
|
+
|
|
224
|
+
const result = await core.createEntry({
|
|
225
|
+
collection: body.collection,
|
|
226
|
+
slug: body.slug || body.title,
|
|
227
|
+
frontmatter,
|
|
228
|
+
body: body.content,
|
|
229
|
+
fileExtension: body.fileExtension,
|
|
230
|
+
})
|
|
231
|
+
|
|
117
232
|
if (result.success) {
|
|
118
233
|
manifestWriter.setCollectionDefinitions(await scanCollections(contentDir))
|
|
119
234
|
}
|
|
120
|
-
|
|
235
|
+
const slug = result.sourcePath ? lastSlug(result.sourcePath) : undefined
|
|
236
|
+
sendJson(
|
|
237
|
+
res,
|
|
238
|
+
{
|
|
239
|
+
success: result.success,
|
|
240
|
+
...(result.sourcePath ? { filePath: result.sourcePath } : {}),
|
|
241
|
+
...(slug ? { slug } : {}),
|
|
242
|
+
...(result.error ? { error: result.error } : {}),
|
|
243
|
+
},
|
|
244
|
+
result.success ? 200 : 400,
|
|
245
|
+
)
|
|
121
246
|
}),
|
|
122
|
-
custom('POST', 'markdown/delete', async ({ req, res, manifestWriter, contentDir }) => {
|
|
123
|
-
const body = await parseJsonBody<
|
|
124
|
-
const
|
|
247
|
+
custom('POST', 'markdown/delete', async ({ req, res, core, manifestWriter, contentDir }) => {
|
|
248
|
+
const body = await parseJsonBody<DeleteMarkdownBody>(req)
|
|
249
|
+
const entry = filePathToEntry(contentDir, body.filePath)
|
|
250
|
+
if (!entry) {
|
|
251
|
+
sendJson(res, { success: false, error: `Invalid content path: ${body.filePath}` }, 400)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
const fullPath = path.resolve(getProjectRoot(), body.filePath.replace(/^\//, ''))
|
|
125
255
|
expectedDeletions.add(fullPath)
|
|
126
|
-
const result = await
|
|
256
|
+
const result = await core.deleteEntry(entry.collection, entry.slug)
|
|
127
257
|
if (result.success) {
|
|
128
258
|
manifestWriter.setCollectionDefinitions(await scanCollections(contentDir))
|
|
129
259
|
} else {
|
|
130
260
|
expectedDeletions.delete(fullPath)
|
|
131
261
|
}
|
|
132
|
-
sendJson(res, result, result.success ? 200 : 400)
|
|
262
|
+
sendJson(res, { success: result.success, ...(result.error ? { error: result.error } : {}) }, result.success ? 200 : 400)
|
|
133
263
|
}),
|
|
134
264
|
|
|
135
265
|
// Media
|
|
@@ -143,7 +273,7 @@ const routeMap = new Map<string, RouteHandler>([
|
|
|
143
273
|
}),
|
|
144
274
|
custom('GET', 'media/project-images', async (ctx) => {
|
|
145
275
|
const excludeDir = ctx.mediaAdapter?.staticFiles?.dir
|
|
146
|
-
const items = await listProjectImages({ excludeDir })
|
|
276
|
+
const items = await listProjectImages(ctx.fs, { excludeDir })
|
|
147
277
|
sendJson(ctx.res, { items })
|
|
148
278
|
}),
|
|
149
279
|
custom('POST', 'media/upload', async (ctx) => {
|
|
@@ -225,24 +355,24 @@ const routeMap = new Map<string, RouteHandler>([
|
|
|
225
355
|
sendJson(ctx.res, result, result.success ? 200 : 400)
|
|
226
356
|
}),
|
|
227
357
|
|
|
228
|
-
// Page operations
|
|
229
|
-
|
|
230
|
-
custom('POST', 'page/duplicate', async ({ req, res }) => {
|
|
231
|
-
const body = await parseJsonBody<
|
|
232
|
-
const result = await
|
|
358
|
+
// Page operations — structural, delegated to cms-core
|
|
359
|
+
postCore('page/create', (body: Parameters<CmsCore['createPage']>[0], core) => core.createPage(body)),
|
|
360
|
+
custom('POST', 'page/duplicate', async ({ req, res, core }) => {
|
|
361
|
+
const body = await parseJsonBody<DuplicatePageBody>(req)
|
|
362
|
+
const result = await core.duplicatePage(body)
|
|
233
363
|
if (result.success && body.createRedirect) {
|
|
234
|
-
await
|
|
364
|
+
await core.addRedirect({ source: body.sourcePagePath, destination: result.url!, statusCode: 307 })
|
|
235
365
|
}
|
|
236
366
|
sendJson(res, result, result.success ? 200 : 400)
|
|
237
367
|
}),
|
|
238
|
-
custom('POST', 'page/delete', async ({ req, res }) => {
|
|
239
|
-
const body = await parseJsonBody<
|
|
240
|
-
const result = await
|
|
368
|
+
custom('POST', 'page/delete', async ({ req, res, core }) => {
|
|
369
|
+
const body = await parseJsonBody<DeletePageBody>(req)
|
|
370
|
+
const result = await core.deletePage(body)
|
|
241
371
|
if (result.success && result.filePath) {
|
|
242
372
|
expectedDeletions.add(path.resolve(getProjectRoot(), result.filePath))
|
|
243
373
|
}
|
|
244
374
|
if (result.success && body.createRedirect && body.redirectTo) {
|
|
245
|
-
await
|
|
375
|
+
await core.addRedirect({ source: body.pagePath, destination: body.redirectTo, statusCode: 307 })
|
|
246
376
|
}
|
|
247
377
|
sendJson(res, result, result.success ? 200 : 400)
|
|
248
378
|
}),
|
|
@@ -254,18 +384,32 @@ const routeMap = new Map<string, RouteHandler>([
|
|
|
254
384
|
}
|
|
255
385
|
sendJson(ctx.res, await handleCheckSlugExists(slug))
|
|
256
386
|
}),
|
|
257
|
-
|
|
387
|
+
getCore('page/layouts', async (core) => ({ layouts: await core.getLayouts() })),
|
|
258
388
|
|
|
259
|
-
// Redirects
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
389
|
+
// Redirects — structural, delegated to cms-core
|
|
390
|
+
getCore('redirects', (core) => core.listRedirects()),
|
|
391
|
+
postCore('redirects/add', (body: Parameters<CmsCore['addRedirect']>[0], core) => core.addRedirect(body)),
|
|
392
|
+
postCore('redirects/update', (body: Parameters<CmsCore['updateRedirect']>[0], core) => core.updateRedirect(body)),
|
|
393
|
+
postCore('redirects/delete', (body: Parameters<CmsCore['deleteRedirect']>[0], core) => core.deleteRedirect(body)),
|
|
264
394
|
|
|
265
395
|
// Deployment
|
|
266
396
|
get('deployment/status', async () => ({ currentDeployment: null, pendingCount: 0, deploymentEnabled: false })),
|
|
267
397
|
])
|
|
268
398
|
|
|
399
|
+
/** GET route returning a fixed/static payload (no cms-core needed). */
|
|
400
|
+
function get(route: string, handler: () => Promise<unknown>): [string, RouteHandler] {
|
|
401
|
+
return [`GET:${route}`, async ({ res }) => {
|
|
402
|
+
sendJson(res, await handler())
|
|
403
|
+
}]
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Last path segment of a root-relative source path, with the extension and a trailing `/index` stripped. */
|
|
407
|
+
function lastSlug(sourcePath: string): string {
|
|
408
|
+
const withoutExt = sourcePath.replace(/\.(md|mdx|json|yaml|yml)$/, '').replace(/\/index$/, '')
|
|
409
|
+
const slash = withoutExt.lastIndexOf('/')
|
|
410
|
+
return slash >= 0 ? withoutExt.slice(slash + 1) : withoutExt
|
|
411
|
+
}
|
|
412
|
+
|
|
269
413
|
export async function handleCmsApiRoute(ctx: RouteContext): Promise<void> {
|
|
270
414
|
const { req, res, route } = ctx
|
|
271
415
|
|
package/src/handlers/page-ops.ts
CHANGED
|
@@ -1,103 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
-
import
|
|
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
|
-
*
|
|
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
|
@@ -4,6 +4,7 @@ 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'
|
|
@@ -11,7 +12,6 @@ import { resetProjectRoot } from './config'
|
|
|
11
12
|
import { createDevMiddleware } from './dev-middleware'
|
|
12
13
|
import { getErrorCollector, resetErrorCollector } from './error-collector'
|
|
13
14
|
import { ManifestWriter } from './manifest-writer'
|
|
14
|
-
import { createLocalStorageAdapter } from './media/local'
|
|
15
15
|
import type { MediaStorageAdapter } from './media/types'
|
|
16
16
|
import { rehypeCmsMarker } from './rehype-cms-marker'
|
|
17
17
|
import type { CmsFeatures, CmsMarkerOptions, ComponentDefinition } from './types'
|
|
@@ -391,11 +391,16 @@ async function mergeRedirects(dir: URL, logger: { info: (msg: string) => void })
|
|
|
391
391
|
logger.info(`Merged ${lineCount} CMS redirect(s) into _redirects`)
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
// Shared structural contract from @nuasite/cms-types — surfaced through the cms public API
|
|
395
|
+
// so consumers of @nuasite/cms get the field-type list + guard from one place.
|
|
396
|
+
export {
|
|
397
|
+
createContemberStorageAdapter as contemberMedia,
|
|
398
|
+
createLocalStorageAdapter as localMedia,
|
|
399
|
+
createS3StorageAdapter as s3Media,
|
|
400
|
+
} from '@nuasite/cms-core'
|
|
401
|
+
export { FIELD_TYPES, isFieldType } from '@nuasite/cms-types'
|
|
394
402
|
export { n } from './field-types'
|
|
395
403
|
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
404
|
export type { MediaFolderItem, MediaItem, MediaListOptions, MediaListResult, MediaStorageAdapter, MediaTypeFilter } from './media/types'
|
|
400
405
|
export type { Color, Date, DateTime, Email, Image, Reference, Textarea, Time, Url } from './prop-types'
|
|
401
406
|
|