@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 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.42.1", K3 = W3, ct = {
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.42.1",
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",
@@ -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
- const projectRoot = getProjectRoot()
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 { handleCreateMarkdown, handleDeleteMarkdown, handleGetMarkdownContent, handleRenameMarkdown, handleUpdateMarkdown } from './markdown-ops'
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 postWithStatus<T>(route: string, handler: (body: T) => Promise<{ success: boolean }>): [string, RouteHandler] {
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 get(route: string, handler: () => Promise<unknown>): [string, RouteHandler] {
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 result = await handleGetMarkdownContent(filePath)
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/update', async ({ req, res, manifestWriter }) => {
109
- const body = await parseJsonBody<Parameters<typeof handleUpdateMarkdown>[0]>(req)
110
- const result = await handleUpdateMarkdown(body, manifestWriter.getComponentDefinitions())
111
- sendJson(res, result)
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
- post('markdown/rename', (body: Parameters<typeof handleRenameMarkdown>[0]) => handleRenameMarkdown(body)),
114
- custom('POST', 'markdown/create', async ({ req, res, manifestWriter, contentDir }) => {
115
- const body = await parseJsonBody<Parameters<typeof handleCreateMarkdown>[0]>(req)
116
- const result = await handleCreateMarkdown(body)
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
- sendJson(res, result, result.success ? 200 : 400)
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<Parameters<typeof handleDeleteMarkdown>[0]>(req)
124
- const fullPath = path.resolve(getProjectRoot(), body.filePath?.replace(/^\//, '') ?? '')
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 handleDeleteMarkdown(body)
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
- postWithStatus('page/create', (body: Parameters<typeof handleCreatePage>[0]) => handleCreatePage(body)),
230
- custom('POST', 'page/duplicate', async ({ req, res }) => {
231
- const body = await parseJsonBody<Parameters<typeof handleDuplicatePage>[0]>(req)
232
- const result = await handleDuplicatePage(body)
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 handleAddRedirect({ source: body.sourcePagePath, destination: result.url!, statusCode: 307 })
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<Parameters<typeof handleDeletePage>[0]>(req)
240
- const result = await handleDeletePage(body)
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 handleAddRedirect({ source: body.pagePath, destination: body.redirectTo, statusCode: 307 })
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
- get('page/layouts', async () => ({ layouts: await handleGetLayouts() })),
387
+ getCore('page/layouts', async (core) => ({ layouts: await core.getLayouts() })),
258
388
 
259
- // Redirects
260
- get('redirects', () => handleGetRedirects()),
261
- postWithStatus('redirects/add', (body: Parameters<typeof handleAddRedirect>[0]) => handleAddRedirect(body)),
262
- postWithStatus('redirects/update', (body: Parameters<typeof handleUpdateRedirect>[0]) => handleUpdateRedirect(body)),
263
- postWithStatus('redirects/delete', (body: Parameters<typeof handleDeleteRedirect>[0]) => handleDeleteRedirect(body)),
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
 
@@ -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
@@ -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